Subidas de archivos reanudables en Angular
En las aplicaciones web actuales, las redes poco fiables y los archivos grandes pueden generar experiencias de usuario frustrantes durante la subida de archivos. Las subidas reanudables permiten que tus usuarios continúen su subida donde la dejaron si ocurre una interrupción. En esta publicación te mostramos cómo integrar tus-js-client en tu aplicación Angular para crear subidas de archivos robustas y reanudables.
¿Por qué subidas reanudables?
Las subidas reanudables abordan problemas comunes con archivos grandes y redes inestables. Al dividir un archivo en fragmentos manejables y permitir que los segmentos fallidos se reintenten, aseguras una experiencia de usuario más fiable. El protocolo estandarizado tus, que tus-js-client implementa, hace que este proceso sea más fácil de gestionar y escalar.
Configurar tu proyecto de Angular
Empieza creando un nuevo proyecto de Angular con la CLI de Angular y componentes independientes:
npm install -g @angular/cli
ng new resumable-upload-demo --standalone
cd resumable-upload-demo
Instala el paquete tus-js-client con los tipos de TypeScript:
npm install tus-js-client@4.3.1
Crear un servicio de subida
Vamos a crear un servicio de Angular para gestionar las subidas con tus-js-client. Este servicio
incluye tipos de TypeScript adecuados, reintentos acotados y la búsqueda de URL de subida
almacenadas previamente. Guárdalo como upload.service.ts. El tipo
UploadOptions está disponible en el paquete fijado 4.3.1.
import { Injectable } from '@angular/core'
import { DetailedError, Upload, type UploadOptions } from 'tus-js-client'
export interface UploadProgress {
bytesUploaded: number
bytesTotal: number
percentage: number
}
@Injectable({
providedIn: 'root',
})
export class UploadService {
#upload: Upload | null = null
#stopping = false
async startUpload(
file: File,
endpoint: string,
onProgress: (progress: UploadProgress) => void,
onSuccess: () => void,
onError: (error: Error) => void,
headers: Record<string, string> = {},
): Promise<void> {
if (this.#upload) {
onError(new Error('Stop the current upload before starting another.'))
return
}
const options: UploadOptions = {
endpoint,
headers,
retryDelays: [0, 1000, 3000, 5000],
removeFingerprintOnSuccess: true,
metadata: {
filename: file.name,
filetype: file.type,
},
onError: (error) => {
if (this.#upload !== upload || this.#stopping) return
this.#upload = null
const status = error instanceof DetailedError ? error.originalResponse?.getStatus() : null
const message = status === 401 || status === 403
? 'Upload authorization failed. Sign in again before retrying.'
: 'Upload stopped. Check the connection, then select Upload to retry.'
onError(new Error(message, { cause: error }))
},
onProgress: (bytesUploaded: number, bytesTotal: number) => {
if (this.#upload !== upload || this.#stopping) return
onProgress({
bytesUploaded,
bytesTotal,
percentage: bytesTotal > 0 ? (bytesUploaded / bytesTotal) * 100 : 0,
})
},
onSuccess: () => {
if (this.#upload !== upload || this.#stopping) return
this.#upload = null
onSuccess()
},
}
const upload = new Upload(file, options)
this.#upload = upload
try {
const previousUploads = await upload.findPreviousUploads()
if (this.#upload !== upload || this.#stopping) return
if (previousUploads[0]) upload.resumeFromPreviousUpload(previousUploads[0])
upload.start()
} catch (error) {
if (this.#upload !== upload || this.#stopping) return
this.#upload = null
onError(new Error('Unable to prepare the upload. Please try again.', { cause: error }))
}
}
async abortUpload(): Promise<void> {
const upload = this.#upload
if (!upload) return
this.#stopping = true
await upload.abort()
this.#upload = null
this.#stopping = false
}
}
La API de tus-js-client describe sus valores
DetailedError y su política de reintentos. onError informa de un error que no se reintentará
más; no es una promesa de que la recuperación continuará automáticamente. El array de esperas acota
los reintentos consecutivos, y el progreso puede reiniciar ese presupuesto. abort() devuelve una promesa
y pausa la subida sin eliminar su recurso en el servidor. Un aborto rechazado se expone a quien lo
invoca, y los nuevos inicios permanecen bloqueados hasta que la detención tenga éxito.
Después de recargar, el usuario debe seleccionar el mismo archivo otra vez. El servicio busca la URL
de subida guardada y la reanuda antes de empezar. Esto depende de que el almacenamiento de URL del
navegador esté disponible, de que la subida no haya expirado y de que el servidor autorice a ese
usuario en cada petición. Este ejemplo elige la primera coincidencia; las aplicaciones con varias
coincidencias o cuentas deberían ofrecer una elección y limitar los registros almacenados a la
cuenta activa. Deja chunkSize con su valor predeterminado salvo que el servidor o el proxy
requieran un límite de tamaño de petición.
Crear el componente de subida
Guarda este componente independiente como upload.component.ts, impórtalo en el
imports del componente raíz y añade <app-upload /> a la plantilla raíz. Configura /files/ como tu
propio endpoint tus. Para un endpoint protegido, vincula el token de acceso de la sesión actual a
[authToken]; no incrustes una credencial en el código. Este componente proporciona su propia
instancia del servicio para que los widgets de subida separados no compartan una transferencia
activa.
import { CommonModule } from '@angular/common'
import { ChangeDetectorRef, Component, Input, type OnDestroy } from '@angular/core'
import { UploadService, type UploadProgress } from './upload.service'
@Component({
selector: 'app-upload',
standalone: true,
imports: [CommonModule],
providers: [UploadService],
template: `
<div class="upload-container">
<input
type="file"
[disabled]="isUploading"
(change)="onFileSelected($event)"
[attr.aria-label]="'Choose file to upload'"
/>
<button
(click)="startUpload()"
[disabled]="!selectedFile || isUploading"
class="upload-button"
>
{{ isUploading ? 'Uploading...' : 'Upload' }}
</button>
<button (click)="stopUpload()" [disabled]="!isUploading || isStopping">Stop</button>
<div *ngIf="progress" class="progress-container">
<div class="progress-bar" [style.width.%]="progress.percentage">
{{ progress.percentage | number: '1.0-0' }}%
</div>
</div>
<div *ngIf="message" [class]="messageType" role="alert">
{{ message }}
</div>
</div>
`,
styles: [
`
.upload-container {
padding: 1rem;
}
.progress-container {
margin-top: 1rem;
background: #f0f0f0;
border-radius: 4px;
}
.progress-bar {
height: 20px;
background: #4caf50;
border-radius: 4px;
text-align: center;
color: white;
transition: width 0.3s ease;
}
.error {
color: #d32f2f;
margin-top: 1rem;
}
.success {
color: #388e3c;
margin-top: 1rem;
}
`,
],
})
export class UploadComponent implements OnDestroy {
@Input() authToken: string | null = null
selectedFile: File | null = null
progress: UploadProgress | null = null
message = ''
messageType = ''
isUploading = false
isStopping = false
constructor(private uploadService: UploadService, private changeDetector: ChangeDetectorRef) {}
onFileSelected(event: Event): void {
const input = event.target
if (input instanceof HTMLInputElement) {
this.selectedFile = input.files?.[0] ?? null
this.progress = null
this.message = ''
this.messageType = ''
}
}
async startUpload(): Promise<void> {
if (!this.selectedFile || this.isUploading) return
this.isUploading = true
this.message = ''
this.progress = null
const uploadEndpoint = '/files/'
await this.uploadService.startUpload(
this.selectedFile,
uploadEndpoint,
(progress) => {
this.progress = progress
this.changeDetector.markForCheck()
},
() => {
this.message = 'Upload completed successfully!'
this.messageType = 'success'
this.isUploading = false
this.changeDetector.markForCheck()
},
(error: Error) => {
this.message = error.message
this.messageType = 'error'
this.isUploading = false
this.changeDetector.markForCheck()
},
this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {},
)
}
async stopUpload(): Promise<void> {
this.isStopping = true
try {
await this.uploadService.abortUpload()
this.isUploading = false
this.message = 'Upload stopped. Select Upload to resume.'
this.messageType = ''
} catch {
this.message = 'Unable to stop the upload. Please try stopping it again.'
this.messageType = 'error'
} finally {
this.isStopping = false
this.changeDetector.markForCheck()
}
}
ngOnDestroy(): void {
void this.uploadService.abortUpload().catch(() => {
console.error('Unable to stop the upload during cleanup.')
})
}
}
Configuración de CORS
Asegúrate de que tu servidor tus incluya las siguientes cabeceras CORS para funcionar correctamente:
Access-Control-Allow-Origin: <your-domain>
Access-Control-Allow-Methods: POST, PATCH, HEAD, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset
Access-Control-Expose-Headers: Upload-Offset, Location, Upload-Length, Tus-Version, Tus-Resumable, Tus-Max-Size, Tus-Extension, Upload-Metadata
Buenas prácticas y consideraciones
-
Autenticación: tus-js-client usa su propio transporte XHR del navegador, así que
HttpInterceptorde Angular no ve estas peticiones. El servicio pasa la autorización mediante la opciónheadersdel protocolo tus. Para tokens que expiran, obtén un token actual para cada petición mediante el hook documentadoonBeforeRequest, y restringe las URL de subida a tu servidor de confianza. -
Validación de archivos: Añade este método dentro de
UploadComponenty llámalo enstartUpload()antes de asignarisUploading:if (!this.validateFile(this.selectedFile)) return. Las comprobaciones del navegador dan retroalimentación; el servidor debe imponer el tamaño e inspeccionar el contenido real.
validateFile(file: File): boolean {
const maxSize = 100 * 1024 * 1024 // 100 MiB
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
if (file.size > maxSize) {
this.message = 'File size exceeds 100 MiB limit'
return false
}
if (!allowedTypes.includes(file.type)) {
this.message = 'Invalid file type'
return false
}
return true
}
- Recuperación ante errores: Implementa lógica de reintento automático para problemas de red
- Monitoreo del progreso: Usa sujetos de RxJS para difundir el progreso de la subida a otros componentes
- Limpieza: Gestiona correctamente la destrucción del componente y aborta las subidas en curso
Conclusión
Implementar subidas reanudables en Angular con tus-js-client ofrece una solución robusta para gestionar archivos grandes e interrupciones de red. La combinación de tipos de TypeScript, un manejo de errores adecuado y buenas prácticas garantiza una experiencia de subida fiable para tus usuarios.
Si necesitas una solución más completa para la gestión de archivos, considera explorar la API de Transloadit, que ofrece funciones avanzadas para el procesamiento y la transformación de archivos.
