Sube archivos en Angular con progreso y un servidor local
Envía un archivo seleccionado como FormData mediante
HttpClient de Angular y muestra el éxito solo cuando el servidor lo acepte.
Esta guía crea un cargador de un solo archivo con progreso, errores visibles y un receptor
Node.js ejecutable. El receptor comprueba la solicitud y devuelve una suma de verificación;
no guarda el archivo.
Prepara el entorno necesario
Usa Node.js 26.8.2, Corepack con Yarn 4.12.0 y un navegador moderno. El ejemplo fija Angular 22.1.7, Angular CLI/build 22.1.8 y TypeScript 6.0.3. Angular 22 es una versión con soporte; su tabla de compatibilidad explica los requisitos de Node y TypeScript. Esta es una aplicación de navegador independiente sin renderizado del lado del servidor. El ejemplo se probó en macOS con Chromium 145. Los comandos usan un intérprete de comandos POSIX, como Bash en macOS, Linux o WSL.
Ejecuta lo siguiente desde el directorio padre donde quieras crear una carpeta
angular-upload-demo. mkdir rechaza un destino existente,
y cada comando dependiente se ejecuta solo si el anterior termina correctamente. Conserva el
archivo de bloqueo generado para que las instalaciones posteriores usen la misma resolución
de dependencias.
(
mkdir angular-upload-demo &&
cd angular-upload-demo &&
NG_CLI_ANALYTICS=false corepack yarn@4.12.0 dlx @angular/cli@22.1.8 new upload-demo \
--directory . --standalone --routing=false --ssr=false --style=css \
--skip-tests --skip-git --skip-install --package-manager=yarn --defaults &&
printf 'nodeLinker: node-modules\n' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn@4.12.0 add --exact \
@angular/common@22.1.7 @angular/compiler@22.1.7 @angular/core@22.1.7 \
@angular/forms@22.1.7 @angular/platform-browser@22.1.7 @angular/router@22.1.7 \
rxjs@7.8.2 fastify@5.12.5 @fastify/multipart@10.1.1 &&
corepack yarn@4.12.0 add --dev --exact \
@angular/build@22.1.8 @angular/cli@22.1.8 @angular/compiler-cli@22.1.7 typescript@6.0.3
)
Cuando termine correctamente, abre angular-upload-demo en tu editor. Todos los nombres
de archivo siguientes son relativos a esa carpeta. Reemplaza solo el archivo generado
src/main.ts; crea los otros tres archivos con los nombres indicados.
Usa la codificación multipart del navegador
El campo nativo de selección de archivos te proporciona un File;
FormData.append('file', file) coloca sus bytes en el campo multipart que espera nuestro servidor.
Deja Content-Type sin establecer. El navegador añade el delimitador multipart,
como explica la guía de FormData de MDN. No se necesita
FormsModule ni ngModel para el evento de cambio de este campo.
El progreso de subida requiere el backend XHR de Angular. Configura provideHttpClient(withXhr())
y solicita los eventos con observe: 'events' y reportProgress: true.
El backend Fetch predeterminado no informa del progreso de subida. Angular documenta tanto la
configuración de XHR como la
secuencia de eventos de la solicitud. Un evento de progreso describe
la transmisión del cuerpo de la solicitud, incluida la estructura delimitadora multipart.
Incluso si se ha transferido el 100 %, eso no significa que el servidor haya aceptado el archivo.
Añade el componente de subida
Reemplaza src/main.ts con este punto de entrada completo. Las señales actualizan
la vista cuando llegan eventos HTTP. Mientras hay una solicitud pendiente, ambos controles están
deshabilitados y los manejadores evitan las llamadas repetidas. Si la solicitud falla, el archivo
seleccionado se conserva para reintentar manualmente; si tiene éxito, se vacía el campo nativo
para que se pueda volver a seleccionar el mismo archivo.
import { HttpClient, HttpErrorResponse, HttpEventType, provideHttpClient, withXhr } from '@angular/common/http'
import { Component, DestroyRef, inject, signal } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { bootstrapApplication } from '@angular/platform-browser'
import { finalize } from 'rxjs'
@Component({
selector: 'app-root',
standalone: true,
templateUrl: './upload.html',
})
class UploadDemo {
readonly selected = signal<File | null>(null)
readonly busy = signal(false)
readonly progress = signal<number | null>(null)
readonly message = signal('Choose a file to upload.')
readonly #http = inject(HttpClient)
readonly #destroyRef = inject(DestroyRef)
select(event: Event): void {
if (this.busy()) return
const input = event.target
if (!(input instanceof HTMLInputElement)) return
const file = input.files?.[0] ?? null
this.progress.set(null)
this.selected.set(null)
if (file === null) {
this.message.set('Choose a file to upload.')
return
}
if (file.size > 1024 * 1024) {
input.value = ''
this.message.set('Choose a file of 1 MiB or smaller.')
return
}
this.selected.set(file)
this.message.set(`Ready to upload “${file.name}”.`)
}
upload(input: HTMLInputElement): void {
const file = this.selected()
if (file === null || this.busy()) return
this.busy.set(true)
this.progress.set(null)
this.message.set(`Sending “${file.name}”…`)
const body = new FormData()
body.append('file', file)
this.#http.post('/api/upload', body, {
observe: 'events',
reportProgress: true,
timeout: 30_000,
}).pipe(
takeUntilDestroyed(this.#destroyRef),
finalize(() => this.busy.set(false)),
).subscribe({
next: (event) => {
if (event.type === HttpEventType.UploadProgress) {
const total = event.total
this.progress.set(total != null && total > 0
? Math.floor(100 * event.loaded / total)
: null)
if (total != null && total > 0 && event.loaded >= total) {
this.message.set('Transfer complete. Waiting for server acceptance…')
}
} else if (event.type === HttpEventType.Response) {
this.selected.set(null)
input.value = ''
this.message.set(`Server accepted “${file.name}”. This demo did not save it.`)
}
},
error: (error: unknown) => {
this.progress.set(null)
this.message.set(error instanceof HttpErrorResponse && error.status !== 0
? `Upload failed (HTTP ${error.status}). Try again or choose another file.`
: 'Network error or timeout. The server may have received the file; check before retrying.')
},
})
}
}
bootstrapApplication(UploadDemo, {
providers: [provideHttpClient(withXhr())],
}).catch(console.error)
Etiqueta los controles y los mensajes de respuesta
Crea src/upload.html. El campo nativo y el botón permiten la navegación con teclado.
La región de estado anuncia los mensajes de respuesta sin mover el foco. Si se desconoce el total,
la barra de progreso queda indeterminada; una subida local pequeña puede terminar demasiado rápido
como para mostrar porcentajes intermedios.
<main>
<h1>Upload a file</h1>
<p id="file-hint">Up to 1 MiB. The server rejects empty files and does not save uploads.</p>
<label for="upload-file">Choose a file</label>
<input #fileInput id="upload-file" type="file" aria-describedby="file-hint"
[disabled]="busy()" (change)="select($event)" />
<button type="button" [disabled]="selected() === null || busy()"
(click)="upload(fileInput)">Upload</button>
@if (busy()) {
<p>
<progress aria-label="Request upload progress" max="100" [attr.value]="progress()"></progress>
@if (progress() !== null) {
<span>{{ progress() }}% transferred</span>
}
</p>
}
<p role="status" aria-atomic="true">{{ message() }}</p>
</main>
Ejecuta un receptor que compruebe la subida
Crea server.ts en la raíz del proyecto. Acepta
POST /api/upload con exactamente un campo de archivo multipart llamado
file, rechaza archivos vacíos con HTTP 422 y limita los archivos a 1 MiB.
El plugin multipart aplica los límites de tamaño y cantidad de partes;
toBuffer() consume el archivo antes de que se genere la respuesta.
El receptor devuelve el nombre del archivo recibido, el nombre del campo, la cantidad de bytes y la suma de verificación SHA-256. Almacena temporalmente el archivo en memoria y lo descarta después de la solicitud. No tiene una ruta de almacenamiento y, por lo tanto, nunca sobrescribe un archivo existente. Esta demostración local acepta cualquier tipo de archivo y escucha únicamente en la dirección de bucle local.
import { createHash } from 'node:crypto'
import multipart from '@fastify/multipart'
import Fastify from 'fastify'
const server = Fastify()
await server.register(multipart, {
limits: { files: 1, fields: 0, parts: 1, fileSize: 1024 * 1024 },
})
server.setErrorHandler((error, _request, reply) => {
// A part-count limit can interrupt toBuffer() before the parser's own error surfaces.
if (error instanceof Error && 'code' in error && error.code === 'ERR_STREAM_PREMATURE_CLOSE') {
return reply.code(400).send({ error: 'Incomplete multipart upload.' })
}
const status = error instanceof Error && 'statusCode' in error
&& typeof error.statusCode === 'number'
&& error.statusCode >= 400 && error.statusCode <= 599
? error.statusCode : 500
reply.code(status).send({ error: 'Upload failed.' })
})
server.post('/api/upload', async (request, reply) => {
let receipt: { field: string; filename: string; bytes: number; sha256: string } | null = null
for await (const part of request.parts()) {
if (part.type !== 'file') {
return reply.code(400).send({ error: 'Send one file field named file.' })
}
const bytes = await part.toBuffer()
if (part.fieldname !== 'file') {
return reply.code(400).send({ error: 'Send one file field named file.' })
}
if (bytes.length === 0) {
return reply.code(422).send({ error: 'Empty files are not accepted.' })
}
receipt = {
field: part.fieldname,
filename: part.filename,
bytes: bytes.length,
sha256: createHash('sha256').update(bytes).digest('hex'),
}
}
if (receipt === null) {
return reply.code(400).send({ error: 'Send one file field named file.' })
}
return receipt
})
const address = await server.listen({ host: '127.0.0.1', port: Number(process.env.PORT ?? '3000') })
console.log(`Receiver listening at ${address}`)
Crea proxy.json en la raíz del proyecto para que el servidor de desarrollo de
Angular reenvíe las solicitudes /api a Node. El navegador usa su propio
origen, por lo que este ejemplo no necesita configuración de CORS.
{
"/api/**": {
"target": "http://127.0.0.1:3000"
}
}
Desde el directorio padre usado para la preparación, inicia el receptor en una terminal:
cd angular-upload-demo && node server.ts
En una segunda terminal, también desde ese directorio padre, compila y sirve la aplicación:
cd angular-upload-demo &&
corepack yarn@4.12.0 ng build &&
NG_CLI_ANALYTICS=false corepack yarn@4.12.0 ng serve --host 127.0.0.1 --port 4200 --proxy-config proxy.json
Abre http://127.0.0.1:4200. Si alguno de los puertos está ocupado, usa otro puerto libre:
establece PORT al iniciar Node y actualiza el destino del proxy para que
coincida; cambia --port de Angular según sea necesario. Detén ambos servidores
con Ctrl+C cuando termines. Al recompilar, se reemplaza la salida dist/
de esta aplicación generada; los archivos subidos nunca se escriben allí.
Comprueba el éxito y el fallo por separado
Selecciona un archivo no vacío de menos de 1 MiB y activa Upload. El mensaje final debería decir
«Server accepted» y explicar que la demostración no lo guardó. En el panel Red del navegador,
inspecciona la respuesta de /api/upload: field debería ser
file, y bytes y sha256 describen
el contenido del archivo recibido, sin incluir la estructura delimitadora multipart. Selecciona
el mismo archivo de nuevo para enviar otra solicitud.
Prueba con un archivo vacío para ver el mensaje HTTP 422. La interfaz debería rechazar un archivo de más de 1 MiB antes de enviarlo; el servidor también aplica su propio límite a las solicitudes que eluden la interfaz. Mientras una subida está pendiente, el selector de archivos y el botón Upload permanecen deshabilitados, incluso después de que la transmisión alcance el 100 %. Una respuesta del servidor demorada o de rechazo nunca debe convertirse en un mensaje de subida aceptada.
Para probar un fallo de conexión, deja que se cargue la página, activa el modo sin conexión en el panel Red del navegador y luego sube un archivo. Restablece la conexión antes de reintentar. Si detienes solo el receptor Node, es posible que obtengas en cambio un error HTTP del proxy de desarrollo. Un error de red o un tiempo de espera agotado no permiten saber si el servidor procesó la solicitud antes de que fallara la conexión; este ejemplo no reintenta automáticamente.
La comprobación de tamaño facilita el uso, pero no constituye una barrera de seguridad. Si tu
aplicación necesita subidas de archivos JPEG, PNG o PDF, el atributo accept
del campo puede orientar la selección, pero tanto este como File.type son
indicaciones controladas por el cliente. Valida el contenido real en el servidor receptor.
MDN explica por qué las restricciones del selector de archivos no validan las subidas.
Un receptor desplegado también necesita la autenticación, la autorización, la protección contra
la falsificación de solicitudes y la política de almacenamiento de tu aplicación; el receptor
local no las proporciona.
Añade la reanudación cuando la necesites
Esta solicitud multipart vuelve a empezar desde cero si falla la transferencia. Si necesitas continuar subidas interrumpidas, consulta subidas de archivos reanudables en Angular con tus-js-client. Si buscas un selector de archivos y una interfaz de subida listos para usar, la guía oficial de Uppy para Angular documenta sus componentes y las versiones compatibles. Combina esa interfaz con un plugin de subida y un receptor que utilice el mismo protocolo.
