Skip to content

Commit aad90db

Browse files
davindicodeclaude
andcommitted
Chunked upload for large files to bypass Cloudflare 100MB limit
Files under 80MB upload directly (single request). Files over 80MB are split into 80MB chunks, uploaded individually, then assembled on the server via a finalize endpoint. This bypasses Cloudflare's per-request body size limit for free Quick Tunnels. - /api/files/upload-chunk: receives individual chunks to temp dir - /api/files/upload-finalize: assembles chunks into destination file, cleans up temp chunks - Client tracks overall progress across chunks - Cancel works per-file, aborting active chunk and skipping remaining Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d93bec0 commit aad90db

3 files changed

Lines changed: 285 additions & 32 deletions

File tree

app/components/files/FilesPage.tsx

Lines changed: 96 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -245,53 +245,119 @@ function FileSessionView({ session }: { session: FileSession }) {
245245
setUploadQueue((q) => q.map((f, i) => i === index && f.status !== "done" ? { ...f, status: "cancelled", progress: 0 } : f));
246246
};
247247

248-
const uploadFile = (file: File, index: number): Promise<void> => {
249-
if (cancelledIndices.current.has(index)) return Promise.resolve();
248+
const CHUNK_SIZE = 80 * 1024 * 1024; // 80MB — under Cloudflare's 100MB limit
250249

250+
const uploadChunk = (blob: Blob, uploadId: string, chunkIndex: number, index: number): Promise<boolean> => {
251251
return new Promise((resolve) => {
252252
const formData = new FormData();
253-
formData.append("file", file);
254-
formData.append("dir", cwd);
253+
formData.append("uploadId", uploadId);
254+
formData.append("chunkIndex", String(chunkIndex));
255+
formData.append("chunk", blob);
255256

256257
const xhr = new XMLHttpRequest();
257258
uploadXhrs.current.set(index, xhr);
258259

259-
xhr.upload.onprogress = (e) => {
260-
if (e.lengthComputable) {
261-
const pct = Math.round((e.loaded / e.total) * 100);
262-
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, progress: pct } : f));
263-
}
264-
};
265260
xhr.onload = () => {
266261
uploadXhrs.current.delete(index);
267262
try {
268263
const data = JSON.parse(xhr.responseText);
269-
if (data.error) {
270-
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "error", error: data.error } : f));
271-
} else {
272-
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "done", progress: 100 } : f));
273-
}
274-
} catch {
275-
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "done", progress: 100 } : f));
276-
}
277-
resolve();
278-
};
279-
xhr.onerror = () => {
280-
uploadXhrs.current.delete(index);
281-
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "error", error: "Network error" } : f));
282-
resolve();
283-
};
284-
xhr.onabort = () => {
285-
uploadXhrs.current.delete(index);
286-
resolve();
264+
resolve(!data.error);
265+
} catch { resolve(false); }
287266
};
267+
xhr.onerror = () => { uploadXhrs.current.delete(index); resolve(false); };
268+
xhr.onabort = () => { uploadXhrs.current.delete(index); resolve(false); };
288269

289-
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "uploading" } : f));
290-
xhr.open("POST", "/api/files/upload");
270+
xhr.open("POST", "/api/files/upload-chunk");
291271
xhr.send(formData);
292272
});
293273
};
294274

