Subidas reanudables de archivos en Angular
Usa tus-js-client con un servidor del protocolo tus para reanudar una subida de
archivos en Angular desde los bytes que el servidor ya tiene. Este ejemplo crea un formulario
Angular independiente y un receptor local con almacenamiento en disco. Podrás pausar una
transferencia, recargar la página, seleccionar el mismo archivo y terminar de subirlo.
¿Por qué usar subidas reanudables?
El protocolo tus usa un POST para crear
una URL de subida, solicitudes PATCH para enviar bytes y una solicitud
HEAD para descubrir el Upload-Offset guardado al reanudar.
El desplazamiento del servidor determina dónde continuar. Una barra de progreso del navegador
por sí sola no puede indicar qué bytes se conservaron tras una interrupción de la conexión.
El navegador guarda la URL de subida, no una copia del archivo. Tras recargar la página, el usuario debe volver a seleccionar el archivo original, sin cambios, en el mismo perfil del navegador y origen de la página. Borrar el almacenamiento del sitio, modificar el archivo o perder la subida guardada en el servidor puede obligar a empezar de nuevo.
Configura tu proyecto Angular
Usa Node.js 24.15.0 y Yarn 4.12.0 para este tutorial. Las versiones de los paquetes que se indican a continuación se compilaron y probaron en Chromium 145 sobre Linux. Angular tiene sus propios requisitos de compatibilidad con Node.js y TypeScript; estas versiones fijas describen la configuración probada.
Desde un directorio de trabajo donde quieras alojar el ejemplo, crea un directorio de proyecto nuevo. Estos comandos de Bash rechazan un directorio existente y un proyecto Yarn Plug’n’Play que lo contenga, cuyo cargador puede afectar la compilación de Angular. Mantienen tu shell en su ubicación original:
(
node --input-type=module <<'NODE' &&
import { existsSync, realpathSync } from 'node:fs'
import { dirname, join } from 'node:path'
let directory = realpathSync('.')
while (true) {
if (existsSync(join(directory, '.pnp.cjs')) || existsSync(join(directory, '.pnp.js'))) {
console.error('Create this demo outside the enclosing Yarn Plug’n’Play project.')
process.exit(1)
}
const parent = dirname(directory)
if (parent === directory) break
directory = parent
}
NODE
mkdir resumable-upload-demo &&
cd resumable-upload-demo &&
printf 'nodeLinker: node-modules\n' > .yarnrc.yml &&
touch yarn.lock
)
Guarda cada archivo de los que se muestran a continuación dentro de resumable-upload-demo.
El archivo de bloqueo local y la configuración de Yarn le dan a este ejemplo su propia instalación
de dependencias. package.json fija las dependencias directas y el destino de
compilación para el navegador:
{
"name": "resumable-upload-demo",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": {
"build": "ng build",
"start": "ng serve --host 127.0.0.1 --port 4200",
"server": "node server.ts"
},
"browserslist": ["Chrome 145"],
"dependencies": {
"@angular/build": "22.2.0",
"@angular/cli": "22.2.0",
"@angular/common": "22.2.0",
"@angular/compiler": "22.2.0",
"@angular/compiler-cli": "22.2.0",
"@angular/core": "22.2.0",
"@angular/platform-browser": "22.2.0",
"@tus/file-store": "2.1.1",
"@tus/server": "2.4.5",
"@types/node": "24.19.0",
"rxjs": "7.8.2",
"tslib": "2.8.1",
"tus-js-client": "4.3.1",
"typescript": "6.0.3"
}
}
Guarda angular.json para conectar la compilación y el servidor de desarrollo
con main.ts:
{
"version": 1,
"cli": { "analytics": false, "cache": { "enabled": false } },
"projects": {
"demo": {
"projectType": "application",
"root": ".",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "main.ts",
"tsConfig": "tsconfig.json",
"index": "index.html",
"outputPath": "dist",
"styles": [],
"assets": []
}
},
"serve": {
"builder": "@angular/build:dev-server",
"options": { "buildTarget": "demo:build" }
}
}
}
}
}
Guarda tsconfig.json. Los tipos de Node satisfacen las declaraciones compartidas
del paquete cliente; no añaden las API de Node al navegador:
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"experimentalDecorators": true,
"strict": true,
"types": ["node"],
"lib": ["ES2022", "DOM"],
"rewriteRelativeImportExtensions": true
},
"angularCompilerOptions": { "strictTemplates": true },
"files": ["main.ts"]
}
Guarda index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="/" />
<title>Resumable Angular upload</title>
</head>
<body><app-upload></app-upload></body>
</html>
Añade el servidor local del protocolo tus
Guarda server.ts. El
servidor del protocolo tus para Node gestiona el protocolo y las
cabeceras CORS; su FileStore conserva tanto los bytes subidos
como los metadatos en uploads/. Reiniciar este proceso desde el mismo
directorio los conserva. Este receptor local tiene un límite de 100 MiB por archivo y no incluye
autenticación. Mantenlo vinculado a la interfaz de bucle local.
import { FileStore } from '@tus/file-store'
import { Server } from '@tus/server'
const server = new Server({
path: '/files',
datastore: new FileStore({ directory: './uploads' }),
maxSize: 100 * 1024 * 1024,
allowedOrigins: ['http://127.0.0.1:4200'],
})
server.listen({ host: '127.0.0.1', port: 1080 })
.once('listening', () => {
console.log('Tus receiver ready at http://127.0.0.1:1080/files')
})
.once('error', () => {
console.error('Cannot start the receiver. Check whether port 1080 is already in use.')
process.exitCode = 1
})
Crea un servicio de subida
Guarda upload.service.ts. El servicio establece el estado ocupado antes de buscar
las URL guardadas, ignora los callbacks de una subida detenida y espera a
abort() antes de permitir otro inicio. Sus señales notifican a Angular
cuando los callbacks asíncronos del cliente actualizan la interfaz, incluso con la
detección de cambios sin zonas.
import { Injectable, signal, type OnDestroy } from '@angular/core'
import { DetailedError, Upload } from 'tus-js-client'
@Injectable()
export class UploadService implements OnDestroy {
readonly busy = signal(false)
readonly pausing = signal(false)
readonly percentage = signal(0)
readonly message = signal('Choose a file, then select Upload.')
readonly uploadUrl = signal<string | null>(null)
#upload: Upload | null = null
#ignoreCallbacks = false
async start(file: File): Promise<void> {
if (this.busy()) return
if (file.size > 100 * 1024 * 1024) {
this.message.set('Choose a file no larger than 100 MiB.')
return
}
this.busy.set(true)
this.#ignoreCallbacks = false
this.percentage.set(0)
this.uploadUrl.set(null)
this.message.set('Uploading…')
const isCurrent = (): boolean => this.#upload === upload && !this.#ignoreCallbacks
const upload = new Upload(file, {
endpoint: 'http://127.0.0.1:1080/files',
chunkSize: 1024 * 1024,
retryDelays: [0, 1000, 3000, 5000],
removeFingerprintOnSuccess: true,
metadata: { filename: file.name },
onUploadUrlAvailable: () => {
if (isCurrent()) this.uploadUrl.set(upload.url)
},
onProgress: (sent, total) => {
if (isCurrent()) this.percentage.set(total > 0 ? (sent / total) * 100 : 0)
},
onSuccess: () => {
if (!isCurrent()) return
this.#upload = null
this.busy.set(false)
this.percentage.set(100)
this.message.set('Upload complete.')
},
onError: (error) => {
if (!isCurrent()) return
this.#upload = null
this.busy.set(false)
const status = error instanceof DetailedError ? error.originalResponse?.getStatus() : null
this.message.set(status === 401 || status === 403
? 'Upload denied. Check authorization before retrying.'
: 'Upload failed. Check the receiver and connection, then select Upload to retry.')
},
})
this.#upload = upload
try {
const previous = await upload.findPreviousUploads()
if (!isCurrent()) return
if (previous[0]) upload.resumeFromPreviousUpload(previous[0])
upload.start()
} catch {
if (!isCurrent()) return
this.#upload = null
this.busy.set(false)
this.message.set('Cannot prepare the upload. Check browser storage, then retry.')
}
}
async pause(): Promise<void> {
const upload = this.#upload
if (!upload || this.pausing()) return
this.#ignoreCallbacks = true
this.pausing.set(true)
try {
await upload.abort()
this.#upload = null
this.busy.set(false)
this.message.set('Paused. Select Upload to resume.')
} catch {
this.message.set('Could not pause. Select Pause to try again.')
} finally {
this.pausing.set(false)
}
}
ngOnDestroy(): void {
const upload = this.#upload
this.#upload = null
void upload?.abort().catch(() => {
console.error('Unable to pause the upload during cleanup.')
})
}
}
La API del cliente define abort() como una
pausa; en cambio, abort(true) solicita la eliminación. Este ejemplo descarta
la instancia del cliente detenido y busca su URL en el siguiente inicio, por lo que tanto la
reanudación mediante el botón como la recuperación tras recargar requieren que el navegador
almacene las URL. Selecciona la primera coincidencia guardada, lo cual es adecuado para esta demo
de un solo usuario.
Los fragmentos de 1 MiB facilitan la inspección de solicitudes individuales. No son necesarios para reanudar subidas; el cliente usa de forma predeterminada un tamaño de fragmento ilimitado. Su política de reintentos limita los reintentos consecutivos y restablece el cupo cuando hay progreso. Una vez que se agotan los reintentos, Upload permite al usuario volver a intentarlo.
Crea el componente de subida
Guarda main.ts. Este es tanto el componente independiente como el punto de
entrada de la aplicación, por lo que no queda ninguna plantilla raíz ni ningún módulo por conectar.
La instancia del servicio pertenece al componente y se libera cuando Angular lo destruye.
import { DecimalPipe } from '@angular/common'
import { Component, inject, signal } from '@angular/core'
import { bootstrapApplication } from '@angular/platform-browser'
import { UploadService } from './upload.service.ts'
@Component({
selector: 'app-upload',
standalone: true,
imports: [DecimalPipe],
providers: [UploadService],
template: `
<main>
<h1>Resumable upload</h1>
<label for="file">Choose file</label>
<input id="file" type="file" [disabled]="uploader.busy()" (change)="select($event)" />
<button type="button" (click)="start()" [disabled]="!file() || uploader.busy()">
Upload
</button>
<button type="button" (click)="uploader.pause()"
[disabled]="!uploader.busy() || uploader.pausing()">Pause</button>
<p>Sent: {{ uploader.percentage() | number: '1.0-0' }}%</p>
<progress max="100" [value]="uploader.percentage()" aria-label="Upload progress"></progress>
<p role="status">{{ uploader.message() }}</p>
@if (uploader.uploadUrl(); as url) {
<p>Upload URL: <code>{{ url }}</code></p>
}
</main>
`,
})
export class UploadComponent {
readonly uploader = inject(UploadService)
readonly file = signal<File | null>(null)
select(event: Event): void {
if (this.uploader.busy()) return
if (event.target instanceof HTMLInputElement) {
this.file.set(event.target.files?.[0] ?? null)
}
}
start(): void {
const file = this.file()
if (file) void this.uploader.start(file)
}
}
bootstrapApplication(UploadComponent).catch(() => {
console.error('Unable to start the upload application.')
})
Ejecútalo y verifica una transferencia reanudada
Con los siete archivos guardados, instala y compila desde el directorio padre:
(cd resumable-upload-demo && yarn install && yarn build)
Tras una compilación exitosa, inicia el receptor en una terminal y el servidor de desarrollo de Angular en otra, ambos desde ese mismo directorio padre:
(cd resumable-upload-demo && yarn server)
(cd resumable-upload-demo && yarn start)
Abre http://127.0.0.1:4200. Selecciona un archivo de menos de 100 MiB mediante
Choose file y luego selecciona
Upload. Usa un archivo de varios MiB y
la limitación de velocidad de red del navegador para tener tiempo de seleccionar
Pause. Espera a que aparezca
Paused. Select Upload to resume. antes de continuar.
Inspecciona las solicitudes de red del navegador. Después de al menos un
PATCH exitoso, recarga la página, selecciona ese mismo archivo sin cambios
y vuelve a seleccionar Upload.
Deberías ver un HEAD a la URL de subida anterior, seguido de un
PATCH cuyo Upload-Offset de la solicitud coincida con el
desplazamiento devuelto por HEAD. Ese desplazamiento debería ser mayor
que cero. Un nuevo POST significa que el cliente creó una subida nueva
en lugar de reanudar la anterior.
Espera a que aparezca Upload complete.. El último
segmento de la URL de subida mostrada identifica el archivo en resumable-upload-demo/uploads/;
un archivo .json ubicado junto a él contiene los metadatos. Compara los
bytes o el resumen SHA-256 del archivo subido con los del original. El nombre de archivo original
es un metadato, no una ruta de disco. Volver a subirlo tras completar la subida crea un recurso
independiente porque el cliente elimina el registro de la URL completada. Las subidas existentes
permanecen en disco.
El porcentaje muestra los bytes enviados por el navegador y puede retroceder tras la recuperación. La finalización se determina mediante el callback de éxito del protocolo tus, no al alcanzar el 100 % en la barra de progreso. Ninguno de los dos indica que una aplicación en producción haya validado el archivo, lo haya sometido a un análisis de seguridad o lo haya publicado.
Configuración de CORS
Usa 127.0.0.1 de forma coherente: localhost es un origen
distinto. El receptor permite el origen de Angular en el puerto 4200 y proporciona las cabeceras
de solicitud y de respuesta expuestas del protocolo tus, incluidas Location
y Upload-Offset. Si un puerto está ocupado, elige otro y actualiza la URL
correspondiente en server.ts, upload.service.ts o el script
start. Los errores de CORS suelen aparecer ante el cliente como fallos de
red; inspecciona tanto la respuesta a la solicitud preliminar como la solicitud de subida.
CORS no es autenticación. Este receptor sirve para pruebas locales y no autoriza a los usuarios.
tus-js-client usa su propio transporte XMLHttpRequest, por lo que
HttpInterceptor de Angular no adjunta credenciales a estas solicitudes. Para una
integración protegida, usa la opción headers o
onBeforeRequest documentada del cliente, permite en el servidor cualquier cabecera
añadida y autoriza cada operación de subida. Mantén las credenciales fuera de este ejemplo y
envíalas solo a URL de subida de confianza.
Buenas prácticas y consideraciones
La huella del navegador del cliente usa los metadatos del archivo y el endpoint, no un hash del contenido. Vuelve a seleccionar el original sin cambios. Una aplicación con varias cuentas necesita almacenar las URL por cuenta y una política deliberada para las coincidencias múltiples; seleccionar la primera coincidencia no comprueba la propiedad.
Si la URL guardada devuelve 404 o 410, la
versión 4.3.1 recurre a crear una nueva subida en el endpoint configurado. Los bytes que faltan en
el servidor no se pueden recuperar del almacenamiento del navegador. Este servidor local no
programa la caducidad ni elimina los archivos abandonados; detén ambos procesos con Ctrl+C cuando
termines y elimina el directorio uploads/ de la demo solo cuando ya no
necesites sus datos.
Ante fallos de conexión, restablece el receptor o la red y vuelve a intentarlo. Si se deniega una solicitud, resuelve primero la autorización. El formulario mantiene deshabilitados la selección de archivos y los nuevos inicios hasta que una subida activa termine, falle o termine de pausarse. Si la pausa falla, vuelve a intentar Pause antes de iniciar cualquier otra cosa. El servidor aplica el límite de tamaño independientemente del navegador; la validación del contenido en producción y la limpieza del almacenamiento también corresponden al servidor.
