Sube archivos con Uppy, XHRUpload y Express
Usa el Dashboard de Uppy para seleccionar archivos y XHRUpload para enviarlos a un endpoint multipart. Este tutorial ofrece un ejemplo local completo de transferencia del navegador al servidor: un selector de archivos con controles de progreso y reintento, más un receptor Express que devuelve el número de bytes y el SHA-256 de cada subida.
Elige el contrato de subida
El Dashboard proporciona la interfaz; XHRUpload envía una petición
POST multipart por archivo. El receptor espera el campo file y responde
con JSON después de leer el archivo completo. No hay una segunda XMLHttpRequest escrita manualmente
junto a Uppy.
Esta es una demostración de transferencia local. El receptor mantiene cada archivo en memoria mientras procesa su petición, calcula su hash y lo descarta. No guarda archivos, devuelve enlaces de descarga ni inspecciona su contenido. Una respuesta exitosa significa que el receptor leyó los bytes, no que los almacenó de forma duradera.
Crea el proyecto
Usa Bash en Linux, Node.js 24.15.0 y Yarn 4.12.0 disponible a través de
corepack yarn. El ejemplo también funciona en Node.js 26.8.1; la verificación en
el navegador usa Chromium 145. Node ejecuta server.ts mediante la
eliminación integrada de tipos de TypeScript.
El código del navegador se procesa con esbuild.
Pega esto en un directorio donde quieras crear una carpeta uppy-xhr-demo.
Los paréntesis mantienen tu shell en su directorio original. Si esa carpeta ya existe, la
configuración se detiene sin modificarla; elige una ubicación nueva. Si la instalación falla,
detente y resuelve el problema antes de continuar.
(
mkdir uppy-xhr-demo &&
cd uppy-xhr-demo &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn add --exact @uppy/core@6.0.2 @uppy/dashboard@6.0.0 @uppy/xhr-upload@6.0.0 express@5.2.1 multer@2.4.0 esbuild@0.27.0 &&
mkdir public
)
El archivo de bloqueo vacío hace que este sea un proyecto de Yarn independiente, incluso dentro de
otro proyecto. Conserva el yarn.lock resultante para instalar de forma
reproducible la combinación probada. El package.json local establece módulos
ES incluso dentro de un proyecto padre CommonJS;
el enlazador node-modules de Yarn permite que
Node resuelva las importaciones del servidor sin herramientas adicionales.
Guarda los siguientes tres archivos dentro de uppy-xhr-demo.
Añade el receptor
Guarda esto como server.ts. Multer analiza
el cuerpo multipart y aplica límites a la petición mientras la recibe. El receptor acepta un archivo
por petición, de hasta 2 MiB, sin campos de texto adicionales. La comprobación MIME solo verifica
la declaración del remitente; no demuestra que los bytes sean una imagen o un PDF válidos.
import { createHash } from 'node:crypto'
import { join } from 'node:path'
import express from 'express'
import multer from 'multer'
const app = express()
const receive = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 0 },
}).single('file')
const allowedTypes = new Set(['image/jpeg', 'image/png', 'application/pdf'])
app.use(express.static(join(import.meta.dirname, 'public')))
app.post('/upload', (req, res) => {
receive(req, res, (error: unknown) => {
if (error) {
const tooLarge = error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE'
res.status(tooLarge ? 413 : 400).json({ error: 'Upload rejected.' })
return
}
const file = req.file
if (!file) {
res.status(400).json({ error: 'Expected one file in the file field.' })
return
}
if (!allowedTypes.has(file.mimetype)) {
res.status(415).json({ error: 'Expected a JPEG, PNG, or PDF MIME type.' })
return
}
res.json({
bytes: file.size,
sha256: createHash('sha256').update(file.buffer).digest('hex'),
})
})
})
const port = Number(process.env.PORT ?? 0)
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('PORT must be an integer from 0 to 65535.')
}
const server = app.listen(port, '127.0.0.1', (error?: Error) => {
if (error) {
console.error(`Cannot start the upload server: ${error.message}`)
process.exitCode = 1
return
}
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing listening address.')
console.log(`Open http://127.0.0.1:${address.port}`)
})
El puerto cero solicita al sistema operativo un puerto disponible. Puedes establecer
PORT en un puerto fijo si es necesario. El callback gestiona los
errores de inicio de Express 5, por lo que, si el puerto está
ocupado, el proceso termina con un error en lugar de imprimir una dirección que indica, de forma
engañosa, que está listo.
Añade la página y el componente de subida
Guarda esto como public/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Uppy upload demo</title>
<link rel="icon" href="data:," />
<link rel="stylesheet" href="/app.css" />
<style>
body { margin: 1rem; font-family: sans-serif; }
#receipts { overflow-wrap: anywhere; }
</style>
<script type="module" src="/app.js"></script>
</head>
<body>
<h1>Upload to the local receiver</h1>
<div id="drag-drop-area"></div>
<h2>Received by the server</h2>
<p>These receipts confirm transfer. Files are not saved.</p>
<ul id="receipts" aria-label="Server receipts" aria-live="polite"></ul>
</body>
</html>
Guarda esto como client.ts. El Dashboard
ya muestra errores de selección, el progreso de subida y controles de cancelación y reintento.
El pequeño manejador de eventos añade confirmaciones de recepción del servidor mediante
textContent, de modo que el nombre de archivo se muestra como texto.
import Uppy from '@uppy/core'
import Dashboard from '@uppy/dashboard'
import XHRUpload from '@uppy/xhr-upload'
import '@uppy/core/css/style.min.css'
import '@uppy/dashboard/css/style.min.css'
const receipts = document.querySelector('#receipts')
if (!(receipts instanceof HTMLUListElement)) throw new Error('Missing receipt list.')
const uppy = new Uppy({
autoProceed: false,
allowMultipleUploadBatches: false,
restrictions: {
maxFileSize: 2 * 1024 * 1024,
maxNumberOfFiles: 5,
allowedFileTypes: ['image/jpeg', 'image/png', '.pdf'],
},
})
.use(Dashboard, {
inline: true,
target: '#drag-drop-area',
note: 'Up to five JPEG, PNG, or PDF files, each up to 2 MiB.',
})
.use(XHRUpload, {
endpoint: '/upload',
fieldName: 'file',
formData: true,
bundle: false,
allowedMetaFields: false,
limit: 2,
shouldRetry: () => false,
onAfterResponse(xhr) {
if (xhr.status === 413) throw new Error('Choose a file no larger than 2 MiB.')
if (xhr.status === 415) throw new Error('The server requires a JPEG, PNG, or PDF MIME type.')
if (xhr.status < 200 || xhr.status >= 300) {
throw new Error('The server rejected the upload. Check the endpoint before retrying.')
}
},
})
uppy.on('upload-success', (file, response) => {
if (!file) return
const item = document.createElement('li')
item.textContent = `${file.name}: ${JSON.stringify(response.body)}`
receipts.append(item)
})
fieldName coincide con single('file') en el receptor.
allowedMetaFields: false omite los campos de metadatos predeterminados de Uppy para
respetar el límite de cero campos del servidor. No establezcas por tu cuenta un encabezado
Content-Type: el navegador proporciona el delimitador multipart. Se pueden
transferir dos archivos simultáneamente; los restantes esperan en la cola de Uppy.
Compila y prueba una subida
Desde el mismo directorio padre donde ejecutaste la configuración, compila el paquete del navegador e inicia el servidor:
(
cd uppy-xhr-demo &&
corepack yarn exec esbuild client.ts --bundle --format=esm --outfile=public/app.js &&
node server.ts
)
esbuild genera tanto public/app.js como public/app.css.
El CSS necesita su propio enlace en el HTML,
que la página anterior ya incluye. Al volver a compilar se reemplazan esos dos archivos generados.
Detén el servidor en primer plano con Ctrl+C antes de volver a compilar y, después de reiniciarlo,
recarga la página.
Abre la dirección exacta que imprime el servidor. Elige archivos con
browse files y luego usa el botón de subida del Dashboard.
Si hay un solo archivo seleccionado, muestra Upload 1 file.
Cada archivo aceptado añade una confirmación bajo Received by the server
con bytes y sha256. Un archivo vacío cuyo
nombre tenga la extensión PDF es una entrada válida para esta demostración de transferencia y recibe
bytes: 0; eso no lo convierte en un documento PDF válido.
El ejemplo admite un lote de selección a la vez. Una vez que empieza la subida, se bloquean las nuevas selecciones. Reintenta los archivos fallidos de ese lote o recarga la página para empezar un lote nuevo cuando termine. La cancelación borra la selección para que puedas elegir de nuevo. Las confirmaciones ya mostradas permanecen hasta que recargues la página.
Que una barra de progreso llegue al 100 % describe la transferencia, no la aceptación del servidor.
Solo upload-success añade una confirmación. En tu propia integración, no
consideres el evento complete de Uppy
como prueba de que todos los archivos se subieron correctamente: también se dispara cuando hay
archivos fallidos y proporciona arrays separados successful y
failed.
Mantén la validación en el servidor
Las restricciones del cliente facilitan la detección de errores antes de enviar datos. Otro cliente puede eludirlas. Este receptor limita por separado el tamaño de la petición y el número de archivos, pero su comprobación MIME sigue confiando en los metadatos proporcionados por quien hace la llamada. Nunca renderiza, ejecuta ni vuelve a servir los bytes subidos.
Antes de adaptarlo a un servicio público, añade autenticación y autorización, validación de contenido para los formatos que aceptes y almacenamiento adecuado para tu aplicación. Aplica también límites de peticiones y concurrencia: los límites pequeños por archivo no limitan la memoria total utilizada por muchos clientes simultáneos. La demostración escucha solo en la dirección local de loopback y no tiene autenticación ni almacenamiento persistente.
Solución de problemas comunes
Configuración de CORS
Sirve la página desde la dirección HTTP impresa, en lugar de abrir index.html
como archivo local. La página y /upload comparten un origen, por lo que
este ejemplo no necesita middleware de CORS. Si mueves la API a otro origen, configura ese servidor
para permitir el origen real de tu frontend y los encabezados necesarios.
CORS controla el acceso del navegador a las respuestas;
no autentica una petición de subida.
Errores de red
Este ejemplo desactiva los reintentos automáticos con shouldRetry: () => false para que
cada intento se pueda observar. Detén el servidor después de cargar la página e intenta subir un
archivo para ver el estado de error del Dashboard. Reinícialo en el mismo puerto, o recarga la página
en su nueva dirección y selecciona los archivos de nuevo.
Si una petición falla en el mismo endpoint, corrige la causa y elige Retry. XHRUpload vuelve a enviar ese archivo desde el principio. Una respuesta perdida puede dejar al navegador sin saber el resultado, incluso si el servidor recibió los bytes. Un servicio de subida persistente necesita su propia política de gestión de duplicados.
Usa la limitación de velocidad de red del navegador con un archivo permitido de mayor tamaño para probar el control Cancel de la barra de estado mientras haya trabajo pendiente. La cancelación aborta las peticiones pendientes del navegador y borra la selección; no puede retirar los bytes que el servidor ya recibió. No equivale a pausar y reanudar.
Validación del tipo de archivo
Un archivo mayor que el límite del cliente, una extensión o un tipo no admitidos, o un sexto archivo
producen un error de selección en el Dashboard sin iniciar la subida de ese archivo. La regla
.pdf permite la extensión del nombre de archivo; no inspecciona el
contenido del PDF. HTTP 415 significa que el receptor rechazó el tipo MIME declarado. HTTP 413
significa que se superó su límite de tamaño. Inspecciona la petición POST en el panel de red del
navegador; cambiar la configuración de CORS no resolverá esas respuestas.
Usa el protocolo tus cuando las subidas deban reanudarse
XHRUpload es una buena opción para endpoints multipart comunes y archivos pequeños. Cambiar su límite de concurrencia no añade fragmentación ni capacidad de reanudación. Para subidas que deban continuar tras una interrupción, usa el plugin Tus de Uppy con un servidor compatible con el protocolo tus. Se trata de un protocolo diferente y requiere reemplazar este receptor multipart.