275+
const uploadFile = async (file: File, index: number): Promise<void> => {
276+
if (cancelledIndices.current.has(index)) return;
277+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "uploading" } : f));
278+
279+
// Small files: direct upload
280+
if (file.size <= CHUNK_SIZE) {
281+
return new Promise((resolve) => {
282+
const formData = new FormData();
283+
formData.append("file", file);
284+
formData.append("dir", cwd);
285+
286+
const xhr = new XMLHttpRequest();
287+
uploadXhrs.current.set(index, xhr);
288+
289+
xhr.upload.onprogress = (e) => {
290+
if (e.lengthComputable) {
291+
const pct = Math.round((e.loaded / e.total) * 100);
292+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, progress: pct } : f));
293+
}
294+
};
295+
xhr.onload = () => {
296+
uploadXhrs.current.delete(index);
297+
try {
298+
const data = JSON.parse(xhr.responseText);
299+
if (data.error) {
300+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "error", error: data.error } : f));
301+
} else {
302+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "done", progress: 100 } : f));
303+
}
304+
} catch {
305+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "done", progress: 100 } : f));
306+
}
307+
resolve();
308+
};
309+
xhr.onerror = () => {
310+
uploadXhrs.current.delete(index);
311+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "error", error: "Network error" } : f));
312+
resolve();
313+
};
314+
xhr.onabort = () => { uploadXhrs.current.delete(index); resolve(); };
315+
316+
xhr.open("POST", "/api/files/upload");
317+
xhr.send(formData);
318+
});
319+
}
320+
321+
// Large files: chunked upload
322+
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
323+
const uploadId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
324+
325+
for (let c = 0; c < totalChunks; c++) {
326+
if (cancelledIndices.current.has(index)) {
327+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "cancelled", progress: 0 } : f));
328+
return;
329+
}
330+
const start = c * CHUNK_SIZE;
331+
const end = Math.min(start + CHUNK_SIZE, file.size);
332+
const blob = file.slice(start, end);
333+
334+
const ok = await uploadChunk(blob, uploadId, c, index);
335+
if (!ok) {
336+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "error", error: `Chunk ${c + 1}/${totalChunks} failed` } : f));
337+
return;
338+
}
339+
const pct = Math.round(((c + 1) / totalChunks) * 100);
340+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, progress: pct } : f));
341+
}
342+
343+
// Finalize: assemble chunks on server
344+
try {
345+
const res = await fetch("/api/files/upload-finalize", {
346+
method: "POST",
347+
headers: { "Content-Type": "application/json" },
348+
body: JSON.stringify({ uploadId, dir: cwd, fileName: file.name, totalChunks }),
349+
});
350+
const data = await res.json();
351+
if (data.error) {
352+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "error", error: data.error } : f));
353+
} else {
354+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "done", progress: 100 } : f));
355+
}
356+
} catch (err: any) {
357+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "error", error: err.message } : f));
358+
}
359+
};
360+
295361
const handleUpload = async (files: FileList | null) => {
296362
if (!files || files.length === 0) return;
297363
cancelledIndices.current.clear();

server/dev.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import "dotenv/config";
22
import express from "express";
33
import { createServer } from "http";
4-
import { createWriteStream } from "fs";
4+
import { createWriteStream, createReadStream, unlinkSync, mkdirSync, existsSync, readdirSync } from "fs";
55
import { join } from "path";
6+
import { tmpdir } from "os";
67
import Busboy from "busboy";
78
import { Server as SocketIOServer } from "socket.io";
89
import { registerSocketHandlers } from "./socket-handlers.js";
@@ -78,6 +79,97 @@ async function main() {
7879
req.pipe(busboy);
7980
});
8081

82+
// Chunked upload: receive individual chunks
83+
const chunksDir = join(tmpdir(), "otgcode-chunks");
84+
if (!existsSync(chunksDir)) mkdirSync(chunksDir, { recursive: true });
85+
86+
app.post("/api/files/upload-chunk", (req, res) => {
87+
req.setTimeout(0);
88+
let uploadId = "";
89+
let chunkIndex = "";
90+
91+
const busboy = Busboy({ headers: req.headers });
92+
93+
busboy.on("field", (name: string, val: string) => {
94+
if (name === "uploadId") uploadId = val;
95+
if (name === "chunkIndex") chunkIndex = val;
96+
});
97+
98+
busboy.on("file", (_name: string, stream: NodeJS.ReadableStream) => {
99+
if (!uploadId || chunkIndex === "") {
100+
stream.resume();
101+
return;
102+
}
103+
const uploadDir = join(chunksDir, uploadId);
104+
if (!existsSync(uploadDir)) mkdirSync(uploadDir, { recursive: true });
105+
const chunkFile = join(uploadDir, `chunk_${chunkIndex.padStart(6, "0")}`);
106+
const ws = createWriteStream(chunkFile);
107+
stream.pipe(ws);
108+
109+
ws.on("error", (err: Error) => {
110+
if (!res.headersSent) res.status(500).json({ error: err.message });
111+
});
112+
ws.on("finish", () => {
113+
if (!res.headersSent) res.json({ success: true, chunk: parseInt(chunkIndex, 10) });
114+
});
115+
});
116+
117+
busboy.on("error", (err: Error) => {
118+
if (!res.headersSent) res.status(500).json({ error: err.message });
119+
});
120+
121+
req.pipe(busboy);
122+
});
123+
124+
app.post("/api/files/upload-finalize", express.json(), (req, res) => {
125+
const { uploadId, dir, fileName, totalChunks } = req.body as {
126+
uploadId: string; dir: string; fileName: string; totalChunks: number;
127+
};
128+
129+
if (!uploadId || !dir || !fileName || !totalChunks) {
130+
res.status(400).json({ error: "Missing required fields" });
131+
return;
132+
}
133+
134+
const uploadDir = join(chunksDir, uploadId);
135+
const dest = join(dir, fileName);
136+
137+
try {
138+
const ws = createWriteStream(dest);
139+
let i = 0;
140+
141+
const writeNext = () => {
142+
if (i >= totalChunks) {
143+
ws.end(() => {
144+
try {
145+
const files = readdirSync(uploadDir);
146+
for (const f of files) unlinkSync(join(uploadDir, f));
147+
require("fs").rmdirSync(uploadDir);
148+
} catch {}
149+
res.json({ success: true, path: dest });
150+
});
151+
return;
152+
}
153+
const chunkPath = join(uploadDir, `chunk_${String(i).padStart(6, "0")}`);
154+
const rs = createReadStream(chunkPath);
155+
rs.on("error", (err: Error) => {
156+
ws.destroy();
157+
if (!res.headersSent) res.status(500).json({ error: `Chunk ${i} missing: ${err.message}` });
158+
});
159+
rs.pipe(ws, { end: false });
160+
rs.on("end", () => { i++; writeNext(); });
161+
};
162+
163+
ws.on("error", (err: Error) => {
164+
if (!res.headersSent) res.status(500).json({ error: err.message });
165+
});
166+
167+
writeNext();
168+
} catch (err: any) {
169+
res.status(500).json({ error: err.message });
170+
}
171+
});
172+
81173
// Reverse proxy
82174
mountProxy(app, httpServer, PORT);
83175

