Sube archivos con Web Workers y flujos acotados
Usa un worker cuando tu pipeline de subida necesite procesar archivos fuera del hilo principal de
la página. Este ejemplo lee un File de forma incremental, envía un fragmento
de 1 MiB a la vez por worker y espera la confirmación final del receptor antes de mostrar un enlace
de descarga. Dos workers pueden gestionar archivos distintos mientras los demás esperan en una
cola acotada.
Desafíos de las subidas de archivos tradicionales
Una subida asíncrona habitual puede añadir un File directamente a
FormData. No necesitas leerlo primero con FileReader;
consulta los ejemplos de FormData de MDN. Empieza por ahí si solo
necesitas enviar un archivo. Los workers no aumentan el ancho de banda de la red, y este tutorial
no hace ninguna afirmación sobre la velocidad ni la memoria total.
La tarea específica aquí es controlar las lecturas incrementales y las confirmaciones de varios archivos. La introducción a los workers cubre los mensajes básicos entre workers. El tutorial de fragmentos en paralelo aborda las solicitudes simultáneas dentro de un archivo; aquí, las solicitudes de cada archivo son secuenciales.
Conoce los Web Workers y los flujos
Web Workers
Los workers pueden procesar archivos y realizar solicitudes, pero no pueden actualizar el DOM de la
página. La página envía cada File mediante clonación estructurada y muestra
los mensajes de su worker. No lee el archivo en un ArrayBuffer para enviarlo.
Consulta cómo usar Web Workers.
Flujos de JavaScript
Blob.stream(), heredado por
File, proporciona un flujo de bytes y está disponible dentro de los
workers. Los límites de sus lecturas no coinciden con los límites de nuestro protocolo de subida.
El generador que aparece a continuación combina esas lecturas en fragmentos de 1 MiB y conserva
intacto el último fragmento, de menor tamaño.
Resumen de la arquitectura
La página admite hasta cuatro archivos de un máximo de 8 MiB cada uno, incluidos los archivos vacíos. El grupo ejecuta como máximo dos tareas. Cada worker crea una subida, lee un fragmento, espera la confirmación de su POST multipart y, por último, pide al receptor que complete la subida. El progreso cuenta los bytes del archivo confirmados, no los bytes que están en tránsito.
El receptor se incluye a continuación. Comprueba los desplazamientos y las longitudes, conserva hasta cuatro archivos en memoria y permite descargar los archivos completados. Las subidas caducan 60 segundos después de su creación, incluidas las completadas. Al detener el servidor se pierden todos los archivos. Esta es una demostración de un protocolo local, sin reintentos, recuperación tras recargar la página ni almacenamiento duradero.
Configura la demostración local de subida
Usa Node.js 26.8.1 para este ejemplo local y un navegador compatible con workers de tipo módulo,
Blob.stream() y AbortSignal.timeout(). El proceso completo se probó en Linux
con Chromium 145. Al 1 de octubre de 2026, Node 26 es una versión Current con mantenimiento; consulta
el calendario de versiones al elegir un entorno de ejecución para
el despliegue. Este receptor está pensado para localhost.
En Bash, crea un directorio nuevo. Si no puedes crearlo o acceder a él, detente y elige otro nombre antes de guardar archivos. No necesitas instalar dependencias ni usar la configuración de compilación de un proyecto que lo contenga.
mkdir worker-stream-demo && cd worker-stream-demo
Guarda el HTML como index.html, coloca ambos bloques del hilo principal en
main.ts en el orden indicado y guarda el worker y el receptor como
upload-worker.ts y server.mts. El receptor elimina las anotaciones de
TypeScript al servir los scripts del navegador; los navegadores nunca ejecutan directamente los
archivos fuente .ts.
Hilo principal (main.js)
Guarda esto como index.html. Los scripts se servirán desde el mismo origen local.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Worker stream uploads</title>
</head>
<body>
<main>
<h1>Worker stream uploads</h1>
<label for="file-input">Up to four files, each at most 8 MiB</label>
<input id="file-input" type="file" multiple />
<button id="upload-files" type="button">Upload files</button>
<button id="cancel-uploads" type="button" disabled>Cancel uploads</button>
<p id="status" role="status">Choose files.</p>
<ul id="results" aria-label="Upload results"></ul>
</main>
<script type="module" src="/main.js"></script>
</body>
</html>
Empieza main.ts con este grupo de workers. Desvincula una tarea terminada antes
de llamar a la interfaz y hace avanzar la cola incluso si una función de devolución de llamada
lanza una excepción. Un fallo del script de un worker o de serialización de mensajes cancela todo
el grupo en lugar de volver a poner en servicio un worker defectuoso.
export type WorkerReply =
| { type: 'progress'; progress: number; message: string }
| { type: 'complete'; message: string; url: string }
| { type: 'error'; message: string }
type Callbacks = {
onProgress?: (progress: number, message: string) => void
onComplete?: (message: string, url: string) => void
onError?: (message: string) => void
}
type Task = { id: string; file: File }
class WorkerPool {
closed = false
workers: Worker[] = []
idleWorkers: Worker[] = []
taskQueue: Task[] = []
taskCallbacks = new Map<string, Callbacks>()
currentTasks = new Map<Worker, Task>()
constructor(script: string, size = 2) {
if (!Number.isInteger(size) || size < 1 || size > 2) throw new Error('Use one or two workers')
try {
for (let index = 0; index < size; index++) {
const worker = new Worker(script, { type: 'module' })
worker.onmessage = (event: MessageEvent<WorkerReply>) => this.handleWorkerMessage(worker, event.data)
worker.onerror = (event) => {
event.preventDefault()
this.terminate('An upload worker failed. Try again.')
}
worker.onmessageerror = () => this.terminate('An upload worker failed. Try again.')
this.workers.push(worker)
this.idleWorkers.push(worker)
}
} catch (error) {
this.terminate()
throw error
}
}
processFile(file: File, callbacks: Callbacks, id = crypto.randomUUID()): string {
if (this.closed) throw new Error('The upload pool is closed.')
if (this.taskCallbacks.size >= 4) throw new Error('The upload queue is full.')
const task = { id, file }
this.taskCallbacks.set(task.id, callbacks)
const worker = this.idleWorkers.pop()
if (worker) this.runTask(worker, task)
else this.taskQueue.push(task)
return task.id
}
runTask(worker: Worker, task: Task): void {
this.currentTasks.set(worker, task)
try {
worker.postMessage(task)
} catch {
this.terminate('An upload worker failed. Try again.')
}
}
handleWorkerMessage(worker: Worker, data: WorkerReply): void {
if (this.closed) return
const task = this.currentTasks.get(worker)
if (!task) return
const callbacks = this.taskCallbacks.get(task.id)
if (!callbacks) return
if (data.type === 'progress') {
callbacks.onProgress?.(data.progress, data.message)
return
}
this.currentTasks.delete(worker)
this.taskCallbacks.delete(task.id)
try {
if (data.type === 'complete') callbacks.onComplete?.(data.message, data.url)
else callbacks.onError?.(data.message)
} finally {
if (!this.closed) {
const next = this.taskQueue.shift()
if (next) this.runTask(worker, next)
else this.idleWorkers.push(worker)
}
}
}
terminate(message = 'Upload canceled.'): void {
if (this.closed) return
this.closed = true
for (const worker of this.workers) worker.terminate()
this.workers = []
this.idleWorkers = []
this.taskQueue = []
this.currentTasks.clear()
const callbacks = [...this.taskCallbacks.values()]
this.taskCallbacks.clear()
const errors: unknown[] = []
for (const callback of callbacks) {
try {
callback.onError?.(message)
} catch (error) {
errors.push(error)
}
}
if (errors.length > 0) throw new AggregateError(errors, 'Upload cancellation callbacks failed.')
}
}
Añade este bloque a main.ts. La selección de archivos y los envíos repetidos se
deshabilitan mientras se ejecuta un lote o se realiza su limpieza. Cada fila pertenece a un archivo,
por lo que un fallo no puede confundirse con el resultado de otro archivo. Los nombres se insertan
como texto, nunca se interpretan como HTML ni como rutas de almacenamiento.
const input = document.getElementById('file-input')
const upload = document.getElementById('upload-files')
const cancel = document.getElementById('cancel-uploads')
const status = document.getElementById('status')
const results = document.getElementById('results')
if (!(input instanceof HTMLInputElement) || !(upload instanceof HTMLButtonElement) ||
!(cancel instanceof HTMLButtonElement) || !status || !results) throw new Error('Missing controls')
let pool: WorkerPool | null = null
let running = false
cancel.addEventListener('click', () => pool?.terminate())
window.addEventListener('pagehide', () => pool?.terminate())
input.addEventListener('change', () => {
if (!running) status.textContent = 'Ready to upload.'
})
upload.addEventListener('click', async () => {
if (running) return
const files = Array.from(input.files ?? [])
if (files.length < 1 || files.length > 4 || files.some((file) => file.size > 8 * 1024 * 1024)) {
status.textContent = 'Choose one to four files, each at most 8 MiB.'
return
}
running = true
input.disabled = upload.disabled = true
cancel.disabled = false
results.replaceChildren()
status.textContent = 'Uploading…'
const failedIds: string[] = []
let failures = 0
try {
const batchPool = new WorkerPool('/upload-worker.js')
pool = batchPool
const jobs = files.map((file) => new Promise<void>((resolve) => {
const id = crypto.randomUUID()
const row = document.createElement('li')
const name = document.createElement('p')
name.textContent = file.name
const progress = document.createElement('progress')
progress.max = 100
progress.value = 0
progress.setAttribute('aria-label', `Acknowledged bytes for ${file.name}`)
const message = document.createElement('p')
message.setAttribute('role', 'status')
message.textContent = 'Queued.'
row.append(name, progress, message)
results.append(row)
batchPool.processFile(file, {
onProgress(value, text) { progress.value = value; message.textContent = text },
onComplete(text, url) {
progress.value = 100
message.textContent = text
const link = document.createElement('a')
link.href = url
link.download = 'upload.bin'
link.textContent = `Download ${file.name}`
row.append(link)
resolve()
},
onError(text) {
failures++
failedIds.push(id)
message.textContent = text
resolve()
},
}, id)
}))
await Promise.all(jobs)
status.textContent = failures === 0 ? 'All uploads complete.' : 'Some uploads did not complete.'
} catch {
status.textContent = 'Could not start the upload workers. Try again.'
} finally {
pool?.terminate()
pool = null
let cleanupFailed = false
for (const id of failedIds) {
try {
const response = await fetch(`/uploads/${id}`, {
method: 'DELETE', headers: { 'X-Upload-Demo': '1' }, signal: AbortSignal.timeout(5000),
})
if (!response.ok) cleanupFailed = true
} catch { cleanupFailed = true }
}
if (cleanupFailed) status.textContent += ' Cleanup was not confirmed; uploads will expire.'
running = false
input.disabled = upload.disabled = false
cancel.disabled = true
}
})
Implementación del worker (upload-worker.js)
Guarda esto como upload-worker.ts. Esperar cada POST impide que el generador produzca
otro fragmento de la aplicación mientras la solicitud anterior está pendiente. El navegador aún
puede almacenar internamente en búfer las lecturas del flujo y los datos de las solicitudes.
El tamaño de fragmento de 1 MiB no limita la memoria total del navegador.
import type { WorkerReply } from './main.js'
declare const self: DedicatedWorkerGlobalScope
const CHUNK_SIZE = 1024 * 1024
async function* readChunks(file: Blob): AsyncGenerator<Uint8Array<ArrayBuffer>> {
const reader = file.stream().getReader()
let buffer = new Uint8Array(CHUNK_SIZE)
let used = 0
let finished = false
try {
while (true) {
const { done, value } = await reader.read()
if (done) { finished = true; break }
let offset = 0
while (offset < value.length) {
const length = Math.min(CHUNK_SIZE - used, value.length - offset)
buffer.set(value.subarray(offset, offset + length), used)
used += length
offset += length
if (used === CHUNK_SIZE) {
yield buffer
buffer = new Uint8Array(CHUNK_SIZE)
used = 0
}
}
}
if (used > 0) yield buffer.subarray(0, used)
} finally {
if (!finished) await reader.cancel().catch(() => {})
reader.releaseLock()
}
}
function send(message: WorkerReply): void { self.postMessage(message) }
async function request(path: string, options: RequestInit): Promise<Response> {
const response = await fetch(path, {
...options, headers: { ...options.headers, 'X-Upload-Demo': '1' },
signal: AbortSignal.timeout(5000),
})
if (!response.ok) throw new Error('Upload request rejected')
return response
}
self.onmessage = async (event: MessageEvent<{ file: File; id: string }>) => {
const { file, id } = event.data
try {
if (!(file instanceof File) || file.size > 8 * CHUNK_SIZE) throw new Error('Invalid file')
await request(`/uploads/${id}`, { method: 'POST', headers: { 'Upload-Length': String(file.size) } })
send({ type: 'progress', progress: 0, message: 'Uploading…' })
let acknowledged = 0
for await (const chunk of readChunks(file)) {
const form = new FormData()
form.append('offset', String(acknowledged))
form.append('chunk', new Blob([chunk]), 'chunk.bin')
await request(`/uploads/${id}/chunks`, { method: 'POST', body: form })
acknowledged += chunk.length
send({ type: 'progress', progress: 100 * acknowledged / file.size,
message: `${acknowledged} / ${file.size} bytes acknowledged.` })
}
send({ type: 'progress', progress: 100, message: 'Confirming…' })
const response = await request(`/uploads/${id}/complete`, { method: 'POST' })
if (await response.text() !== String(file.size)) throw new Error('Unexpected acknowledgment')
send({ type: 'complete', message: 'Upload complete.', url: `/uploads/${id}/file` })
} catch {
send({ type: 'error', message: 'Upload failed. Try again.' })
}
}
Esto usa solicitudes multipart independientes, no un cuerpo de solicitud
ReadableStream. Deja que el navegador establezca el delimitador multipart de
Content-Type. Un archivo vacío no envía fragmentos, pero sigue requiriendo que se
complete la subida.
¿Por qué no leer todo el archivo de una vez en el worker?
Trasladar a un worker una llamada a arrayBuffer() que lee el archivo completo sigue
creando un búfer de la aplicación con todo el archivo. Aquí, el combinador conserva un búfer de
1 MiB más la lectura actual del flujo, y cada tarea activa tiene una sola solicitud de fragmento
pendiente de confirmación. Un Blob y la implementación de red pueden crear
copias adicionales. Las tareas en cola conservan referencias a archivos en lugar de bytes de
archivos leídos de antemano.
Creación de un pequeño grupo de workers
El grupo anterior tiene dos workers y como máximo cuatro tareas pendientes. Este es un límite de planificación, no una recomendación basada en el número de CPU. Más workers pueden aumentar la sobrecarga de procesamiento, memoria y solicitudes sin aumentar la tasa de transferencia útil. Haz mediciones con tu propia etapa de procesamiento y tu receptor.
Recibe los fragmentos localmente
Guarda esto como server.mts. La extensión .mts mantiene
la ejecución inequívocamente en modo de módulos ES, incluso dentro de un proyecto CommonJS.
stripTypeScriptTypes() elimina las anotaciones; no comprueba
los tipos de los scripts.
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { stripTypeScriptTypes } from 'node:module'
const CHUNK_SIZE = 1024 * 1024
const MAX_BODY = CHUNK_SIZE + 4096
const TTL_MS = 60_000
type Upload = { bytes: Buffer; received: number; complete: boolean; expires: number }
const uploads = new Map<string, Upload>()
class HttpError extends Error {
status: number
constructor(status: number, message: string) { super(message); this.status = status }
}
function expire(): void {
for (const [id, upload] of uploads) {
if (upload.expires <= Date.now()) uploads.delete(id)
}
}
async function readBody(req: IncomingMessage, limit: number): Promise<Buffer> {
const bytes = Buffer.alloc(limit)
let length = 0
// Keep the connection long enough to report an oversize body.
for await (const part of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(part)) throw new HttpError(400, 'Expected bytes')
if (length + part.length > limit) throw new HttpError(413, 'Body too large')
part.copy(bytes, length)
length += part.length
}
return bytes.subarray(0, length)
}
async function main(): Promise<void> {
const assets = new Map<string, string>([['/', await readFile(new URL('./index.html', import.meta.url), 'utf8')]])
for (const name of ['main', 'upload-worker']) {
assets.set(`/${name}.js`, stripTypeScriptTypes(await readFile(new URL(`./${name}.ts`, import.meta.url), 'utf8')))
}
const port = Number(process.argv[2] ?? 0)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port')
let origin = ''
let active = 0
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.headers.host !== new URL(origin).host ||
(req.headers.origin !== undefined && req.headers.origin !== origin)) throw new HttpError(403, 'Use the printed local URL')
const method = req.method
if (method !== 'GET' && req.headers['x-upload-demo'] !== '1') throw new HttpError(403, 'Missing demo header')
const path = new URL(req.url ?? '/', origin).pathname
const isChunk = method === 'POST' && path.endsWith('/chunks')
const body = await readBody(req, isChunk ? MAX_BODY : 0)
// Parse before looking up an upload: pending work cannot retain a deleted session.
let chunk: Uint8Array | undefined
let offset: string | undefined
if (isChunk) {
const contentType = req.headers['content-type']
if (typeof contentType !== 'string' || !contentType.startsWith('multipart/form-data;')) throw new HttpError(400, 'Expected multipart data')
let form: FormData
try {
form = await new Request(origin, { method: 'POST', headers: { 'Content-Type': contentType }, body: new Uint8Array(body) }).formData()
} catch { throw new HttpError(400, 'Invalid multipart data') }
const value = form.get('chunk')
const position = form.get('offset')
if (!(value instanceof Blob) || value.size > CHUNK_SIZE || typeof position !== 'string' ||
!/^(0|[1-9]\d*)$/.test(position) || [...form.keys()].length !== 2) throw new HttpError(400, 'Invalid chunk fields')
chunk = new Uint8Array(await value.arrayBuffer())
offset = position
}
expire()
res.setHeader('Cache-Control', 'no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
const asset = assets.get(path)
if (method === 'GET' && asset !== undefined) {
res.setHeader('Content-Type', path === '/' ? 'text/html; charset=utf-8' : 'text/javascript')
res.end(asset)
return
}
const match = /^\/uploads\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\/(chunks|complete|file))?$/.exec(path)
if (!match) throw new HttpError(404, 'Unknown route')
const [, id, operation] = match
if (method === 'POST' && operation === undefined) {
const size = req.headers['upload-length']
if (typeof size !== 'string' || !/^(0|[1-9]\d*)$/.test(size) || Number(size) > 8 * CHUNK_SIZE) throw new HttpError(400, 'Expected a size from 0 to 8 MiB')
if (uploads.has(id)) throw new HttpError(409, 'Upload already exists')
if (uploads.size >= 4) throw new HttpError(503, 'Upload capacity reached')
uploads.set(id, { bytes: Buffer.alloc(Number(size)), received: 0, complete: false, expires: Date.now() + TTL_MS })
res.writeHead(201).end()
return
}
if (method === 'DELETE' && operation === undefined) {
uploads.delete(id)
res.writeHead(204).end()
return
}
const upload = uploads.get(id)
if (!upload) throw new HttpError(404, 'Upload missing or expired')
if (isChunk && operation === 'chunks') {
const expected = Math.min(CHUNK_SIZE, upload.bytes.length - upload.received)
if (!chunk || chunk.length === 0 || chunk.length !== expected || offset !== String(upload.received) || upload.complete) throw new HttpError(409, 'Unexpected chunk offset or length')
upload.bytes.set(chunk, upload.received)
upload.received += chunk.length
res.writeHead(204).end()
return
}
if (method === 'POST' && operation === 'complete') {
if (upload.received !== upload.bytes.length) throw new HttpError(409, 'Missing bytes')
upload.complete = true
res.end(String(upload.received))
return
}
if (method === 'GET' && operation === 'file') {
if (!upload.complete) throw new HttpError(409, 'Upload is not complete')
res.setHeader('Content-Type', 'application/octet-stream')
res.setHeader('Content-Disposition', 'attachment; filename="upload.bin"')
res.end(upload.bytes)
return
}
throw new HttpError(405, 'Unsupported operation')
}
const server = createServer({ requestTimeout: 10_000, headersTimeout: 10_000 }, (req, res) => {
if (active >= 6) { res.writeHead(503, { Connection: 'close' }).end('Too many requests'); return }
active++
let handled = false
let closed = false
const deadline = setTimeout(() => { req.destroy(); res.destroy() }, 10_000)
function release(): void {
if (handled && closed) { clearTimeout(deadline); active-- }
}
res.once('close', () => { closed = true; release() })
handle(req, res).catch((error: unknown) => {
const status = error instanceof HttpError ? error.status : 500
const message = error instanceof HttpError ? error.message : 'Request failed'
if (!res.destroyed) res.writeHead(status, { Connection: 'close' }).end(message)
}).finally(() => { handled = true; release() })
})
server.maxConnections = 16
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', () => resolve())
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing server address')
origin = `http://127.0.0.1:${address.port}`
setInterval(expire, 1000).unref()
process.once('SIGINT', () => {
server.close()
server.closeAllConnections()
})
console.log(`Open ${origin}`)
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : 'Could not start the server')
process.exitCode = 1
})
Los cuatro búferes de archivos registrados suman como máximo 32 MiB. El análisis de solicitudes usa búferes adicionales; cada cuerpo aceptado está limitado a 1 MiB más 4 KiB de sobrecarga multipart, incluidos los datos finales. Hay como máximo seis manejadores activos. Una descarga puede conservar el búfer de su archivo tras su eliminación o caducidad, por lo que el cupo del manejador sigue ocupado hasta que finalicen tanto el manejo como el cierre de la respuesta. Estos límites describen las operaciones de la aplicación, no la memoria total del proceso ni los tiempos de recolección de basura.
Desde el directorio con los cuatro archivos guardados, ejecuta este comando en primer plano:
node server.mts
Abre la URL http://127.0.0.1:PORT que se muestra. Selecciona archivos y haz clic en
Upload files. Cada fila llega a
Confirming… tras la confirmación de sus fragmentos;
solo una respuesta de finalización exitosa produce
Upload complete. y un enlace de descarga.
Haz clic en el enlace para guardar los bytes recibidos como upload.bin mediante
tu navegador. Una cantidad de bytes coincidente no es una suma de comprobación de integridad;
compara el archivo descargado con el original al comprobar este ejemplo.
Detén el servidor con Ctrl+C, lo que cierra sus conexiones. Para seleccionar un puerto libre
específico, añade su número a node server.mts.
Si el puerto está ocupado o falta un archivo fuente, el proceso termina con un error en lugar de
mostrar una URL lista para usar.
Compatibilidad con navegadores
Comprueba que las versiones de navegador que admites sean compatibles con
Worker, Blob.stream() y
AbortSignal.timeout().
Este ejemplo no transfiere un flujo entre hilos. Su tiempo de espera de cinco segundos para las
solicitudes del navegador usa tiempo activo y puede pausarse en un worker suspendido; el receptor
tiene un plazo independiente de diez segundos. No se probaron otros navegadores ni sistemas
operativos para este tutorial.
Buenas prácticas de gestión de memoria
Mantén un fragmento de la aplicación en tránsito por worker y una cola pequeña de referencias a
archivos. El generador cancela un flujo sin terminar y libera el bloqueo de su lector cuando falla
una solicitud. La página termina todos los workers al finalizar un lote, al cancelar y cuando
ocurre pagehide.
Haz clic en Cancel uploads mientras un lote está
pendiente. Las filas activas y en cola reciben
Upload canceled.; las filas ya completadas conservan
sus resultados. La página intenta eliminar los IDs de las subidas fallidas o canceladas y mantiene
los controles deshabilitados hasta que concluya la limpieza. Una respuesta de creación perdida, el
cierre de la página o una eliminación fallida pueden dejar bytes hasta que caduquen. Terminar un
worker no ejecuta sus bloques finally ni deshace una solicitud que el receptor
ya haya aceptado.
Seguridad y resiliencia
El servidor escucha en 127.0.0.1, comprueba el host y el origen del navegador y
exige un encabezado personalizado para las modificaciones. No habilita el acceso entre orígenes,
no interpreta los nombres de archivo como rutas ni sirve los bytes subidos como HTML ejecutable.
Estos son límites de la demostración local, no autenticación ni validación de contenido.
No expongas este receptor como un servicio público de subida.
Un servicio desplegado necesita autenticación, autorización, cuotas por usuario, HTTPS, almacenamiento duradero y validación adecuada para sus consumidores. Este protocolo basado en desplazamientos rechaza deliberadamente los fragmentos duplicados o reordenados. Añadir reintentos requiere un contrato de idempotencia; la recuperación tras recargar la página requiere estado persistente y reconciliación con el receptor.
Depuración de Web Workers
Inspecciona las solicitudes y los mensajes de los workers en las herramientas de desarrollo del navegador. Un error al cargar un script cancela el grupo; un fallo HTTP se convierte en Upload failed. Try again. en la fila correspondiente. Una respuesta 409 indica un desplazamiento incorrecto, una longitud de fragmento incorrecta o un archivo incompleto. Un 503 puede significar que los cuatro cupos de subida están ocupados, incluidos los archivos completados; espera a que caduquen antes de reintentar. Los IDs inexistentes o caducados devuelven 404.
Errores comunes
- Considerar que la subida está completa cuando se ha confirmado el 100 % de los bytes. El último POST aún puede fallar.
- Volver a habilitar la entrada antes de que concluya el trabajo cancelado. Esta página espera a que termine su intento de limpieza.
- Suponer que los tamaños de lectura del flujo coinciden con los tamaños de los fragmentos HTTP. El combinador gestiona un resto de menor tamaño.
- Suponer que terminar un worker elimina el estado del servidor. La caducidad sigue siendo necesaria.
- Leer de antemano todos los archivos en cola, lo que anula el enfoque de procesamiento incremental.
Conclusiones clave
Prueba con un archivo vacío y un archivo binario de un tamaño ligeramente superior a 1 MiB; luego, compara cada resultado descargado con su entrada. El alcance útil abarca el proceso completo, desde una lectura incremental hasta un fragmento reconocido y un archivo confirmado, con un número pequeño de tareas activas. Para reanudar subidas en producción, empieza con un protocolo que reciba mantenimiento, como el protocolo tus, y un receptor compatible. El plugin Tus de Uppy proporciona un cliente de navegador con mantenimiento.
