Deploy Ladill Transfer / deploy (push) Successful in 39s
Large files upload in 5 MB chunks (hosting file manager pattern) for the transfer UI and Ladill Mail S2S API, with no app-level file size cap when TRANSFER_MAX_FILE_BYTES=0. Co-authored-by: Cursor <cursoragent@cursor.com>
122 lines
4.0 KiB
JavaScript
122 lines
4.0 KiB
JavaScript
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<string, unknown>,
|
|
* extraChunk?: Record<string, unknown>,
|
|
* extraFinalize?: Record<string, unknown>,
|
|
* 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 };
|