Subidas reanudables de archivos en Angular con RxJS
Dividir los archivos en fragmentos limita la cantidad de datos que se deben reenviar si falla la conexión. Este ejemplo implementa la subida de un solo archivo dentro de la página, con opciones para pausar, reanudar y cancelar, y un indicador de progreso del archivo completo. Conserva el enfoque de Angular/RxJS del artículo original y actualiza la configuración a Angular 22.1.6.
¿Por qué usar subidas por fragmentos y reanudables?
Una solicitud interrumpida puede haber llegado al servidor aunque su respuesta nunca haya llegado al navegador. Por lo tanto, al reanudar se debe consultar la posición hasta la que el servidor ha guardado los datos de forma definitiva. Un contador local de fragmentos por sí solo no permite determinar qué bytes se almacenaron.
Esta es una integración completa del cliente, no un servidor de subidas. Antes de ejecutarla, proporciona esta API del mismo origen, protegida por tu middleware existente de inicio de sesión y CSRF:
| Solicitud | Comportamiento requerido |
|---|---|
PUT /api/uploads/{id} con {size} | Reservar el UUID para el usuario autenticado o devolver su estado existente como {offset, complete}. Vincularlo permanentemente a ese propietario y tamaño; rechazar su reutilización si hay discrepancias. |
PUT /api/uploads/{id}/bytes | Aceptar un cuerpo sin procesar con Content-Range: bytes start-end/total. Bajo un bloqueo de la subida, validar la longitud exacta, el total, la cuota y la posición esperada; guardar los bytes de forma definitiva antes de confirmar con 204. |
POST /api/uploads/{id}/complete | Verificar todos los bytes y el cumplimiento de la política de contenido, y luego publicar de forma atómica una sola vez. Las ejecuciones repetidas devuelven la misma respuesta de éxito. |
DELETE /api/uploads/{id} | Cancelar de forma idempotente una subida incompleta bajo el mismo bloqueo; rechazar la cancelación de una subida publicada. |
Cada operación debe comprobar la propiedad, la autenticación de la sesión y la protección CSRF.
Establece un límite de 100 MiB por archivo, un límite de 1 MiB por fragmento, límites de almacenamiento
y de frecuencia por usuario, y un plazo de caducidad para las sesiones abandonadas. Mantén privados los
datos parciales. Rechaza las redirecciones y el acceso entre orígenes en la puerta de enlace. Angular
envía el encabezado X-XSRF-TOKEN para las URL relativas de modificación cuando el
servidor proporciona la cookie XSRF-TOKEN; el servidor debe validarlo realmente.
Ningún Auth Secret de Transloadit debe incluirse en este código del navegador.
Configura tu proyecto de Angular
Usa Node.js 24.15 o una versión posterior de la rama Node 24 con soporte y genera la estructura de una aplicación independiente:
corepack yarn dlx @angular/cli@22.1.8 new chunked-upload-app --standalone --ssr=false --style=css --package-manager=yarn
cd chunked-upload-app
corepack yarn add --exact @angular/common@22.1.6 @angular/compiler@22.1.6 @angular/core@22.1.6 @angular/platform-browser@22.1.6 rxjs@7.8.2
corepack yarn add --dev --exact @angular/compiler-cli@22.1.6 typescript@6.0.2
Los ejemplos usan importaciones explícitas con .ts. Incorpora esta opción al
tsconfig.json raíz generado para que TypeScript reescriba esas extensiones al generar
JavaScript:
{
"compilerOptions": {
"rewriteRelativeImportExtensions": true
}
}
Usa el siguiente src/app/app.config.ts. El backend Fetch predeterminado de Angular no emite
el progreso de subida; esta aplicación, exclusiva para el navegador, selecciona XHR explícitamente.
Consulta la configuración HTTP de Angular y la
documentación del ciclo de vida de las solicitudes.
import { provideHttpClient, withXhr } from '@angular/common/http'
import type { ApplicationConfig } from '@angular/core'
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withXhr())],
}
Sirve la aplicación compilada y la API desde el mismo origen. Para el desarrollo local, dirige
/api a tu backend de desarrollo mediante el proxy de la CLI; no lo dirijas
al entorno de producción.
Implementa la división del archivo en fragmentos
El siguiente servicio divide el archivo de un fragmento a la vez. end es un
límite exclusivo para File.slice, pero inclusivo en el encabezado de rango. Por lo
tanto, un archivo de 2.097.155 bytes termina con bytes 2097152-2097154/2097155, no con un total
inferido del último fragmento de menor tamaño.
Crea un pipeline de RxJS para subir fragmentos
Coloca el servicio completo en src/app/file-upload.service.ts. concatMap mantiene
una sola solicitud en curso. Cada reanudación determina el estado del servidor antes de enviar más
bytes; los errores siguen siendo observables y el archivo seleccionado queda disponible para otro
intento de reanudación.
import { HttpClient, HttpEventType } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import {
BehaviorSubject,
EMPTY,
Subscription,
catchError,
concatMap,
concatWith,
defer,
finalize,
range,
tap,
} from 'rxjs'
interface UploadState {
progress: number
running: boolean
canceling: boolean
complete: boolean
message: string
}
@Injectable({ providedIn: 'root' })
export class FileUploadService {
readonly state = new BehaviorSubject<UploadState>({
progress: 0,
running: false,
canceling: false,
complete: false,
message: 'Select a file.',
})
readonly #http = inject(HttpClient)
#request = new Subscription()
#file: File | null = null
#id = ''
#offset = 0
readonly #chunkSize = 1024 * 1024
select(file: File): void {
if (this.state.value.canceling) return
this.pause()
this.#file = null
if (file.size === 0 || file.size > 100 * 1024 * 1024) {
this.state.next({
progress: 0,
running: false,
canceling: false,
complete: false,
message: 'Choose a file between 1 byte and 100 MiB.',
})
return
}
this.#file = file
this.#id = crypto.randomUUID()
this.#offset = 0
this.state.next({
progress: 0,
running: false,
canceling: false,
complete: false,
message: 'Ready.',
})
this.resume()
}
resume(): void {
const file = this.#file
if (!file || this.state.value.running || this.state.value.canceling || this.state.value.complete) {
return
}
const url = `/api/uploads/${this.#id}`
this.state.next({ ...this.state.value, running: true, message: 'Uploading…' })
this.#request = this.#http
.put<unknown>(url, { size: file.size }, { timeout: 30_000 })
.pipe(
concatMap((status) => {
if (
typeof status !== 'object' ||
status === null ||
!('offset' in status) ||
typeof status.offset !== 'number' ||
!Number.isSafeInteger(status.offset) ||
status.offset < 0 ||
status.offset > file.size ||
!('complete' in status) ||
typeof status.complete !== 'boolean' ||
(status.complete && status.offset !== file.size)
) {
throw new Error('Invalid upload state')
}
this.#offset = status.offset
this.progress(file)
if (status.complete) {
this.finished()
return EMPTY
}
const offset = status.offset
return range(0, Math.ceil((file.size - offset) / this.#chunkSize)).pipe(
concatMap((index) =>
defer(() => {
const start = offset + index * this.#chunkSize
const end = Math.min(start + this.#chunkSize, file.size)
return this.#http
.put(`${url}/bytes`, file.slice(start, end), {
headers: { 'Content-Range': `bytes ${start}-${end - 1}/${file.size}` },
observe: 'events',
reportProgress: true,
timeout: 30_000,
})
.pipe(
tap((event) => {
if (event.type === HttpEventType.UploadProgress) {
this.progress(file, Math.min(event.loaded, end - start))
}
if (event.type === HttpEventType.Response) {
this.#offset = end
this.progress(file)
}
}),
)
}),
),
// Finalization runs once, including when the server already has every byte.
concatWith(
defer(() =>
this.#http
.post(`${url}/complete`, {}, { timeout: 30_000 })
.pipe(tap(() => this.finished())),
),
),
)
}),
finalize(() => this.state.next({ ...this.state.value, running: false })),
)
.subscribe({
error: () =>
this.state.next({ ...this.state.value, message: 'Upload interrupted. Resume to retry.' }),
})
}
pause(): void {
if (this.state.value.canceling) return
this.#request.unsubscribe()
if (this.#file && !this.state.value.complete) {
this.progress(this.#file)
this.state.next({ ...this.state.value, running: false, message: 'Paused.' })
}
}
cancel(): void {
if (this.state.value.canceling) return
this.pause()
if (!this.#file || this.state.value.complete) return
const url = `/api/uploads/${this.#id}`
this.#file = null
let confirmed = false
this.state.next({
progress: 0,
running: true,
canceling: true,
complete: false,
message: 'Canceling…',
})
this.#request = this.#http
.delete(url, { timeout: 30_000 })
.pipe(
tap(() => {
confirmed = true
}),
// Errors and interrupted requests both leave cleanup unconfirmed.
catchError(() => EMPTY),
// Publish one settled state so observers can safely select another file.
finalize(() =>
this.state.next({
...this.state.value,
running: false,
canceling: false,
message: confirmed ? 'Canceled.' : 'Cancellation unconfirmed. Check your uploads.',
}),
),
)
.subscribe()
}
destroy(): void {
this.pause()
// Destruction also interrupts cleanup; its finalizer reports the unconfirmed result.
this.#request.unsubscribe()
}
private progress(file: File, loaded = 0): void {
this.state.next({
...this.state.value,
progress: Math.min(99, Math.floor((100 * (this.#offset + loaded)) / file.size)),
})
}
private finished(): void {
this.state.next({
progress: 100,
running: false,
canceling: false,
complete: true,
message: 'Upload complete.',
})
}
}
Añade las opciones de pausa, reanudación y cancelación
Cancelar la suscripción aborta la solicitud HTTP activa. No deshace los bytes que el servidor ya aceptó. La pausa conserva el File y el UUID en memoria; la reanudación sincroniza la posición con el servidor. Al recargar la página se pierde este estado, por lo que este ejemplo no reanuda las subidas tras una recarga. Seleccionar otro archivo abandona la sesión anterior, que la política de caducidad del servidor debe eliminar. La cancelación intenta eliminar los datos de la subida en el servidor; un fallo de red o una condición de carrera con la publicación exige consultar la lista de subidas del servidor. Mientras la limpieza está pendiente, la pausa, la reanudación, la cancelación y la selección de archivos están deshabilitadas y el servicio las ignora. Destruir el componente interrumpe la limpieza e informa que la cancelación no está confirmada.
Supervisa el progreso
Reemplaza src/app/app.ts por este componente raíz independiente. La sintaxis integrada
de control de flujo no necesita NgIf; AsyncPipe se
importa explícitamente. El progreso se mantiene por debajo de 100 hasta que se confirma la publicación.
Una reanudación puede hacer retroceder el progreso cuando es necesario reenviar bytes no confirmados.
import { AsyncPipe } from '@angular/common'
import { Component, inject, OnDestroy } from '@angular/core'
import { FileUploadService } from './file-upload.service.ts'
@Component({
selector: 'app-root',
standalone: true,
imports: [AsyncPipe],
template: `
@if (upload.state | async; as state) {
<label>File <input type="file" (change)="select($event)" [disabled]="state.running" /></label>
<progress aria-label="Upload progress" max="100" [value]="state.progress"></progress>
<p role="status">{{ state.message }}</p>
<button (click)="upload.pause()" [disabled]="!state.running || state.canceling">Pause</button>
<button (click)="upload.resume()" [disabled]="state.running || state.canceling || state.complete">Resume</button>
<button (click)="upload.cancel()" [disabled]="state.canceling || state.complete">Cancel</button>
}
`,
})
export class App implements OnDestroy {
readonly upload = inject(FileUploadService)
select(event: Event): void {
if (!(event.target instanceof HTMLInputElement)) return
const file = event.target.files?.[0]
if (file) this.upload.select(file)
}
ngOnDestroy(): void {
this.upload.destroy()
}
}
Usa este src/main.ts y conserva el index.html generado con su
elemento app-root:
import { bootstrapApplication } from '@angular/platform-browser'
import { App } from './app/app.ts'
import { appConfig } from './app/app.config.ts'
bootstrapApplication(App, appConfig).catch(() => console.error('Application startup failed.'))
Si prefieres un protocolo estandarizado de subidas reanudables en lugar de este contrato específico de la aplicación, considera el protocolo tus y Uppy. Ni la validación del lado del cliente ni una barra de progreso sustituyen la autorización del servidor y la semántica de confirmación del almacenamiento duradero.
