Implementa subidas HTML5 con arrastrar y soltar y JavaScript
Una subida por lotes necesita un resultado para cada archivo, incluso cuando falla una solicitud. Crea un cargador en JavaScript que permita seleccionar o soltar varios archivos, los envíe uno por uno y mantenga visibles el progreso y la confirmación del servidor para cada archivo.
Configura un cargador local por lotes
Necesitas un navegador, un shell POSIX como Bash y Node.js 24.15 o posterior de la rama 24.x con
mantenimiento, o Node.js 26.5 o posterior de la rama 26.x. No hay paquetes que instalar ni pasos de
compilación. El servidor usa las API integradas de Node para Request,
File y el análisis de datos multipart. Conserva la extensión .mts:
Node la trata como un módulo ES
incluso dentro de un proyecto CommonJS.
Esta guía se probó en Linux con Node.js 24.15.0, 26.5.0 y 26.8.1, y Chromium 145.
Este ejemplo comprueba la coincidencia de bytes y luego descarta el archivo subido. Acepta cualquier tipo de archivo, incluidos los vacíos, de hasta 5 MiB cada uno, con un máximo de 10 archivos por selección. Si solo necesitas un archivo con reintentos manuales, usa la guía del cargador de un solo archivo.
Desde el directorio donde guardas tus experimentos, crea una carpeta nueva:
mkdir html5-upload-demo
Si ya existe, detente y elige otro nombre de directorio; no sobrescribas un proyecto existente.
Guarda los siguientes cuatro bloques de código como index.html,
styles.css, upload.js y server.mts
dentro de esa carpeta.
Configura un formulario básico de arrastrar y soltar
Guárdalo como index.html. El campo nativo permanece visible y accesible mediante
el teclado. El atributo multiple
permite incluir varios archivos en una selección. Volver a seleccionar reemplaza la cola cuando
está inactiva.
<!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>
Aplica estilos a la cola
Guárdalo como styles.css. Los colores del sistema siguen el tema claro u oscuro
del navegador. Los nombres largos y las sumas de verificación se distribuyen en varias líneas en
lugar de ensanchar la 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%; }
Implementa las interacciones de arrastrar y soltar
Guárdalo como upload.js. Cada fila de la cola muestra el nombre del archivo y
su tamaño en bytes antes del envío; este ejemplo no decodifica los archivos para obtener vistas
previas de imágenes. Las comprobaciones de la selección ofrecen información inmediata y el receptor
aplica de forma independiente sus límites para archivos y cuerpos de solicitud.
Usamos XMLHttpRequest.upload
para mostrar el progreso del cuerpo de la solicitud. Cada barra corresponde a un archivo e incluye
los datos adicionales de multipart. Una barra al 100 % significa que se envió el cuerpo; la fila
sigue esperando la respuesta. Solo una respuesta HTTP 200 con el número de bytes y el SHA-256
esperados se convierte en un resultado aceptado.
La cola se bloquea durante todo el lote: desactiva el selector y el botón de envío, ignora los archivos que se suelten y los envíos adicionales, y continúa después de cualquier fallo individual. Cada solicitud tiene un plazo límite de 30 segundos, incluida la espera de una respuesta. Los resultados permanecen visibles hasta la siguiente selección; se desactiva la opción de volver a enviar la misma cola completada.
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()
})
La suma de verificación usa crypto.subtle.digest()
y lee un archivo a la vez en memoria. Sirve la página desde la URL de loopback que se muestra en la
terminal para que Web Crypto esté disponible. Deja Content-Type sin definir: el
navegador proporciona el delimitador multipart
para FormData.
Añade un receptor que confirme cada archivo
Guárdalo como server.mts. Sirve únicamente los tres recursos del navegador y
acepta un file más un campo sha256 por solicitud.
Lee el cuerpo completo antes de analizarlo y permite 16 KiB de datos adicionales de multipart por
encima del límite de 5 MiB por archivo. Una solicitud que supera ese límite del cuerpo recibe una
respuesta HTTP 413. La suma de verificación de una respuesta exitosa proviene de los bytes que el
receptor leyó realmente.
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
})
El servidor escucha en la interfaz de loopback y selecciona un puerto disponible cuando
PORT no está definido o es cero. Puedes definir
PORT en el entorno del servidor si necesitas un puerto fijo. La ausencia
de recursos, un valor de PORT no válido o un puerto ocupado impiden el
inicio y producen un código de salida distinto de cero.
Ejecuta el lote y lee sus resultados
Desde el directorio superior donde creaste html5-upload-demo, pega lo siguiente:
(cd html5-upload-demo && node server.mts)
Abre la URL que se muestra, como http://127.0.0.1:49152. Mantén esa terminal abierta; Ctrl+C
detiene el servidor y te devuelve al directorio superior. Reinicia después de editar un recurso,
ya que el servidor carga esos archivos al iniciarse.
Usa la tecla Tab para llegar a Choose files, abre el selector y selecciona dos archivos de distintos tamaños. Usa Tab para llegar a Upload batch y actívalo. Cada fila debería mostrar Accepted., su número de bytes y una suma de verificación SHA-256. El estado final informa de dos archivos aceptados y cero archivos fallidos. No se guardó nada en el servidor.
Prueba a soltar archivos en el área delimitada como segunda selección. Mientras se ejecuta un lote, su campo y su botón están desactivados, y soltar otros archivos no modifica las filas actuales. En una conexión local rápida, una barra puede saltar directamente al 100 %; los eventos de progreso no son una animación fluida ni una medida del procesamiento del servidor.
Una solicitud rechazada conserva su fila fallida y el siguiente archivo de la cola se envía de todos modos. HTTP 400 indica datos multipart mal formados o campos incorrectos; 403, una discrepancia de origen o host; 413, un límite de tamaño; y 422, una discrepancia en la suma de verificación. Una confirmación HTTP 200 mal formada o incoherente también se considera un fallo. Si el receptor deja de estar accesible o supera el plazo límite, no se sabe si el archivo fue aceptado: la falta de respuesta no permite saber si un servidor completó el trabajo. Para reintentar, selecciona los archivos fallidos como un lote nuevo; cada reintento vuelve a enviar todo su contenido. Al recargar la página, se pierde la cola.
Decide qué debe incluir un cargador público
Este receptor local comprueba los tamaños y la coincidencia de bytes, acepta contenidos arbitrarios y los descarta. Una suma de verificación no demuestra que un archivo sea seguro o se pueda decodificar por completo. Las comprobaciones de host y origen no son autenticación de usuarios. Antes de conservar archivos subidos en un servicio público, añade autorización, validación de contenido, una política de almacenamiento y protección CSRF donde las sesiones basadas en cookies la necesiten; la guía de subidas de OWASP explica esos controles. Mantén los archivos subidos que almacenes fuera del directorio raíz web y maneja la pérdida de una confirmación sin crear registros duplicados, por ejemplo, mediante una clave de idempotencia aplicada por el servidor.
Conserva el campo visible de selección de archivos, las barras de progreso etiquetadas, los contornos de foco y la región dinámica del lote cuando adaptes la interfaz. Los fallos incluyen texto y siguen siendo legibles sin depender del color. Un único porcentaje para todo el lote requeriría una política de ponderación definida; promediar los porcentajes da a un archivo diminuto el mismo peso que a uno grande.
Para una interfaz más completa, el plugin XHRUpload de Uppy permite
subidas multipart a un receptor compatible. Para reanudar las subidas, su
plugin Tus requiere un servidor compatible con el protocolo tus.
Ninguna de las dos opciones permite reanudar las subidas con este receptor
/upload.
