Criando um uploader de arquivos sob medida com JavaScript e HTML
Mantenha o input de arquivo nativo e construa sua interface personalizada em torno dele. Este passo a passo entrega um uploader em JavaScript sem framework, com seleção por teclado, arrastar e soltar, progresso e retentativas, além de um servidor local que confere os bytes recebidos com o checksum SHA-256 do arquivo selecionado.

Configure um projeto de upload local
Você precisa do Node.js 24 ou mais recente, de um navegador e de um terminal. O exemplo usa as APIs Request e File nativas do Node, então não há pacotes para instalar. O passo a passo foi testado no Linux com Node.js 24.2.0, 26.5.0 e 26.8.1, e com Chromium 145 e 152.
Vamos fazer upload de um JPEG, PNG ou PDF não vazio por vez, de até 10 MiB. Cada tentativa envia o arquivo inteiro como dados de formulário multipart. Isso mantém o navegador e o servidor pequenos o bastante para rodarem juntos sem implementar um protocolo de montagem de blocos.
Execute isto em um shell POSIX, como o Bash, a partir de um diretório onde você guarda experimentos:
mkdir custom-uploader &&
cd custom-uploader &&
touch index.html styles.css script.js server.ts
O comando se recusa a usar um diretório existente. Se alguma etapa falhar, pare e resolva o problema antes de continuar; escolha outro nome de diretório novo, se necessário. Cole os próximos quatro exemplos nos arquivos vazios que acabaram de ser criados. Os uploads não vão criar nem sobrescrever arquivos: o receptor calcula o hash dos bytes em memória e os descarta depois de responder. Uma nova execução confere os bytes novamente. Mantenha esta demonstração na sua própria máquina.
Configurando a estrutura HTML
Salve isto como index.html. O input de arquivo com rótulo continua visível e acessível com Tab. A
zona de soltar é uma forma alternativa de selecionar um arquivo, e o botão Upload separado inicia a
requisição. As mensagens de status permanecem na tela em uma live region do tipo polite.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Custom file uploader</title>
<link rel="stylesheet" href="styles.css" />
<script src="script.js" defer></script>
</head>
<body>
<main>
<h1>Upload a file</h1>
<form id="upload-form" aria-label="File upload">
<section id="drop-zone" aria-label="Drop a file">
<label for="file-input">Choose a file</label>
<input id="file-input" type="file" accept="image/jpeg,image/png,application/pdf"
aria-describedby="file-help selection" />
<p id="file-help">Choose or drop one JPEG, PNG, or PDF, up to 10 MiB.</p>
</section>
<p id="selection">No file selected.</p>
<button id="upload" type="submit" disabled>Upload</button>
<button id="retry" type="button" disabled>Retry</button>
</form>
<p><label for="progress">Request body sent</label></p>
<progress id="progress" max="100" value="0"></progress>
<p id="status" role="status" aria-live="polite" aria-atomic="true">Choose a file to begin.</p>
<pre id="receipt" aria-label="Server receipt"></pre>
<noscript>This uploader needs JavaScript enabled.</noscript>
</main>
</body>
</html>
Estilizando o uploader de arquivos com CSS
Salve isto como styles.css. Estilize o botão do seletor e o contorno de foco do input sem ocultar o
input. Usar display: none o removeria da navegação por teclado e das tecnologias assistivas;
veja o exemplo de input de arquivo da MDN.
body {
font: 1rem/1.5 system-ui, sans-serif;
margin: 2rem auto;
padding: 0 1rem;
max-width: 40rem;
color: #172b4d;
background: #fff;
}
#drop-zone {
border: 2px dashed #52647c;
border-radius: 0.5rem;
padding: 1.5rem;
}
#drop-zone.dragover { background: #e8f1ff; }
label { display: block; font-weight: bold; }
input { max-width: 100%; }
button, input::file-selector-button {
font: inherit;
padding: 0.5rem 1rem;
margin: 0.5rem 0;
cursor: pointer;
}
:focus-visible { outline: 3px solid #075ac7; outline-offset: 3px; }
button:disabled { cursor: default; }
progress { width: 100%; }
#status { min-height: 3rem; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
Implementando JavaScript para lidar com a seleção e o upload de arquivos
Salve isto como script.js. XMLHttpRequest expõe
eventos de progresso de upload.
Eles medem a transmissão do corpo da requisição, incluindo o overhead do multipart. Chegar a 100%
não significa que o servidor aceitou o arquivo. Somente uma resposta HTTP 200 com tamanho e checksum
correspondentes produz a mensagem “Accepted”.
A política para o estado ocupado é explícita: desabilitar o seletor e os botões, ignorar arquivos soltos e envios adicionais, e manter o arquivo atual até a requisição terminar. Um erro de rede, timeout ou erro do servidor habilita o botão Retry, com no máximo três tentativas por seleção. As retentativas enviam o arquivo inteiro novamente; nenhuma retentativa é executada automaticamente. Uma requisição rejeitada ou um recibo inválido exige uma nova seleção.
const form = document.getElementById('upload-form')
const input = document.getElementById('file-input')
const zone = document.getElementById('drop-zone')
const selection = document.getElementById('selection')
const upload = document.getElementById('upload')
const retry = document.getElementById('retry')
const progress = document.getElementById('progress')
const status = document.getElementById('status')
const receipt = document.getElementById('receipt')
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
const maxSize = 10 * 1024 * 1024
let selected = null
let busy = false
let attempts = 0
let retryable = false
function updateControls() {
input.disabled = busy
upload.disabled = busy || !selected || attempts > 0
retry.disabled = busy || !selected || !retryable || attempts >= 3
}
function choose(files) {
if (busy) return
selected = null
attempts = 0
retryable = false
progress.value = 0
receipt.textContent = ''
selection.textContent = 'No file selected.'
const file = files[0]
if (files.length !== 1) {
status.textContent = 'Choose exactly one file.'
} else if (!allowedTypes.includes(file.type)) {
status.textContent = 'Choose a JPEG, PNG, or PDF with a recognized MIME type.'
} else if (file.size === 0 || file.size > maxSize) {
status.textContent = 'The file must be nonempty and no larger than 10 MiB.'
} else {
selected = file
selection.textContent = file.name
status.textContent = 'Ready to upload.'
}
updateControls()
}
input.addEventListener('change', () => {
choose(Array.from(input.files))
// Retain the File ourselves so selecting the same file can fire change again.
input.value = ''
})
zone.addEventListener('dragover', (event) => {
event.preventDefault()
if (!busy) zone.classList.add('dragover')
})
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'))
zone.addEventListener('drop', (event) => {
event.preventDefault()
zone.classList.remove('dragover')
choose(Array.from(event.dataTransfer.files))
})
function send(file, sha256) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) progress.value = (event.loaded / event.total) * 100
})
xhr.upload.addEventListener('load', () => {
progress.value = 100
status.textContent = 'Body sent. Waiting for server acceptance…'
})
xhr.addEventListener('load', () => {
const result = xhr.response
if (xhr.status === 200 && result?.bytes === file.size && result?.sha256 === sha256) {
resolve({ ok: true, result })
} else {
resolve({
ok: false,
retryable: xhr.status >= 500,
message: xhr.status === 200
? 'Invalid server receipt.'
: `Server rejected the upload (HTTP ${xhr.status}).`,
})
}
})
xhr.addEventListener('error', () => {
resolve({ ok: false, retryable: true, message: 'Network error.' })
})
xhr.addEventListener('timeout', () => {
resolve({ ok: false, retryable: true, message: 'Request timed out.' })
})
xhr.open('POST', '/upload')
xhr.responseType = 'json'
xhr.timeout = 30000
const body = new FormData()
body.append('file', file)
body.append('sha256', sha256)
xhr.send(body)
})
}
async function startUpload() {
if (busy || !selected || attempts >= 3 || (attempts > 0 && !retryable)) return
busy = true
retryable = false
attempts += 1
updateControls()
progress.value = 0
receipt.textContent = ''
status.textContent = `Preparing attempt ${attempts} of 3…`
try {
const digest = await crypto.subtle.digest('SHA-256', await selected.arrayBuffer())
const sha256 = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('')
status.textContent = `Uploading ${selected.name} (attempt ${attempts} of 3)…`
const outcome = await send(selected, sha256)
if (outcome.ok) {
status.textContent = `Accepted: ${selected.name}. Size and SHA-256 match. No file was saved.`
receipt.textContent = JSON.stringify(outcome.result, null, 2)
} else {
retryable = outcome.retryable
const next = retryable && attempts < 3
? 'Choose Retry to send it again.'
: 'Select a file to start again.'
status.textContent = `${outcome.message} ${next}`
}
} catch {
status.textContent = 'Could not prepare or send this file. Select it again.'
} finally {
busy = false
updateControls()
}
}
form.addEventListener('submit', (event) => {
event.preventDefault()
void startUpload()
})
retry.addEventListener('click', () => void startUpload())
O navegador calcula o checksum com
crypto.subtle.digest().
Isso lê o arquivo pequeno para a memória. Sirva a página na URL de loopback exibida pelo servidor
abaixo para que a Web Crypto esteja disponível; não abra index.html diretamente. Deixe Content-Type sem
definir: o FormData fornece seu próprio boundary multipart.
Adicione o receptor local
Salve isto como server.ts. Ele serve apenas nossos três arquivos do navegador e aceita exatamente
dois campos multipart: file e sha256. O limite do corpo reserva 16 KiB para os cabeçalhos
multipart, além do arquivo de 10 MiB. O receptor calcula o próprio checksum antes de responder.
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 = 10 * 1024 * 1024
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
const assets = new Map<string, { body: Buffer; type: string }>()
for (const [route, file, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/styles.css', 'styles.css', 'text/css'],
['/script.js', 'script.js', 'text/javascript'],
]) {
assets.set(route, { body: await readFile(new URL(file, import.meta.url)), type })
}
let origin = ''
function reply(res: ServerResponse, status: number, data: object): void {
res.writeHead(status, { '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
}
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 size = 0
// Keep the socket open long enough to return 413 when stopping iteration early.
for await (const chunk of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
size += chunk.length
if (size > maxSize + 16 * 1024) {
reply(res, 413, { error: 'Request body is too large.' })
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 === 0 || file.size > maxSize) {
reply(res, 413, { error: 'File must be nonempty and no larger than 10 MiB.' })
return
}
if (!allowedTypes.includes(file.type)) {
reply(res, 415, { error: 'Unsupported declared MIME type.' })
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, { name: file.name, bytes: file.size, sha256 })
}
const server = createServer((req, res) => {
void handle(req, res).catch(() => {
reply(res, 500, { error: 'Unable to process the upload.' })
})
})
server.requestTimeout = 30000
server.listen(0, '127.0.0.1', () => {
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}`)
})
O atributo accept é uma dica para o seletor, não uma validação.
Tanto o navegador quanto este receptor verificam o tipo MIME declarado; nenhum dos dois prova que
os bytes são uma imagem ou um PDF válidos ou seguros. O checksum estabelece a concordância dos
bytes, não a confiabilidade. Esta demonstração não tem contas de usuário, armazenamento persistente,
varredura de segurança do conteúdo nem decodificação de formato. Ela se vincula à interface de
loopback e verifica a origem do navegador, mas essas verificações não são autenticação de usuário.
Um serviço público precisa de autorização própria, proteção contra CSRF para sessões baseadas em
cookies, validação de conteúdo e uma política de armazenamento.
Execute e confira o resultado
De dentro de custom-uploader, execute:
node server.ts
Abra a URL exibida, como http://127.0.0.1:49152. O servidor escolhe uma porta disponível, que pode mudar
ao reiniciar. Ele lê os arquivos do navegador na inicialização, então reinicie-o depois de editá-los.
Pare-o com Ctrl+C ao terminar.
Pressione Tab para focar em “Choose a file”, abra o seletor com o teclado e selecione um PNG pequeno. Vá com Tab até Upload e ative-o. O status final deve dizer “Accepted”, e o recibo deve mostrar o nome do arquivo, o tamanho em bytes e um valor SHA-256 de 64 caracteres. O navegador comparou esse valor com o próprio checksum; nada foi salvo no servidor.
Teste também estes casos de falha e de interação:
- Solte um arquivo na área contornada e depois faça o upload. Soltar dois arquivos deve pedir exatamente um.
- Teste um arquivo vazio, um arquivo de texto e um arquivo maior que 10 MiB. Cada um deve produzir uma explicação persistente sem iniciar uma requisição. Arquivos com tipo MIME vazio ou não reconhecido também são rejeitados, mesmo que a extensão pareça aceitável.
- Use as ferramentas de rede do navegador para deixar um upload mais lento. Enquanto ele estiver pendente, o seletor e os botões devem ficar desabilitados, e arquivos soltos não devem alterar a seleção ativa. A barra pode chegar a 100% enquanto o status ainda diz que está aguardando a aceitação do servidor.
- Com a página já carregada, coloque o navegador offline e faça o upload. Depois de “Network error”, volte a ficar online e escolha Retry. O upload do mesmo arquivo selecionado deve funcionar. Mantenha o navegador offline durante as três tentativas para ver o limite de retentativas e, em seguida, selecione o mesmo arquivo de novo para iniciar um novo conjunto de tentativas.
Se uma requisição retornar HTTP 400, confira os nomes dos campos multipart; 403 significa que a origem ou o host não correspondeu à URL exibida. HTTP 413 indica o limite de tamanho, 415 um tipo MIME declarado não suportado e 422 uma divergência de checksum. Chegar a 100% e depois receber um desses erros é um upload com falha. O prazo de 30 segundos no cliente cobre a requisição e a resposta; aumente-o deliberadamente se você experimentar conexões mais lentas.
Quando você precisa de uploads retomáveis
Aqui, retentar significa enviar o arquivo inteiro novamente enquanto a página está aberta. Recarregar a página esquece a seleção e a contagem de tentativas. Se você adicionar armazenamento persistente, trate a perda de uma resposta de sucesso: uma retentativa não pode criar registros duplicados. Use uma chave de idempotência imposta pelo servidor nesse fluxo.
Para arquivos grandes que precisam continuar a partir de um offset de bytes aceito anteriormente, use um cliente e servidor tus. Isso exige um protocolo retomável dos dois lados; dividir um arquivo em blocos apenas com JavaScript no navegador não oferece isso.