server/index.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import "dotenv/config";
22
import express from "express";
33
import { createServer } from "http";
4-
import { createWriteStream } from "fs";
4+
import { createWriteStream, createReadStream, unlinkSync, mkdirSync, existsSync, readdirSync } from "fs";
55
import { join } from "path";
6+
import { tmpdir } from "os";
67
import Busboy from "busboy";
78
import { Server as SocketIOServer } from "socket.io";
89
import { registerSocketHandlers } from "./socket-handlers.js";
@@ -82,6 +83,100 @@ async function main() {
8283
req.pipe(busboy);
8384
});
8485

86+
// Chunked upload: receive individual chunks
87+
const chunksDir = join(tmpdir(), "otgcode-chunks");
88+
if (!existsSync(chunksDir)) mkdirSync(chunksDir, { recursive: true });
89+
90+
app.post("/api/files/upload-chunk", (req, res) => {
91+
req.setTimeout(0);
92+
let uploadId = "";
93+
let chunkIndex = "";
94+
let chunkFile = "";
95+
96+
const busboy = Busboy({ headers: req.headers });
97+
98+
busboy.on("field", (name: string, val: string) => {
99+
if (name === "uploadId") uploadId = val;
100+
if (name === "chunkIndex") chunkIndex = val;
101+
});
102+
103+
busboy.on("file", (_name: string, stream: NodeJS.ReadableStream) => {
104+
if (!uploadId || chunkIndex === "") {
105+
stream.resume();
106+
return;
107+
}
108+
const uploadDir = join(chunksDir, uploadId);
109+
if (!existsSync(uploadDir)) mkdirSync(uploadDir, { recursive: true });
110+
chunkFile = join(uploadDir, `chunk_${chunkIndex.padStart(6, "0")}`);
111+
const ws = createWriteStream(chunkFile);
112+
stream.pipe(ws);
113+
114+
ws.on("error", (err: Error) => {
115+
if (!res.headersSent) res.status(500).json({ error: err.message });
116+
});
117+
ws.on("finish", () => {
118+
if (!res.headersSent) res.json({ success: true, chunk: parseInt(chunkIndex, 10) });
119+
});
120+
});
121+
122+
busboy.on("error", (err: Error) => {
123+
if (!res.headersSent) res.status(500).json({ error: err.message });
124+
});
125+
126+
req.pipe(busboy);
127+
});
128+
129+
// Chunked upload: finalize — assemble chunks into destination file
130+
app.post("/api/files/upload-finalize", express.json(), (req, res) => {
131+
const { uploadId, dir, fileName, totalChunks } = req.body as {
132+
uploadId: string; dir: string; fileName: string; totalChunks: number;
133+
};
134+
135+
if (!uploadId || !dir || !fileName || !totalChunks) {
136+
res.status(400).json({ error: "Missing required fields" });
137+
return;
138+
}
139+
140+
const uploadDir = join(chunksDir, uploadId);
141+
const dest = join(dir, fileName);
142+
143+
try {
144+
const ws = createWriteStream(dest);
145+
let i = 0;
146+
147+
const writeNext = () => {
148+
if (i >= totalChunks) {
149+
ws.end(() => {
150+
// Clean up chunks
151+
try {
152+
const files = readdirSync(uploadDir);
153+
for (const f of files) unlinkSync(join(uploadDir, f));
154+
require("fs").rmdirSync(uploadDir);
155+
} catch {}
156+
res.json({ success: true, path: dest });
157+
});
158+
return;
159+
}
160+
const chunkPath = join(uploadDir, `chunk_${String(i).padStart(6, "0")}`);
161+
const rs = createReadStream(chunkPath);
162+
rs.on("error", (err: Error) => {
163+
ws.destroy();
164+
if (!res.headersSent) res.status(500).json({ error: `Chunk ${i} missing: ${err.message}` });
165+
});
166+
rs.pipe(ws, { end: false });
167+
rs.on("end", () => { i++; writeNext(); });
168+
};
169+
170+
ws.on("error", (err: Error) => {
171+
if (!res.headersSent) res.status(500).json({ error: err.message });
172+
});
173+
174+
writeNext();
175+
} catch (err: any) {
176+
res.status(500).json({ error: err.message });
177+
}
178+
});
179+
85180
// Reverse proxy (before RR handler so it gets priority)
86181
mountProxy(app, httpServer, PORT);
87182

0 commit comments

Comments
 (0)