Crear un cargador de archivos propio con JavaScript y HTML
Conserva el selector de archivos nativo y construye tu interfaz personalizada a su alrededor. Esta guía te ofrece un cargador JavaScript sin frameworks, con selección mediante teclado, arrastrar y soltar, progreso y reintentos, además de un servidor local que compara los bytes recibidos con la suma de verificación SHA-256 del archivo seleccionado.

Configura un proyecto local de subida de archivos
Necesitas Node.js 24 o posterior, un navegador y una terminal. El ejemplo usa las API Request y File integradas en Node, por lo que no hay paquetes que instalar. La guía se probó en Linux con Node.js 24.2.0, 26.5.0 y 26.8.1, y Chromium 145 y 152.
Subiremos un archivo JPEG, PNG o PDF no vacío a la vez, de hasta 10 MiB. Cada intento envía el archivo completo como datos de formulario multipart. Esto mantiene el navegador y el servidor lo bastante sencillos para ejecutarlos juntos sin implementar un protocolo de ensamblaje de fragmentos.
Ejecuta lo siguiente en un shell POSIX, como Bash, desde un directorio donde guardes tus experimentos:
mkdir custom-uploader &&
cd custom-uploader &&
touch index.html styles.css script.js server.ts
El comando rechaza los directorios existentes. Si algún paso falla, detente y resuélvelo antes de continuar; elige otro nombre para el nuevo directorio si es necesario. Pega los siguientes cuatro ejemplos en los archivos vacíos que acabas de crear. Las subidas no crearán ni sobrescribirán archivos: el receptor calcula el hash de los bytes en memoria y los descarta después de responder. Al volver a ejecutar la operación, se verifican los bytes de nuevo. Mantén esta demo en tu propia máquina.
Configura la estructura HTML
Guarda esto como index.html. El selector de archivos etiquetado sigue visible y
accesible con Tab. La zona para soltar archivos es una alternativa para seleccionar un archivo, y el
botón Upload independiente inicia la solicitud. Los mensajes de estado permanecen en pantalla en una
región activa con prioridad de anuncio no intrusiva.
<!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>
Aplica estilos al cargador de archivos con CSS
Guarda esto como styles.css. Aplica estilos al botón de selección y al contorno
de foco del elemento de entrada sin ocultarlo. Usar display: none lo excluiría de la
navegación por teclado y de las tecnologías de asistencia;
consulta el ejemplo de selector de archivos de 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; }
Implementa JavaScript para gestionar la selección y subida de archivos
Guarda esto como script.js. XMLHttpRequest expone
eventos de progreso de subida.
Estos miden la transmisión del cuerpo de la solicitud, incluidos los datos adicionales de multipart.
Llegar al 100 % no significa que el servidor haya aceptado el archivo. Solo una respuesta HTTP 200
con un tamaño y una suma de verificación coincidentes genera el mensaje «Accepted».
La política durante una solicitud en curso es explícita: deshabilitar el selector y los botones, ignorar los archivos soltados y los envíos adicionales, y conservar el archivo actual hasta que la solicitud finalice. Un error de red, un tiempo de espera agotado o un error del servidor habilita Retry, con un máximo de tres intentos por selección. Los reintentos vuelven a enviar el archivo completo; ninguno se ejecuta automáticamente. Una solicitud rechazada o un comprobante de recepción no válido requiere una nueva selección.
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())
El navegador calcula la suma de verificación con
crypto.subtle.digest().
Esto lee el archivo pequeño y lo carga en memoria. Sirve la página en la URL de loopback que muestra
el servidor de abajo para que Web Crypto esté disponible; no abras index.html
directamente. Deja Content-Type sin establecer:
FormData proporciona su propio delimitador multipart.
Añade el receptor local
Guarda esto como server.ts. Solo sirve nuestros tres archivos para el navegador
y acepta exactamente dos campos multipart: file y
sha256. El límite del cuerpo permite 16 KiB para las cabeceras multipart,
además del archivo de 10 MiB. El receptor calcula su propia suma de verificación 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}`)
})
El atributo accept es una
sugerencia para el selector, no una validación.
Tanto el navegador como este receptor comprueban el tipo MIME declarado; ninguno demuestra que
los bytes sean una imagen o un PDF válido o seguro. La suma de verificación confirma que los bytes
coinciden, no que sean confiables. Esta demo no tiene cuentas de usuario, almacenamiento persistente,
análisis de contenido ni decodificación de formatos. Escucha en la interfaz de loopback y comprueba
el origen del navegador, pero esas comprobaciones no autentican al usuario. Un servicio público
necesita su propia autorización, protección CSRF para sesiones basadas en cookies, validación de
contenido y política de almacenamiento.
Ejecútalo y comprueba el resultado
Desde custom-uploader, ejecuta:
node server.ts
Abre la URL que se muestra, como http://127.0.0.1:49152. El servidor elige un puerto
disponible, que puede cambiar al reiniciarlo. Lee los archivos para el navegador al iniciarse, así
que reinícialo después de editarlos. Detenlo con Ctrl+C cuando termines.
Pulsa Tab para enfocar «Choose a file», abre el selector con el teclado y selecciona un PNG pequeño. Usa Tab para llegar a Upload y actívalo. El estado final debería decir «Accepted», y el comprobante de recepción debería mostrar el nombre del archivo, su longitud en bytes y un valor SHA-256 de 64 caracteres. El navegador ha comparado ese valor con su propia suma de verificación; no se ha guardado nada en el servidor.
Prueba también estos casos de fallo e interacción:
- Suelta un archivo en el área delimitada y luego súbelo. Al soltar dos archivos, debería aparecer una solicitud de seleccionar exactamente uno.
- Prueba un archivo vacío, un archivo de texto y un archivo de más de 10 MiB. Cada uno debería generar una explicación persistente sin iniciar una solicitud. También se rechazan los archivos con un tipo MIME vacío o no reconocido, aunque su extensión parezca aceptable.
- Usa las herramientas de red de tu navegador para ralentizar una subida. Mientras esté pendiente, el selector y los botones deberían estar deshabilitados, y soltar archivos no debería alterar la selección activa. La barra puede llegar al 100 % mientras el estado aún indica que se espera la aceptación del servidor.
- Con la página ya cargada, pon el navegador en modo sin conexión y sube un archivo. Después de «Network error», restablece la conexión y elige Retry. La subida del mismo archivo seleccionado debería completarse correctamente. Mantén el navegador sin conexión durante los tres intentos para ver el límite de reintentos y luego vuelve a seleccionar el mismo archivo para iniciar una nueva serie de intentos.
Si una solicitud devuelve HTTP 400, comprueba los nombres de los campos multipart; 403 significa que el origen o el host no coincidía con la URL mostrada. HTTP 413 indica que se superó el límite de tamaño; 415, un tipo MIME declarado no admitido; y 422, una suma de verificación que no coincide. Llegar al 100 % y recibir después uno de estos errores significa que la subida falló. El plazo de 30 segundos del cliente abarca la solicitud y la respuesta; auméntalo de forma deliberada si experimentas con conexiones más lentas.
Cuando necesitas subidas reanudables
Aquí, reintentar significa volver a enviar el archivo completo mientras la página está abierta. Al recargarla, se olvidan la selección y el contador de intentos. Si añades almacenamiento persistente, contempla la pérdida de una respuesta de éxito: un reintento no debe crear registros duplicados. Usa una clave de idempotencia que el servidor haga cumplir para ese flujo de trabajo.
Para archivos grandes que deban continuar desde una posición en bytes previamente aceptada, usa un cliente y un servidor del protocolo tus. Esto requiere un protocolo reanudable en ambos extremos; dividir un archivo en fragmentos únicamente con JavaScript en el navegador no lo proporciona.
