Formulario HTML de subida de archivos: guía para desarrolladores
Para subir archivos con HTML necesitas un campo de archivo con nombre dentro de un formulario con
method="post" y enctype="multipart/form-data", además de un servidor que gestione el
action del formulario. Esta guía conecta esas piezas: enviarás uno o varios
archivos sin JavaScript en el navegador y verás cómo el receptor informa sus nombres, cantidades de
bytes y hashes SHA-256.
Configurar un formulario básico de subida de archivos en HTML
Necesitas Node.js 24 o posterior, un navegador y un intérprete de comandos POSIX, como Bash en Linux, macOS o WSL. El receptor usa las API integradas de Node, así que no hay paquetes que instalar. Node puede ejecutar este TypeScript directamente mediante la eliminación de tipos. Los ejemplos se probaron en Linux con Node.js 24.15.0, 26.5.0 y 26.8.1, y Chromium 145 y 152.
Desde un directorio donde guardes tus experimentos, ejecuta:
mkdir html-upload-demo &&
cd html-upload-demo &&
touch index.html server.ts
Este comando rechaza un directorio existente. Si falla, detente antes de seguir los pasos restantes; elige otro nombre de directorio o resuelve el error. Pega los dos ejemplos siguientes en los archivos que acabas de crear. El servidor solo inspecciona las subidas en memoria. No crea ni sobrescribe los archivos subidos, y enviar de nuevo el mismo archivo simplemente genera otro comprobante.
Guarda esta página completa como index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HTML file upload demo</title>
</head>
<body>
<main>
<h1>Upload files</h1>
<form action="/upload" method="post" enctype="multipart/form-data">
<p>
<label for="file-upload">Choose files (required)</label>
<input
type="file"
id="file-upload"
name="files"
multiple
required
aria-describedby="file-help"
/>
</p>
<p id="file-help">Choose up to three files, at most 1 MiB each. Nothing is saved.</p>
<button type="submit">Upload files</button>
</form>
</main>
</body>
</html>
Cada atributo del formulario tiene una función distinta:
| Atributo | Función en este ejemplo |
|---|---|
action="/upload" | Dirige el envío a la ruta /upload del servidor que sirve esta página. |
method="post" | Envía los datos del formulario en el cuerpo de la solicitud HTTP. |
enctype="multipart/form-data" | Codifica el contenido de los archivos como partes separadas dentro de ese cuerpo. |
name="files" | Da un nombre a cada parte subida para que el receptor pueda recuperarla. |
id="file-upload" | Vincula el campo con su etiqueta; no da nombre al campo enviado. |
multiple | Permite elegir más de un archivo en el selector. |
required | Hace que el navegador solicite una selección antes del envío. |
El navegador construye por ti el delimitador multipart y los encabezados de la solicitud. La
guía de MDN para enviar datos de formularios
explica la codificación. Un campo sin name se omite de los datos enviados,
aunque tenga un id y muestre el nombre de un archivo seleccionado;
consulta las reglas de entradas de formularios del estándar HTML.
Añade un receptor multipart local
Guarda esto como server.ts. Sirve la página y acepta hasta tres archivos de
1 MiB cada uno. Un límite independiente de 4 MiB restringe el cuerpo de la solicitud almacenado en
búfer, incluidos los encabezados multipart, antes de analizarlo. Son límites pequeños para una
demostración; el almacenamiento en búfer y el análisis también requieren memoria adicional al
tamaño del cuerpo sin procesar.
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
const MAX_FILES = 3
const MAX_FILE_BYTES = 1024 * 1024
const MAX_REQUEST_BYTES = 4 * 1024 * 1024
const page = await readFile(new URL('./index.html', import.meta.url))
function reply(response: ServerResponse, status: number, message: string): void {
response.writeHead(status, {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
})
response.end(`${message}\n\nUse Back to choose files again. Nothing was saved.\n`)
}
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
if (request.method === 'GET' && request.url === '/') {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
response.end(page)
return
}
if (request.method === 'GET' && request.url === '/favicon.ico') {
response.writeHead(204).end()
return
}
if (request.method !== 'POST' || request.url !== '/upload') {
reply(response, 404, 'Route not found. Open the URL printed in the terminal.')
return
}
const contentType = request.headers['content-type'] ?? ''
if (!contentType.toLowerCase().startsWith('multipart/form-data;')) {
request.resume()
reply(response, 415, 'Expected multipart/form-data. Check the form enctype.')
return
}
const chunks: Buffer[] = []
let bytes = 0
// Keep the connection alive long enough to return readable size-limit feedback.
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
bytes += chunk.length
if (bytes > MAX_REQUEST_BYTES) {
request.resume()
reply(response, 413, 'Request exceeds 4 MiB. Choose smaller files.')
return
}
chunks.push(chunk)
}
let form: FormData
try {
form = await new Request('http://localhost/upload', {
method: 'POST',
headers: { 'Content-Type': contentType },
body: Buffer.concat(chunks),
}).formData()
} catch {
reply(response, 400, 'Could not read the multipart form. Check its encoding.')
return
}
const files = form.getAll('files')
if (files.length === 0) {
reply(response, 400, 'Choose at least one file in the files field.')
return
}
if (files.length > MAX_FILES) {
reply(response, 400, 'Choose at most three files.')
return
}
const receipts = []
for (const file of files) {
if (!(file instanceof File) || file.name === '') {
reply(response, 400, 'Choose at least one file in the files field.')
return
}
if (file.size > MAX_FILE_BYTES) {
reply(response, 413, 'Each file must be at most 1 MiB. Choose smaller files.')
return
}
receipts.push({
field: 'files',
name: file.name,
bytes: file.size,
sha256: createHash('sha256').update(Buffer.from(await file.arrayBuffer())).digest('hex'),
})
}
reply(response, 200, `Received ${files.length} file(s).\n${JSON.stringify(receipts, null, 2)}`)
}
const server = createServer((request, response) => {
void handle(request, response).catch(() => {
console.error('Upload handler failed.')
if (!response.headersSent) reply(response, 500, 'Could not process the upload. Try again.')
else response.destroy()
})
})
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (address && typeof address !== 'string') {
console.log(`Open http://127.0.0.1:${address.port}/`)
}
})
La API Request integrada
analiza el cuerpo recopilado con
formData().
Construir este objeto no envía otra solicitud de red. El receptor usa
getAll('files')
para recopilar todas las partes con ese nombre. get('files') devolvería solo la
primera.
La opción destroyOnReturn: false del flujo
permite al receptor salir del bucle de lectura sin destruir la conexión cuando el cuerpo es demasiado
grande. Descarta la entrada restante y devuelve una respuesta HTTP 413.
Gestionar la subida de uno o varios archivos
En el mismo directorio html-upload-demo, inicia el receptor:
node server.ts
Abre la URL exacta que aparece en la terminal. El servidor elige un puerto libre y escucha solo en
127.0.0.1. Abrir index.html directamente como archivo local
no conectará su acción /upload con este receptor. Detén el servidor con Ctrl+C
cuando termines; reinícialo después de editar cualquiera de los dos archivos.
Desactiva JavaScript en el navegador, recarga la página y elige un archivo pequeño. Haz clic en
Upload files. El navegador navega a /upload y muestra Received 1 file(s).,
seguido de un comprobante con field, name,
bytes y sha256. Esa respuesta confirma que el servidor
aceptó e inspeccionó los bytes. Que aparezca un nombre de archivo en el selector solo confirma la
selección.
Usa Atrás, elige dos archivos a la vez y vuelve a enviarlos. Deberías ver Received 2 file(s). y
dos comprobantes. Prueba con nombres de archivo que contengan espacios o caracteres no ASCII. Para
comparar un comprobante con tu archivo local, ejecuta lo siguiente desde el directorio de la
demostración y sustituye la ruta después de -- por la de tu archivo:
node --input-type=module -e 'import { readFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const bytes = await readFile(process.argv[1]); console.log(bytes.length, createHash("sha256").update(bytes).digest("hex"))' -- '/path/to/your file.txt'
Tanto la cantidad de bytes como el hash deberían coincidir. Este comando solo lee el archivo. Un archivo vacío seleccionado deliberadamente es válido e informa cero bytes; un selector vacío es un caso distinto.
multiple cambia cuántos archivos puede seleccionar el usuario, no el nombre
del campo. Cada archivo seleccionado se envía bajo files. HTML no exige la
convención de corchetes files[]; este receptor espera la clave literal
files. Si otro backend espera files[], ambos lados
deben usar ese nombre exacto. Para un selector que permita solo un archivo, omite
multiple. Eso cambia únicamente el control del navegador; aplica también una
regla de un solo archivo en el servidor si tu aplicación lo requiere.
Añadir validación del lado del cliente
Sin una selección, Upload files activa el aviso de campo obligatorio del navegador y te mantiene en el formulario. El receptor también rechaza una subida vacía con HTTP 400 porque los clientes pueden eludir la validación del navegador. HTML no tiene un atributo de tamaño de archivo que imponga el límite de 1 MiB, por lo que las selecciones que lo superan llegan al receptor y reciben una página de error.
Esta demostración acepta cualquier tipo de archivo y solo inspecciona sus bytes. Para orientar un
selector de imágenes o documentos, puedes añadir accept=".jpg,.jpeg,.png,.pdf" al campo. Como
explica la documentación de MDN sobre el campo de archivo,
accept es una sugerencia para el selector, no una validación del contenido.
No añade comprobaciones de tipo a este receptor. Un nombre de archivo, una extensión o un tipo MIME
proporcionado no permiten determinar que un archivo sea seguro.
Prueba estos casos de error antes de adaptar el ejemplo:
| Envío | Resultado esperado |
|---|---|
| Ningún archivo seleccionado | El navegador solicita un archivo; una solicitud vacía directa recibe HTTP 400. |
| Cuatro archivos pequeños | HTTP 400: «Choose at most three files». |
| Un archivo de más de 1 MiB | HTTP 413 con un aviso sobre el límite de tamaño. |
| Solicitud total de más de 4 MiB | HTTP 413 antes del análisis multipart. |
Un campo de archivo llamado file o files[] | HTTP 400 porque el receptor no encuentra files. |
Después de un error, usa Atrás y elige una selección válida. Una respuesta exitosa se aplica a todo ese envío. No se guarda nada de las solicitudes, tanto si se aceptan como si se rechazan.
Personalizar el botón de subida de archivos
Puedes aplicar estilos al botón nativo y conservar su etiqueta, la visualización del archivo
seleccionado y su comportamiento con el teclado. Añade lo siguiente en
index.html, dentro de <head>, y reinicia el servidor:
<style>
input[type='file']::file-selector-button {
font: inherit;
padding: 0.5rem 0.75rem;
margin-inline-end: 0.75rem;
cursor: pointer;
}
</style>
El pseudoelemento ::file-selector-button
actúa sobre el botón dentro del campo. Usa Tab para llegar al selector etiquetado y pulsa Espacio
para abrirlo; luego usa Tab para llegar a Upload files y pulsa Intro para enviar. Mantén el campo
visible y su indicador de foco intacto. Su name="files" y su posición dentro del
formulario siguen vinculando la selección con el receptor.
Consideraciones de seguridad
Mantén este receptor en un entorno local. Acepta contenido arbitrario, usa memoria por solicitud y no ofrece inicio de sesión, almacenamiento duradero ni análisis de malware. Los nombres de archivo se muestran como JSON en una respuesta de texto sin formato y nunca se usan como rutas del sistema de archivos. El cálculo de hashes confirma qué bytes llegaron; no valida su formato ni su seguridad.
Al añadir almacenamiento a una aplicación con autenticación, define la autorización, los límites de las solicitudes, la validación del contenido y la protección contra CSRF en el límite del servidor. La guía de subida segura con AJAX, disponible por separado, explica esa tarea centrada en la seguridad.
Añade progreso o arrastrar y soltar cuando lo necesites
Para una interfaz que permanezca en la página y muestre el progreso de la subida, continúa con el cargador personalizado con JavaScript. Explica cómo implementar arrastrar y soltar, reintentos y progreso con un receptor compatible. Si tu aplicación ya usa Bootstrap, consulta el tutorial de subida de archivos con Bootstrap para aplicar estilos con esa herramienta.
