Crie uploads HTML5 de arquivos com arraste e solte em JavaScript
Um upload em lote precisa de um resultado para cada arquivo, mesmo quando uma requisição falha. Crie um uploader em JavaScript que permite selecionar ou soltar vários arquivos, envia um de cada vez e mantém visíveis o progresso e a confirmação do servidor para cada arquivo.
Configure um uploader local em lote
Você precisa de um navegador, um shell POSIX como o Bash e Node.js 24.15 ou mais recente na linha
24.x mantida, ou Node.js 26.5 ou mais recente na linha 26.x. Não há pacotes para instalar nem etapas
de build. O servidor usa as APIs nativas do Node Request, File e de parsing multipart. Mantenha a extensão .mts:
o Node a trata como um módulo ES
mesmo dentro de um projeto CommonJS.
O passo a passo foi testado no Linux com Node.js 24.15.0, 26.5.0 e 26.8.1, e com o Chromium 145.
Este exemplo verifica a concordância dos bytes e depois descarta o upload. Ele aceita qualquer tipo de arquivo, inclusive arquivos vazios, com até 5 MiB cada e no máximo 10 arquivos por seleção. Se você só precisa de um arquivo com novas tentativas manuais, use o passo a passo do uploader de arquivo único.
No diretório onde você guarda seus experimentos, crie uma nova pasta:
mkdir html5-upload-demo
Se ela já existir, pare e escolha outro nome de diretório; não sobrescreva um projeto existente.
Salve os próximos quatro blocos de código como index.html, styles.css, upload.js e server.mts dentro
dessa pasta.
Configure um formulário básico de arrastar e soltar
Salve como index.html. O elemento input nativo continua visível e acessível pelo teclado.
O atributo multiple
permite vários arquivos em uma única seleção. Escolher de novo substitui a fila enquanto ela está
ociosa.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Batch file uploader</title>
<link rel="stylesheet" href="styles.css" />
<script src="upload.js" defer></script>
</head>
<body>
<main>
<h1>Upload a batch of files</h1>
<form id="upload-form" aria-label="Batch upload">
<section id="drop-zone" aria-label="Drop files">
<label for="file-input">Choose files</label>
<input id="file-input" type="file" multiple aria-describedby="file-help" />
<p id="file-help">Choose or drop up to 10 files, each no larger than 5 MiB.</p>
</section>
<button id="upload-button" type="submit" disabled>Upload batch</button>
</form>
<p id="batch-status" role="status" aria-atomic="true">Choose files to begin.</p>
<ol id="queue" aria-label="File queue"></ol>
<noscript>This uploader needs JavaScript enabled.</noscript>
</main>
</body>
</html>
Estilize a fila
Salve como styles.css. As cores do sistema seguem o tema claro ou escuro do navegador. Nomes longos e
checksums quebram a linha em vez de alargar a página.
:root { color-scheme: light dark; }
body {
font: 1rem/1.5 system-ui, sans-serif;
max-width: 45rem;
margin: 2rem auto;
padding: 0 1rem;
color: CanvasText;
background: Canvas;
}
#drop-zone { border: 2px dashed currentColor; padding: 1rem; }
#drop-zone.drag-over { outline: 3px solid Highlight; }
label { display: block; font-weight: bold; }
input { max-width: 100%; }
button, input::file-selector-button { font: inherit; padding: 0.5rem; }
button { margin-top: 1rem; }
:focus-visible { outline: 3px solid Highlight; outline-offset: 3px; }
#queue { padding-left: 1.5rem; }
#queue li { margin-block: 1rem; overflow-wrap: anywhere; }
progress { display: block; width: 100%; }
Implemente as interações de arrastar e soltar
Salve como upload.js. Cada linha da fila mostra o nome do arquivo e o tamanho em bytes antes do envio;
este exemplo não decodifica arquivos para gerar pré-visualizações de imagem. As verificações na
seleção dão feedback imediato, e o receptor aplica de forma independente seus limites de arquivo e
de corpo da requisição.
Usamos XMLHttpRequest.upload
para o progresso do corpo da requisição. Cada barra pertence a um arquivo e inclui o overhead do
multipart. Uma barra em 100% significa que o corpo foi enviado; a linha ainda aguarda a resposta.
Somente um HTTP 200 com a contagem de bytes e o SHA-256 esperados se torna um resultado aceito.
A fila fica bloqueada durante todo o lote: desabilite o seletor e o botão de envio, ignore arquivos soltos e envios adicionais e continue após qualquer falha individual. Toda requisição tem um prazo de 30 segundos, incluindo a espera pela resposta. Os resultados ficam visíveis até a próxima seleção; enviar de novo a mesma fila concluída fica desabilitado.
const form = document.getElementById('upload-form')
const input = document.getElementById('file-input')
const zone = document.getElementById('drop-zone')
const button = document.getElementById('upload-button')
const status = document.getElementById('batch-status')
const list = document.getElementById('queue')
const maxSize = 5 * 1024 * 1024
const number = new Intl.NumberFormat('en-US')
let queue = []
let busy = false
let started = false
function updateControls() {
input.disabled = busy
button.disabled = busy || started || queue.length === 0
}
function choose(files) {
if (busy || files.length === 0) return
queue = []
started = false
list.replaceChildren()
const oversized = files.find((file) => file.size > maxSize)
if (files.length > 10) {
status.textContent = 'Choose at most 10 files.'
} else if (oversized) {
status.textContent = `${oversized.name}: exceeds the 5 MiB limit. Select the batch again.`
} else {
queue = files.map((file, index) => {
const row = document.createElement('li')
const name = document.createElement('strong')
name.textContent = `${file.name} (${number.format(file.size)} bytes)`
const label = document.createElement('label')
label.htmlFor = `progress-${index}`
label.textContent = `Request body sent: ${file.name}`
const progress = document.createElement('progress')
progress.id = label.htmlFor
progress.setAttribute('aria-label', label.textContent)
progress.max = 100
progress.value = 0
const message = document.createElement('p')
message.textContent = 'Queued.'
row.append(name, label, progress, message)
list.append(row)
return { file, progress, message }
})
status.textContent = `${queue.length} files ready. Choose Upload batch to start.`
}
updateControls()
}
input.addEventListener('change', () => {
choose(Array.from(input.files))
// Keep our File objects; allow the same selection to fire change next time.
input.value = ''
})
zone.addEventListener('dragover', (event) => {
event.preventDefault()
if (!busy) zone.classList.add('drag-over')
})
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'))
zone.addEventListener('drop', (event) => {
event.preventDefault()
zone.classList.remove('drag-over')
choose(Array.from(event.dataTransfer.files))
})
function send(item, sha256) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) item.progress.value = (event.loaded / event.total) * 100
else item.progress.removeAttribute('value')
})
xhr.upload.addEventListener('load', () => {
item.progress.value = 100
item.message.textContent = 'Body sent. Waiting for server acknowledgment…'
})
xhr.addEventListener('load', () => {
const receipt = xhr.response
if (xhr.status === 200 && receipt?.bytes === item.file.size && receipt?.sha256 === sha256) {
item.message.textContent = `Accepted. ${number.format(receipt.bytes)} bytes; SHA-256 ${receipt.sha256}.`
resolve(true)
} else {
item.message.textContent = xhr.status === 200
? 'Failed: invalid server receipt.'
: `Failed: server returned HTTP ${xhr.status}.`
resolve(false)
}
})
xhr.addEventListener('error', () => {
item.message.textContent = 'Failed: network error. Server acceptance is unknown.'
resolve(false)
})
xhr.addEventListener('timeout', () => {
item.message.textContent = 'Failed: request timed out. Server acceptance is unknown.'
resolve(false)
})
xhr.open('POST', '/upload')
xhr.responseType = 'json'
xhr.timeout = 30000
const body = new FormData()
body.append('file', item.file)
body.append('sha256', sha256)
xhr.send(body)
})
}
async function uploadBatch() {
if (busy || started || queue.length === 0) return
busy = true
started = true
updateControls()
let accepted = 0
let failed = 0
for (const item of queue) {
status.textContent = `Uploading ${item.file.name}…`
item.message.textContent = 'Preparing checksum…'
try {
const digest = await crypto.subtle.digest('SHA-256', await item.file.arrayBuffer())
const sha256 = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('')
item.message.textContent = 'Sending request body…'
if (await send(item, sha256)) accepted += 1
else failed += 1
} catch {
item.message.textContent = 'Failed: could not prepare or send this file.'
failed += 1
}
}
status.textContent = `Batch finished: ${accepted} accepted, ${failed} failed. Select files for a new batch.`
busy = false
updateControls()
}
form.addEventListener('submit', (event) => {
event.preventDefault()
void uploadBatch()
})
O checksum usa crypto.subtle.digest()
e lê um arquivo por vez para a memória. Sirva a página pela URL de loopback exibida no terminal para
que a Web Crypto esteja disponível. Deixe Content-Type sem definir: o navegador fornece o
delimitador multipart
para FormData.
Adicione um receptor que confirma cada arquivo
Salve como server.mts. Ele serve apenas os três assets do navegador e aceita um campo file mais um campo
sha256 por requisição. Ele lê o corpo completo antes de fazer o parsing, permitindo 16 KiB de
overhead do multipart além do limite de 5 MiB por arquivo. Uma requisição que ultrapassa esse limite
de corpo recebe HTTP 413. O checksum de uma resposta bem-sucedida vem dos bytes que o receptor
realmente leu.
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
const maxSize = 5 * 1024 * 1024
const maxBody = maxSize + 16 * 1024
let origin = ''
const assets = new Map<string, { body: Buffer; type: string }>()
function reply(res: ServerResponse, code: number, data: object): void {
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
res.end(JSON.stringify(data))
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.headers.host !== new URL(origin).host) {
reply(res, 403, { error: 'Use the printed loopback URL.' })
return
}
if (req.method === 'GET' && req.url === '/favicon.ico') {
res.writeHead(204).end()
return
}
const asset = assets.get(req.url ?? '')
if (req.method === 'GET' && asset) {
res.writeHead(200, { 'Content-Type': asset.type })
res.end(asset.body)
return
}
if (req.method !== 'POST' || req.url !== '/upload') {
reply(res, 404, { error: 'Not found.' })
return
}
if (req.headers.origin !== origin) {
reply(res, 403, { error: 'Use the uploader on this server.' })
return
}
const chunks: Buffer[] = []
let bytes = 0
// Keep the socket available for an error response when leaving iteration early.
for await (const chunk of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
bytes += chunk.length
if (bytes > maxBody) {
reply(res, 413, { error: 'Request body exceeds the limit.' })
req.resume()
return
}
chunks.push(chunk)
}
let data: FormData
try {
data = await new Request(origin, {
method: 'POST',
headers: { 'Content-Type': req.headers['content-type'] ?? '' },
body: Buffer.concat(chunks),
}).formData()
} catch {
reply(res, 400, { error: 'Invalid multipart body.' })
return
}
const file = data.get('file')
const expected = data.get('sha256')
if ([...data.keys()].length !== 2 || !(file instanceof File) || typeof expected !== 'string') {
reply(res, 400, { error: 'Expected one file and one checksum.' })
return
}
if (file.size > maxSize) {
reply(res, 413, { error: 'File exceeds the 5 MiB limit.' })
return
}
const sha256 = createHash('sha256').update(new Uint8Array(await file.arrayBuffer())).digest('hex')
if (sha256 !== expected) {
reply(res, 422, { error: 'Checksum does not match.' })
return
}
reply(res, 200, { bytes: file.size, sha256 })
}
async function main(): Promise<void> {
const portText = process.env.PORT ?? '0'
const port = Number(portText)
if (!/^\d+$/.test(portText) || !Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('PORT must be an integer from 0 to 65535.')
}
for (const [route, file, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/styles.css', 'styles.css', 'text/css'],
['/upload.js', 'upload.js', 'text/javascript'],
]) {
assets.set(route, { body: await readFile(new URL(file, import.meta.url)), type })
}
const server = createServer((req, res) => {
void handle(req, res).catch(() => {
if (!res.destroyed && !res.writableEnded) reply(res, 500, { error: 'Unable to process upload.' })
})
})
server.requestTimeout = 35000
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 TCP address')
origin = `http://127.0.0.1:${address.port}`
console.log(`Open ${origin}`)
}
main().catch((error: unknown) => {
const code = error instanceof Error && 'code' in error ? error.code : ''
console.error(code === 'EADDRINUSE'
? 'Port is already in use. Choose another PORT or leave it unset.'
: 'Could not start uploader. Check PORT and the three browser files.')
process.exitCode = 1
})
O servidor se vincula ao loopback e escolhe uma porta disponível quando PORT não está definido ou vale
zero. Você pode definir PORT no ambiente do servidor se precisar de uma porta fixa. Assets ausentes, um
PORT inválido ou uma porta ocupada interrompem a inicialização com um status de saída diferente de
zero.
Execute o lote e leia os resultados
No diretório pai onde você criou html5-upload-demo, cole:
(cd html5-upload-demo && node server.mts)
Abra a URL exibida, como http://127.0.0.1:49152. Mantenha esse terminal aberto; Ctrl+C encerra o servidor e
leva você de volta ao diretório pai. Reinicie após editar um asset, já que o servidor carrega esses
arquivos na inicialização.
Navegue com Tab até Choose files, abra o seletor e selecione dois arquivos de tamanhos diferentes. Navegue com Tab até Upload batch e ative o botão. Cada linha deve mostrar Accepted., sua contagem de bytes e um checksum SHA-256. O status final informa dois arquivos aceitos e zero arquivos com falha. Nada foi salvo no servidor.
Experimente soltar arquivos na área contornada como uma segunda seleção. Enquanto um lote está em execução, o input e o botão ficam desabilitados, e soltar outros arquivos não altera as linhas atuais. Em uma conexão local rápida, uma barra pode pular direto para 100%; os eventos de progresso não são uma animação suave nem uma medida do processamento no servidor.
Uma requisição rejeitada mantém sua linha com falha no lugar, e o upload do próximo arquivo da fila ainda prossegue. HTTP 400 indica dados multipart malformados ou campos errados, 403 uma divergência de origem ou host, 413 um limite de tamanho e 422 uma divergência de checksum. Um recibo HTTP 200 malformado ou inconsistente também falha. Se o receptor ficar inacessível ou exceder o prazo, a aceitação é desconhecida: a ausência de resposta não diz se um servidor concluiu o trabalho. Para tentar de novo, selecione os arquivos com falha como um novo lote; cada nova tentativa envia novamente todo o conteúdo deles. Recarregar a página faz a fila ser esquecida.
Decida o que cabe em um uploader público
Este receptor local verifica tamanhos e a concordância dos bytes, aceita conteúdos arbitrários e os descarta. Um checksum não comprova que um arquivo é seguro ou totalmente decodificável. As verificações de host e origem não são autenticação de usuário. Antes de reter uploads em um serviço público, adicione autorização, validação de conteúdo, uma política de armazenamento e proteção contra CSRF onde sessões baseadas em cookies precisarem dela; o guia de upload da OWASP explica esses controles. Mantenha os uploads armazenados fora da raiz web e trate uma confirmação perdida sem criar registros duplicados, por exemplo com uma chave de idempotência aplicada pelo servidor.
Ao adaptar a interface, mantenha o input de arquivo visível, as barras de progresso rotuladas, os contornos de foco e a região dinâmica de status do lote. As falhas têm texto e continuam legíveis sem depender de cor. Uma única porcentagem para o lote inteiro exigiria uma política de ponderação definida; tirar a média das porcentagens dá a um arquivo minúsculo o mesmo peso que a um arquivo grande.
Para uma interface mais completa, o plugin XHRUpload do Uppy oferece suporte a
uploads multipart para um receptor compatível. Para retomada, o plugin Tus do Uppy
exige um servidor compatível com tus. Nenhuma das opções torna este receptor /upload retomável.
