UNPKG

@astro-utils/forms

Version:

Server component for Astro (call server functions from client side with validation and state management)

239 lines (238 loc) 8.64 kB
import { v4 as uuid } from 'uuid'; const sleep = (ms) => new Promise(res => setTimeout(res, ms)); const UPLOAD_BIG_FILE_OPTIONS = { retryChunks: 5, retryDelay: 1000, chunkSize: 1024 * 1024 * 5, parallelChunks: 3, parallelUploads: 3, waitFinishDelay: 1000, }; const clientWFS = window.clientWFS; async function uploadChunkWithXHR(file, info, progressCallback) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const formData = new FormData(); xhr.responseType = "text"; formData.append('file', file); formData.append("astroBigFileUpload", "true"); formData.append('info', JSON.stringify(info)); if (clientWFS.csrf) { formData.append(clientWFS.csrf.filed, clientWFS.csrf.token); } xhr.upload.onprogress = (event) => { progressCallback(event.loaded, event.total); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(JSON.parse(xhr.responseText)); } else { reject({ ok: false, error: xhr.responseText }); } }; xhr.onerror = () => { reject({ ok: false, error: xhr.responseText }); }; xhr.open('POST', location.href, true); xhr.send(formData); }); } async function finishUpload(uploadId, options) { let maxError = options.retryChunks; while (true) { try { const response = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const formData = new FormData(); xhr.responseType = "text"; formData.append('wait', uploadId); formData.append("astroBigFileUpload", "true"); if (clientWFS.csrf) { formData.append(clientWFS.csrf.filed, clientWFS.csrf.token); } xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(JSON.parse(xhr.responseText)); } else { reject({ ok: false, error: xhr.responseText }); } }; xhr.onerror = () => { reject({ ok: false, error: xhr.responseText }); }; xhr.open('POST', location.href, true); xhr.send(formData); }); if (!response.wait) { break; } await sleep(options.waitFinishDelay); } catch (error) { if (maxError === 0) { throw error; } maxError--; await sleep(options.retryChunks); } } } async function uploadBigFile(fileId, file, progressCallback, options) { const totalSize = file.size; const totalChunks = Math.ceil(totalSize / options.chunkSize); const activeChunks = new Set(); const activeLoads = new Map(); let finishedSize = 0; const uploadChunk = async (i) => { while (activeChunks.size >= options.parallelChunks) { await Promise.race(activeChunks); } if (i + 1 === totalChunks) { await Promise.all(activeChunks); } const start = i * options.chunkSize; const end = Math.min(totalSize, start + options.chunkSize); const chunk = file.slice(start, end); const info = { uploadId: fileId, uploadSize: totalSize, part: i + 1, total: totalChunks, }; const stopRetrying = new AbortController(); const uploadPromiseWithRetry = retry(async () => { const upload = await uploadChunkWithXHR(chunk, info, (loaded) => { activeLoads.set(i, loaded); const loadedSize = Array.from(activeLoads.values()).reduce((a, b) => a + b, 0); progressCallback(finishedSize + loadedSize, totalSize); }); const response = await upload; if (response?.missingChunks && activeChunks.size < options.parallelChunks) { const promises = []; for (const chunk of response.missingChunks) { const { promise } = await uploadChunk(chunk - 1); promises.push(promise); } await Promise.all(promises); } if (!response?.ok) { if (response.retry === false) { stopRetrying.abort('Not retryable error'); } throw new Error(response.error); } }, { retries: options.retryChunks, delay: options.retryDelay, stopRetying: stopRetrying.signal }) .then(() => { activeLoads.delete(i); activeChunks.delete(uploadPromiseWithRetry); finishedSize += chunk.size; }); activeChunks.add(uploadPromiseWithRetry); return { promise: uploadPromiseWithRetry }; }; for (let i = 0; i < totalChunks; i++) { await uploadChunk(i); } await Promise.all(activeChunks); await finishUpload(fileId, options); } export async function uploadAllFiles(els, options = { ...UPLOAD_BIG_FILE_OPTIONS, ...clientWFS.bigFileUploadOptions }) { const activeUploads = new Map(); const filesToUpload = new Map(); let failed = false; for (const el of els) { el.disabled = true; const files = el.files; if (!files || files.length === 0) { continue; } const progress = document.querySelector(`progress[data-for="${el.name}"]`); const progressCallback = (loaded, total) => { if (!progress) return; progress.value = Math.round((loaded / total) * 100); }; for (const file of files) { while (activeUploads.size >= options.parallelUploads) { await Promise.race(activeUploads.values()); } if (progress) { const onActiveClasses = progress.getAttribute('data-onactive-class'); if (onActiveClasses) { const addClass = onActiveClasses.split(' ').filter(Boolean); if (addClass.length > 0) { progress.classList.add(...addClass); } } } const fileId = uuid(); if (failed) { onUploadFinished(el, file, fileId, true); continue; } const upload = uploadBigFile(fileId, file, progressCallback, options).then(() => { activeUploads.delete(file.name); filesToUpload.set(el, fileId); onUploadFinished(el, file, fileId); }).catch(() => { failed = true; onUploadFinished(el, file, fileId, true); }); activeUploads.set(file.name, upload); } } await Promise.all(activeUploads.values()); return filesToUpload; } function onUploadFinished(el, file, id, failed) { const inputElement = document.createElement('input'); inputElement.type = 'hidden'; inputElement.name = el.name; inputElement.value = `big-file:${JSON.stringify({ id, name: file.name, failed })}`; el.required = false; el.removeAttribute('name'); el.after(inputElement); } export function countTotalUploads(els) { let count = 0; let totalSize = 0; for (const el of els) { const files = el.files; if (!files || files.length === 0) { continue; } for (const file of files) { count++; totalSize += file.size; } } return { count, totalSize }; } export function finishFormSubmission(form, onClick) { if (onClick) { const inputElement = document.createElement('input'); inputElement.type = 'hidden'; inputElement.name = 'button-callback'; inputElement.value = onClick; form.append(inputElement); } form.submit(); } async function retry(fn, options = { retries: 5, delay: 1000 }) { let attempts = 0; while (attempts < options.retries) { try { await fn(); return; } catch (error) { attempts++; if (attempts >= options.retries || options.stopRetying?.aborted) { throw error; } await sleep(options.delay); } } }