const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024; const CHUNKED_THRESHOLD = 5 * 1024 * 1024; /** * @param {File} file * @param {{ * chunkSize?: number, * initUrl: string, * chunkUrl: string, * finalizeUrl: string, * csrfToken?: string, * extraInit?: Record, * extraChunk?: Record, * extraFinalize?: Record, * onProgress?: (percent: number) => void, * }} options */ export async function uploadFileChunked(file, options) { const chunkSize = options.chunkSize || DEFAULT_CHUNK_SIZE; const totalChunks = Math.ceil(file.size / chunkSize); const maxRetries = 3; const headers = { Accept: 'application/json' }; if (options.csrfToken) { headers['X-CSRF-TOKEN'] = options.csrfToken; } const initRes = await fetch(options.initUrl, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.name, filesize: file.size, ...(options.extraInit || {}), }), }); const initData = await initRes.json(); if (!initData.success) { throw new Error(initData.error || initData.message || 'Failed to initialize upload.'); } const uploadId = initData.upload_id; for (let i = 0; i < totalChunks; i++) { const start = i * chunkSize; const end = Math.min(start + chunkSize, file.size); const chunk = file.slice(start, end); const expectedSize = end - start; let success = false; let lastError = null; for (let attempt = 0; attempt < maxRetries && !success; attempt++) { try { const formData = new FormData(); formData.append('upload_id', uploadId); formData.append('chunk_index', String(i)); formData.append('chunk_size', String(expectedSize)); formData.append('chunk', chunk, `chunk_${i}`); if (options.extraChunk) { Object.entries(options.extraChunk).forEach(([key, value]) => { formData.append(key, String(value)); }); } const chunkRes = await fetch(options.chunkUrl, { method: 'POST', headers, body: formData, }); if (!chunkRes.ok) { throw new Error(`HTTP ${chunkRes.status}`); } const chunkData = await chunkRes.json(); if (!chunkData.success) { throw new Error(chunkData.error || chunkData.message || 'Chunk upload failed.'); } success = true; } catch (error) { lastError = error; if (attempt < maxRetries - 1) { await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1))); } } } if (!success) { throw new Error( `Failed to upload chunk ${i + 1}/${totalChunks}: ${lastError?.message || 'unknown error'}`, ); } if (typeof options.onProgress === 'function') { options.onProgress(Math.round(((i + 1) / totalChunks) * 100)); } } const finalRes = await fetch(options.finalizeUrl, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ upload_id: uploadId, total_chunks: totalChunks, ...(options.extraFinalize || {}), }), }); const finalData = await finalRes.json(); if (!finalData.success && !finalData.data?.public_url) { throw new Error(finalData.error || finalData.message || 'Failed to finalize upload.'); } return { uploadId, response: finalData }; } export function shouldUseChunkedUpload(fileSize, threshold = CHUNKED_THRESHOLD) { return fileSize > threshold; } export { CHUNKED_THRESHOLD, DEFAULT_CHUNK_SIZE };