Fazer upload de arquivos com Web Worker e receptor local
Um Web Worker pode processar arquivos fora da thread principal da página e informar o progresso do upload por mensagens. Este exemplo conecta um worker dedicado a um receptor local executável: selecione um arquivo, faça o upload em blocos sequenciais e baixe o arquivo que o receptor realmente aceitou.
Escolha o que deve ficar em um worker
Um upload assíncrono comum não precisa de um worker. Use um quando seu pipeline também precisar analisar ou transformar arquivos; workers podem fazer requisições, mas não podem atualizar o DOM da página. Em vez disso, a página renderiza as mensagens deles. Veja como usar Web Workers.
Aqui, o cálculo do hash de um arquivo pequeno ilustra uma etapa de processamento. A própria Web Crypto é assíncrona, portanto este exemplo não afirma que o worker aumenta a velocidade do upload ou reduz o uso total de memória. Para vários arquivos, o tutorial sobre pool de workers e fluxos aborda filas e leituras incrementais de fluxos. Nesta página, vamos nos limitar a um arquivo e um worker.
Use Node.js 24.15.0 ou uma versão mais recente ainda mantida, Corepack com Yarn 4, Bash e um
navegador com workers de módulo, Web Crypto e AbortSignal.timeout(). O exemplo completo foi
testado no Linux com Node.js 24.15.0 e 26.8.1 e Chromium 145. Em 2 de outubro de 2026, o Node 24 é
LTS e o Node 26 é Current; escolha uma versão de correção atual na
lista de versões do Node. Se o Corepack não estiver instalado,
siga as instruções de instalação do Yarn antes de continuar.
Crie o projeto local e a página
Cole isto no Bash. O script recusa um diretório existente, isola o projeto Yarn com seu próprio
arquivo de lock e retorna ao diretório original se a instalação falhar. Uma instalação malsucedida
pode deixar o novo diretório da demonstração no disco; inspecione-o e escolha um novo nome antes de
tentar novamente. Todos os arquivos a seguir devem ficar em 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
Salve isto como index.html. O receptor servirá a página compilada e as rotas de
upload a partir da mesma origem de loopback. Abra a URL HTTP exibida por ele, em vez de abrir este
arquivo diretamente.
<!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>
Defina e verifique o contrato de mensagens
Salve src/worker-types.ts. Uma mensagem de conclusão inclui a soma de verificação do
receptor e a URL de download, para que a página possa mostrar um resultado observável.
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 }
Salve tsconfig.json para a 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"]
}
Salve tsconfig.worker.json. Verificar os workers separadamente evita combinar os globais
do DOM e dos workers. O Vite compila o worker de módulo, enquanto o
TypeScript verifica seus tipos.
{
"extends": "./tsconfig.json",
"compilerOptions": { "lib": ["ES2022", "WebWorker"] },
"include": ["src/upload.worker.ts", "src/worker-types.ts"]
}
Salve tsconfig.server.json para o receptor Node:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["server.mts"]
}
Envie um bloco por vez a partir do worker
Salve src/upload.worker.ts. Arquivos de até 5 MiB têm seu hash calculado antes do envio;
arquivos maiores pulam o cálculo do hash do arquivo inteiro. Cada requisição envia um
Blob bruto, aguarda uma confirmação HTTP e então avança para o próximo
offset. Arquivos vazios não precisam de requisições de blocos, mas ainda exigem finalização.
xhr.upload fornece o progresso da transferência.
Esses eventos não confirmam a aceitação pelo servidor. A barra permanece abaixo de 100% até que a
requisição final seja bem-sucedida, inclusive quando todos os bytes já saíram do 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.' }))
}
Renderize o progresso e libere o worker
Salve src/main.ts. O File selecionado é capturado uma
única vez, e os controles desabilitados impedem uploads simultâneos. Todo caminho de término
encerra o worker. A verificação de identidade também ignora uma
mensagem na fila de um worker que já foi 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.'))
Execute o receptor local compatível
Salve server.mts. O Node executa este módulo ES com
remoção nativa de tipos do TypeScript. O receptor cria um arquivo
com um ID gerado, aceita blocos no próximo offset esperado e disponibiliza um download somente
após verificar o tamanho final e calcular o SHA-256. Ele limita a carga útil de cada bloco a 5 MiB,
acrescenta os blocos ao arquivo em disco e lê o arquivo armazenado como um fluxo ao calcular sua
soma de verificação.
Este é um protocolo didático para localhost com limite de 16 MiB por arquivo. Ele não tem
autenticação, novas tentativas, retomada, expiração nem cota total de disco. Os arquivos permanecem
em received/; reiniciar perde o registro de uploads em memória e as URLs de
download. Após parar a demonstração, remova desse diretório os arquivos de que não precisar mais.
O nome de arquivo fornecido pelo cliente nunca é usado como caminho de armazenamento.
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
})
No diretório da demonstração, cole este bloco para verificar os três ambientes, compilar a página
e o worker e executar o receptor. A cadeia && impede que uma falha na
compilação inicie uma compilação anterior. A porta zero solicita ao sistema operacional uma porta
disponível; abra a URL exibida pelo receptor. Use Ctrl+C neste terminal para pará-lo. Para escolher
uma porta fixa, substitua --port 0 por, por exemplo,
--port 8000; uma porta ocupada faz a inicialização falhar.
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
Escolha um arquivo pequeno e clique em Upload. Quando Upload complete. aparecer, use Download received file e compare os bytes baixados com os do arquivo original. O SHA-256 exibido descreve o arquivo do receptor; somente o ramo para arquivos pequenos também o compara com um hash calculado no lado do cliente. A conclusão confirma este contrato de upload local, sem afirmar que o arquivo passou por uma varredura de malware ou outro pipeline de processamento.
Experimente um arquivo maior que 5 MiB para testar várias requisições e um bloco final curto. Clique em Cancel enquanto um upload estiver pendente e então inicie outro upload. O cancelamento interrompe o worker e sua atividade no lado do cliente; os bytes aceitos podem permanecer no receptor, e uma finalização já aceita pelo servidor não pode ser desfeita pelo cancelamento da resposta. Uma nova tentativa cria um novo ID. Dividir um arquivo em blocos não torna este protocolo retomável.
Para transferências retomáveis, o plugin Tus do Uppy usa o protocolo tus com um servidor tus compatível. Esse contrato com o servidor é diferente do usado nesta demonstração.
