Sube archivos con un Web Worker y un receptor local
Un Web Worker puede procesar archivos fuera del hilo principal de tu página e informar del progreso de la subida mediante mensajes. Este ejemplo conecta un worker dedicado con un receptor local que puedes ejecutar: selecciona un archivo, súbelo en fragmentos secuenciales y descarga el archivo que el receptor realmente aceptó.
Elige qué tareas corresponden a un worker
Una subida asíncrona normal no necesita un worker. Usa uno cuando tu pipeline también necesite analizar o transformar archivos; los workers pueden hacer solicitudes, pero no pueden actualizar el DOM de la página. En su lugar, la página muestra sus mensajes. Consulta cómo usar Web Workers.
Aquí, el cálculo del hash de un archivo pequeño ilustra un paso de procesamiento. Web Crypto es asíncrono por sí mismo, así que este ejemplo no afirma que el worker aumente la velocidad de subida ni reduzca el uso total de memoria. Para varios archivos, el tutorial sobre grupos de workers y flujos aborda las colas y las lecturas incrementales de flujos. En esta página nos limitaremos a un archivo y un worker.
Usa Node.js 24.15.0 o una versión posterior que siga recibiendo mantenimiento, Corepack con Yarn 4,
Bash y un navegador compatible con workers de módulo, Web Crypto y AbortSignal.timeout().
El ejemplo completo se probó en Linux con Node.js 24.15.0 y 26.8.1 y Chromium 145. Al 2 de octubre de
2026, Node 24 es LTS y Node 26 es Current; elige una versión de parche vigente de la
lista de versiones de Node.
Si no tienes Corepack, sigue las instrucciones de instalación de Yarn
antes de continuar.
Crea el proyecto local y la página
Pega esto en Bash. Rechaza un directorio existente, aísla el proyecto de Yarn con su propio archivo
de bloqueo y vuelve a tu directorio original si falla la instalación. Una instalación fallida puede
dejar el nuevo directorio del ejemplo; revísalo y elige un nombre nuevo antes de volver a intentarlo.
Todos los archivos posteriores deben estar dentro de worker-upload-demo.
if (
mkdir worker-upload-demo &&
cd worker-upload-demo &&
printf '%s\n' '{"name":"worker-upload-demo","private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' 'enableGlobalCache: false' 'globalFolder: .yarn/global' 'npmRegistryServer: https://registry.npmjs.org' > .yarnrc.yml &&
touch yarn.lock &&
mkdir src &&
corepack yarn add --dev --exact vite@8.3.1 typescript@6.0.3 @types/node@26.6.3
); then
cd worker-upload-demo
else
printf '%s\n' 'Setup failed; inspect the new directory before retrying.' >&2
false
fi
Guarda esto como index.html. El receptor servirá la página compilada y las rutas
de subida desde el mismo origen de bucle local. Abre la URL HTTP que se muestra en la terminal,
en lugar de abrir este archivo directamente.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dedicated worker upload</title>
</head>
<body>
<main>
<h1>Dedicated worker upload</h1>
<label for="fileInput">File to upload, up to 16 MiB</label>
<input type="file" id="fileInput" />
<button type="button" id="uploadBtn">Upload</button>
<button type="button" id="cancelBtn" disabled>Cancel</button>
<label id="progressLabel" for="uploadProgress">Upload progress</label>
<progress id="uploadProgress" aria-labelledby="progressLabel" value="0" max="100"></progress>
<p id="status" role="status">Choose a file.</p>
<output id="checksum" aria-label="Receiver SHA-256"></output>
<p><a id="download" hidden download="received.bin">Download received file</a></p>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Define y verifica el contrato de mensajes
Guarda src/worker-types.ts. Un mensaje de finalización incluye la suma de verificación
del receptor y la URL de descarga, de modo que la página pueda mostrar un resultado observable.
export interface WorkerMessage {
file: File
}
export type WorkerResponse =
| { type: 'processed' }
| { type: 'progress'; percent: number }
| { type: 'confirming' }
| { type: 'complete'; sha256: string; url: string }
| { type: 'error'; message: string }
Guarda tsconfig.json para la página:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": [],
"strict": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src/main.ts", "src/worker-types.ts"]
}
Guarda tsconfig.worker.json. Verificar los workers por separado evita combinar las
variables globales del DOM y de los workers.
Vite compila el worker de módulo, mientras que TypeScript verifica
sus tipos.
{
"extends": "./tsconfig.json",
"compilerOptions": { "lib": ["ES2022", "WebWorker"] },
"include": ["src/upload.worker.ts", "src/worker-types.ts"]
}
Guarda tsconfig.server.json para el receptor de Node:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["server.mts"]
}
Envía un fragmento a la vez desde el worker
Guarda src/upload.worker.ts. Se calcula el hash de los archivos de hasta 5 MiB antes de
enviarlos; para los archivos más grandes se omite el cálculo del hash del archivo completo. Cada
solicitud envía un Blob sin procesar, espera una confirmación HTTP y luego
avanza a la siguiente posición. Los archivos vacíos no necesitan solicitudes de fragmentos, pero
sí requieren finalización.
xhr.upload proporciona el progreso de la
transferencia. Esos eventos no confirman la aceptación por parte del servidor. La barra permanece
por debajo del 100 % hasta que la solicitud final se completa correctamente, incluso cuando todos
los bytes ya han salido del navegador.
import type { WorkerMessage, WorkerResponse } from './worker-types.ts'
declare const self: DedicatedWorkerGlobalScope
const CHUNK_SIZE = 5 * 1024 * 1024
const MAX_FILE_SIZE = 16 * 1024 * 1024
function send(message: WorkerResponse): void {
self.postMessage(message)
}
function uploadChunk(chunk: Blob, url: string, onProgress: (ratio: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(event.loaded / event.total)
}
xhr.open('PUT', url)
xhr.timeout = 60_000
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve()
else reject(new Error('Chunk rejected'))
}
xhr.onerror = () => reject(new Error('Upload network error'))
xhr.ontimeout = () => reject(new Error('Upload timed out'))
xhr.onabort = () => reject(new Error('Upload canceled'))
xhr.send(chunk)
})
}
async function run({ file }: WorkerMessage): Promise<void> {
if (file.size > MAX_FILE_SIZE) throw new Error('File exceeds the demo limit')
let expectedHash: string | undefined
if (file.size <= CHUNK_SIZE) {
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
expectedHash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
send({ type: 'processed' })
}
const created = await fetch(`/uploads?size=${file.size}`, {
method: 'POST', signal: AbortSignal.timeout(60_000),
})
if (!created.ok) throw new Error('Could not create upload')
const entry: unknown = await created.json()
if (!entry || typeof entry !== 'object' || !('id' in entry)
|| typeof entry.id !== 'string' || !/^[0-9a-f-]{36}$/.test(entry.id)) {
throw new Error('Invalid upload ID')
}
const url = `/uploads/${entry.id}`
for (let start = 0; start < file.size; start += CHUNK_SIZE) {
const chunk = file.slice(start, start + CHUNK_SIZE)
await uploadChunk(chunk, `${url}?offset=${start}`, (ratio) => {
const percent = (start + ratio * chunk.size) / file.size * 100
send({ type: 'progress', percent: Math.min(99, percent) })
})
}
send({ type: 'confirming' })
const completed = await fetch(`${url}/complete`, {
method: 'POST', signal: AbortSignal.timeout(60_000),
})
if (!completed.ok) throw new Error('Finalization failed')
const result: unknown = await completed.json()
if (!result || typeof result !== 'object' || !('sha256' in result) || !('size' in result)
|| typeof result.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(result.sha256)
|| result.size !== file.size || (expectedHash !== undefined && result.sha256 !== expectedHash)) {
throw new Error('Invalid completion acknowledgment')
}
send({ type: 'complete', sha256: result.sha256, url })
}
self.onmessage = (event: MessageEvent<WorkerMessage>) => {
run(event.data).catch(() => send({ type: 'error', message: 'Upload failed. Please try again.' }))
}
Muestra el progreso y libera el worker
Guarda src/main.ts. El valor seleccionado de File se
captura una sola vez y los controles deshabilitados impiden que se superpongan las subidas. Cada
ruta de finalización termina el worker. La comprobación de identidad
también ignora un mensaje en cola de un worker que ya se haya cancelado.
import type { WorkerMessage, WorkerResponse } from './worker-types.ts'
const fileInput = document.getElementById('fileInput')
const uploadBtn = document.getElementById('uploadBtn')
const cancelBtn = document.getElementById('cancelBtn')
const uploadProgress = document.getElementById('uploadProgress')
const statusElement = document.getElementById('status')
const checksum = document.getElementById('checksum')
const download = document.getElementById('download')
if (!(fileInput instanceof HTMLInputElement) || !(uploadBtn instanceof HTMLButtonElement)
|| !(cancelBtn instanceof HTMLButtonElement) || !(uploadProgress instanceof HTMLProgressElement)
|| !(statusElement instanceof HTMLElement) || !(checksum instanceof HTMLOutputElement)
|| !(download instanceof HTMLAnchorElement)) {
throw new Error('Missing upload controls')
}
let worker: Worker | null = null
const finish = (message: string): void => {
worker?.terminate()
worker = null
uploadBtn.disabled = false
fileInput.disabled = false
cancelBtn.disabled = true
statusElement.textContent = message
}
uploadBtn.addEventListener('click', () => {
if (worker !== null) return
const file = fileInput.files?.[0]
uploadProgress.value = 0
checksum.value = ''
download.hidden = true
download.removeAttribute('href')
if (!file) {
statusElement.textContent = 'Please select a file.'
return
}
if (file.size > 16 * 1024 * 1024) {
statusElement.textContent = 'Choose a file of at most 16 MiB.'
return
}
uploadBtn.disabled = true
fileInput.disabled = true
cancelBtn.disabled = false
statusElement.textContent = 'Preparing upload.'
try {
const current = new Worker(new URL('./upload.worker.ts', import.meta.url), { type: 'module' })
worker = current
current.onmessage = (event: MessageEvent<WorkerResponse>) => {
if (worker !== current) return
const response = event.data
switch (response.type) {
case 'processed':
statusElement.textContent = 'File hashed. Uploading.'
break
case 'progress':
uploadProgress.value = response.percent
statusElement.textContent = `Uploading: ${Math.round(response.percent)}%`
break
case 'confirming':
statusElement.textContent = 'Confirming upload.'
break
case 'complete':
checksum.value = response.sha256
download.href = response.url
download.hidden = false
uploadProgress.value = 100
finish('Upload complete.')
break
case 'error':
finish(response.message)
break
}
}
current.onerror = (event) => {
if (worker !== current) return
event.preventDefault()
finish('The upload worker failed. Please try again.')
}
current.onmessageerror = () => {
if (worker === current) finish('Could not read the upload worker response.')
}
current.postMessage({ file } satisfies WorkerMessage)
} catch {
finish('Could not start the upload worker.')
}
})
cancelBtn.addEventListener('click', () => finish('Upload canceled.'))
window.addEventListener('pagehide', () => finish('Upload stopped.'))
Ejecuta el receptor local compatible
Guarda server.mts. Node ejecuta este módulo ES con la
eliminación nativa de tipos de TypeScript.
El receptor crea un archivo con un ID generado, acepta fragmentos en la siguiente posición esperada
y ofrece una descarga solo después de verificar la longitud final y calcular SHA-256. Limita los
datos de cada fragmento a 5 MiB, añade los fragmentos al disco y lee el archivo almacenado como un
flujo al calcular su suma de verificación.
Este es un protocolo didáctico para localhost con un límite de 16 MiB por archivo. No tiene
autenticación, reintentos, reanudación, caducidad ni un cupo total de disco. Los archivos permanecen
en received/; al reiniciar se pierden el registro de subidas en memoria y las
URL de descarga. Después de detener el ejemplo, elimina de ese directorio los archivos que ya no
necesites. El nombre de archivo del cliente nunca se usa como ruta de almacenamiento.
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash, randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { appendFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { parseArgs } from 'node:util'
const CHUNK_SIZE = 5 * 1024 * 1024
const MAX_FILE_SIZE = 16 * 1024 * 1024
const receivedRoot = new URL('./received/', import.meta.url)
interface Upload {
path: URL
size: number
received: number
complete: boolean
busy: boolean
}
const uploads = new Map<string, Upload>()
function reply(response: ServerResponse, status: number, value: unknown): void {
response.writeHead(status, { 'Content-Type': 'application/json' })
response.end(JSON.stringify(value))
}
async function readBody(request: IncomingMessage, limit: number): Promise<Buffer> {
const parts: Buffer[] = []
let length = 0
for await (const part of request) {
if (!(part instanceof Buffer)) throw new Error('Unexpected request body')
length += part.length
if (length > limit) throw new Error('Request body exceeds its limit')
parts.push(part)
}
return Buffer.concat(parts, length)
}
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
const url = new URL(request.url ?? '/', 'http://localhost')
if (request.method === 'GET' && (url.pathname === '/' || /^\/assets\/[\w.-]+\.(js|css)$/.test(url.pathname))) {
const path = url.pathname === '/' ? '/index.html' : url.pathname
const data = await readFile(new URL(`./dist${path}`, import.meta.url))
response.writeHead(200, {
'Content-Type': path.endsWith('.html') ? 'text/html' : path.endsWith('.css') ? 'text/css' : 'text/javascript',
})
response.end(data)
return
}
if (request.method === 'POST' && url.pathname === '/uploads') {
const sizeText = url.searchParams.get('size')
const size = sizeText !== null && /^\d+$/.test(sizeText) ? Number(sizeText) : NaN
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_FILE_SIZE) {
reply(response, 400, { error: 'Invalid file size' })
return
}
await readBody(request, 0)
const id = randomUUID()
const path = new URL(`${id}.bin`, receivedRoot)
await writeFile(path, Buffer.alloc(0), { flag: 'wx' })
uploads.set(id, { path, size, received: 0, complete: false, busy: false })
reply(response, 201, { id })
return
}
const match = /^\/uploads\/([0-9a-f-]{36})(\/complete)?$/.exec(url.pathname)
const upload = match?.[1] !== undefined ? uploads.get(match[1]) : undefined
if (!upload) {
reply(response, 404, { error: 'Upload not found' })
return
}
if (request.method === 'GET' && !match?.[2] && upload.complete) {
response.writeHead(200, { 'Content-Type': 'application/octet-stream' })
createReadStream(upload.path).on('error', () => response.destroy()).pipe(response)
return
}
if (upload.busy || upload.complete) {
reply(response, 409, { error: 'Upload is busy or already complete' })
return
}
upload.busy = true
try {
if (request.method === 'PUT' && !match?.[2]) {
const offsetText = url.searchParams.get('offset')
const offset = offsetText !== null && /^\d+$/.test(offsetText) ? Number(offsetText) : NaN
const expected = Math.min(CHUNK_SIZE, upload.size - upload.received)
if (offset !== upload.received || expected <= 0) {
reply(response, 409, { error: 'Unexpected chunk offset' })
return
}
const data = await readBody(request, expected)
if (data.length !== expected) {
reply(response, 400, { error: 'Unexpected chunk length' })
return
}
await appendFile(upload.path, data)
upload.received += data.length
reply(response, 204, null)
return
}
if (request.method === 'POST' && match?.[2] === '/complete') {
await readBody(request, 0)
if (upload.received !== upload.size || (await stat(upload.path)).size !== upload.size) {
reply(response, 409, { error: 'Upload is incomplete' })
return
}
const hash = createHash('sha256')
for await (const part of createReadStream(upload.path)) hash.update(part)
upload.complete = true
reply(response, 200, { size: upload.size, sha256: hash.digest('hex') })
return
}
reply(response, 405, { error: 'Method not allowed' })
} finally {
upload.busy = false
}
}
async function main(): Promise<void> {
const { values } = parseArgs({ options: { port: { type: 'string', default: '0' } } })
const port = Number(values.port)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port')
await readFile(new URL('./dist/index.html', import.meta.url))
await mkdir(receivedRoot, { recursive: true })
const server = createServer((request, response) => {
handle(request, response).catch(() => {
if (!response.headersSent) reply(response, 500, { error: 'Request failed' })
else response.destroy()
})
})
server.requestTimeout = 60_000
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 listening address')
console.log(`Open http://127.0.0.1:${address.port}`)
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : 'Receiver startup failed')
process.exitCode = 1
})
Desde el directorio del ejemplo, pega este bloque para verificar los tres entornos, compilar la
página y el worker y ejecutar el receptor. La cadena && impide que una
compilación fallida inicie una compilación anterior. El puerto cero solicita al sistema operativo
un puerto disponible; abre la URL que muestra el receptor. Usa Ctrl+C en esta terminal para
detenerlo. Para elegir un puerto fijo, reemplaza --port 0 por, por ejemplo,
--port 8000; si el puerto está ocupado, el inicio falla.
corepack yarn tsc --project tsconfig.json &&
corepack yarn tsc --project tsconfig.worker.json &&
corepack yarn tsc --project tsconfig.server.json &&
corepack yarn vite build &&
node server.mts --port 0
Elige un archivo pequeño y haz clic en Upload. Cuando aparezca Upload complete., usa Download received file y compara los bytes descargados con los del original. El SHA-256 mostrado describe el archivo del receptor; solo la rama para archivos pequeños también lo compara con un hash calculado del lado del cliente. La finalización confirma el cumplimiento de este contrato de subida local, sin afirmar que el archivo haya superado un análisis de malware u otro pipeline de procesamiento.
Prueba con un archivo de más de 5 MiB para probar varias solicitudes y un fragmento final corto. Haz clic en Cancel mientras haya una subida pendiente y luego inicia otra subida. La cancelación detiene el worker y su actividad del lado del cliente; los bytes aceptados pueden permanecer en el receptor, y una finalización que el servidor ya haya aceptado no se puede deshacer cancelando su respuesta. Un nuevo intento crea un ID nuevo. Dividir un archivo en fragmentos no hace que este protocolo permita reanudar las transferencias.
Para transferencias reanudables, el plugin Tus de Uppy usa el protocolo tus con un servidor compatible con el protocolo tus. Ese contrato de servidor es distinto del de este ejemplo.
