Skip to content

Commit 0a09ce0

Browse files
committed
feat: chunked uploads for large files + real progress bar
- 80MB chunks (under CF free plan 100MB limit) - R2 multipart upload to reassemble - Real progress tracking per chunk - Supports files up to 1GB on free plan
1 parent e8a8ad1 commit 0a09ce0

4 files changed

Lines changed: 183 additions & 83 deletions

File tree

src/api.ts

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const WORKER_URL = import.meta.env.VITE_WORKER_URL || ''
2+
const CHUNK_SIZE = 80 * 1024 * 1024 // 80MB per chunk (under CF 100MB limit)
23

34
interface UploadResult {
45
id: string
@@ -9,30 +10,53 @@ export async function uploadVideo(
910
file: File,
1011
onProgress: (progress: number) => void
1112
): Promise<UploadResult> {
12-
// Start with indeterminate progress since Cloudflare buffers uploads
13-
onProgress(-1)
13+
// Step 1: Initialize upload
14+
const initRes = await fetch(`${WORKER_URL}/upload/init`, {
15+
method: 'POST',
16+
headers: { 'Content-Type': 'application/json' },
17+
body: JSON.stringify({
18+
filename: file.name,
19+
contentType: file.type || 'video/mp4',
20+
size: file.size,
21+
}),
22+
})
23+
24+
if (!initRes.ok) {
25+
const err = await initRes.json().catch(() => ({ error: 'Upload init failed' }))
26+
throw new Error((err as { error: string }).error || `Init failed (${initRes.status})`)
27+
}
28+
29+
const { id, totalChunks } = await initRes.json() as { id: string; totalChunks: number }
30+
31+
// Step 2: Upload chunks
32+
for (let i = 0; i < totalChunks; i++) {
33+
const start = i * CHUNK_SIZE
34+
const end = Math.min(start + CHUNK_SIZE, file.size)
35+
const chunk = file.slice(start, end)
36+
37+
const res = await fetch(`${WORKER_URL}/upload/${id}/chunk/${i}`, {
38+
method: 'PUT',
39+
headers: { 'Content-Type': 'application/octet-stream' },
40+
body: chunk,
41+
})
42+
43+
if (!res.ok) {
44+
throw new Error(`Chunk ${i + 1}/${totalChunks} failed (${res.status})`)
45+
}
46+
47+
onProgress(Math.round(((i + 1) / totalChunks) * 100))
48+
}
1449

15-
const response = await fetch(`${WORKER_URL}/upload`, {
50+
// Step 3: Finalize
51+
const finalRes = await fetch(`${WORKER_URL}/upload/${id}/finalize`, {
1652
method: 'POST',
17-
headers: {
18-
'Content-Type': file.type || 'video/mp4',
19-
'X-Filename': encodeURIComponent(file.name),
20-
},
21-
body: file,
2253
})
2354

24-
if (!response.ok) {
25-
const text = await response.text()
26-
let message = `Upload failed (${response.status})`
27-
try {
28-
const json = JSON.parse(text)
29-
if (json.error) message = json.error
30-
} catch { /* ignore */ }
31-
throw new Error(message)
55+
if (!finalRes.ok) {
56+
throw new Error('Failed to finalize upload')
3257
}
3358

34-
onProgress(100)
35-
return response.json()
59+
return finalRes.json()
3660
}
3761

3862
export async function deleteVideo(id: string): Promise<void> {

src/components/UploadProgress.css

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,22 +35,7 @@
3535
height: 100%;
3636
background: #4a9eff;
3737
border-radius: 3px;
38-
transition: width 0.2s;
39-
}
40-
41-
.progress-bar.indeterminate::after {
42-
content: '';
43-
display: block;
44-
height: 100%;
45-
width: 40%;
46-
background: #4a9eff;
47-
border-radius: 3px;
48-
animation: indeterminate 1.5s ease-in-out infinite;
49-
}
50-
51-
@keyframes indeterminate {
52-
0% { transform: translateX(-100%); }
53-
100% { transform: translateX(350%); }
38+
transition: width 0.3s ease;
5439
}
5540

5641
.progress-text {

src/components/UploadProgress.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,14 @@ interface UploadProgressProps {
66
}
77

88
export function UploadProgress({ filename, progress }: UploadProgressProps) {
9-
const indeterminate = progress < 0
10-
119
return (
1210
<div className="upload-progress">
1311
<div className="upload-icon">☁️</div>
1412
<div className="upload-filename">{filename}</div>
15-
<div className={`progress-bar ${indeterminate ? 'indeterminate' : ''}`}>
16-
{!indeterminate && (
17-
<div className="progress-fill" style={{ width: `${progress}%` }} />
18-
)}
19-
</div>
20-
<div className="progress-text">
21-
{indeterminate ? 'Uploading…' : `${progress}%`}
13+
<div className="progress-bar">
14+
<div className="progress-fill" style={{ width: `${progress}%` }} />
2215
</div>
16+
<div className="progress-text">{progress}%</div>
2317
</div>
2418
)
2519
}

0 commit comments

Comments
 (0)