Téléversements de fichiers reprenables dans Angular
Utilisez tus-js-client avec un serveur tus pour reprendre le téléversement d’un fichier dans
Angular à partir des octets que le serveur possède déjà. Cet exemple construit un formulaire Angular
autonome et un récepteur local qui stocke sur disque. Vous pourrez mettre un transfert en pause,
recharger la page, sélectionner le même fichier et terminer son téléversement.
Pourquoi des téléversements reprenables ?
Le protocole tus utilise un POST pour créer une URL de téléversement,
des requêtes PATCH pour envoyer les octets et une requête HEAD pour découvrir le Upload-Offset
enregistré lors de la reprise. C’est l’offset du serveur qui détermine où reprendre. Une barre de
progression dans le navigateur ne peut pas, à elle seule, vous indiquer quels octets ont survécu à
une connexion interrompue.
Le navigateur stocke l’URL de téléversement, pas une copie du fichier. Après un rechargement, l’utilisateur doit sélectionner à nouveau le fichier original, non modifié, dans le même profil de navigateur et sur la même origine de page. Effacer le stockage du site, modifier le fichier ou perdre le téléversement stocké par le serveur peut obliger à tout recommencer.
Configurer votre projet Angular
Utilisez Node.js 24.15.0 et Yarn 4.12.0 pour ce tutoriel. Les versions de paquets ci-dessous ont été compilées et testées dans Chromium 145 sous Linux. Angular a ses propres exigences de compatibilité avec Node.js et TypeScript ; ces versions épinglées décrivent la configuration testée.
Depuis un répertoire de travail où vous souhaitez placer l’exemple, créez un nouveau répertoire de projet. Ces commandes Bash refusent un répertoire existant ainsi qu’un projet Yarn Plug’n’Play englobant, dont le chargeur peut affecter la compilation d’Angular. Elles laissent votre shell à son emplacement d’origine :
(
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
)
Enregistrez chacun des fichiers ci-dessous dans resumable-upload-demo. Le fichier de verrouillage local
et la configuration Yarn donnent à cet exemple sa propre installation de dépendances.
package.json épingle les dépendances directes et la cible de compilation du navigateur :
{
"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"
}
}
Enregistrez angular.json pour relier la compilation et le serveur de développement à 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" }
}
}
}
}
}
Enregistrez tsconfig.json. Les types Node satisfont les déclarations partagées du paquet
client ; ils n’ajoutent pas d’API Node au navigateur :
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"experimentalDecorators": true,
"strict": true,
"types": ["node"],
"lib": ["ES2022", "DOM"],
"rewriteRelativeImportExtensions": true
},
"angularCompilerOptions": { "strictTemplates": true },
"files": ["main.ts"]
}
Enregistrez 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>
Ajouter le serveur tus local
Enregistrez server.ts. Le serveur tus pour Node
gère le protocole et les en-têtes CORS ; son
FileStore conserve à la fois les octets
téléversés et les métadonnées dans uploads/. Redémarrer ce processus depuis le même
répertoire les conserve. Ce récepteur local impose une limite de 100 MiB par fichier et n’a aucune
authentification. Gardez-le lié à l’interface de loopback.
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
})
Créer un service de téléversement
Enregistrez upload.service.ts. Le service réserve l’état occupé avant de rechercher les URL
enregistrées, ignore les callbacks d’un téléversement arrêté et attend abort() avant
d’autoriser un nouveau démarrage. Ses signaux notifient Angular lorsque les callbacks asynchrones du
client mettent à jour l’interface, y compris avec la
détection des changements sans zone.
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.')
})
}
}
L’API du client définit abort() comme une
pause ; abort(true) demande plutôt la suppression. Cet exemple abandonne l’instance client
arrêtée et recherche son URL au démarrage suivant ; la reprise via le bouton comme la récupération
après rechargement nécessitent donc le stockage des URL dans le navigateur. Il sélectionne la
première correspondance stockée, ce qui convient à cette démo mono-utilisateur.
Les blocs de 1 MiB permettent d’inspecter facilement chaque requête. Ils ne sont pas nécessaires à la reprise ; par défaut, le client utilise une taille de bloc illimitée. Sa politique de nouvelles tentatives limite les tentatives consécutives et réinitialise ce budget dès que la progression avance. Une fois les tentatives arrêtées, Upload permet à l’utilisateur de réessayer.
Construire le composant de téléversement
Enregistrez main.ts. Il s’agit à la fois du composant autonome et du point d’entrée de
l’application : il n’y a donc aucun modèle racine ni module à connecter. L’instance du service
appartient au composant et est nettoyée lorsque Angular détruit celui-ci.
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.')
})
Exécuter l’exemple et vérifier un transfert repris
Une fois les sept fichiers enregistrés, installez et compilez depuis le répertoire parent :
(cd resumable-upload-demo && yarn install && yarn build)
Après une compilation réussie, démarrez le récepteur dans un terminal et le serveur de développement Angular dans un autre, tous deux depuis ce même répertoire parent :
(cd resumable-upload-demo && yarn server)
(cd resumable-upload-demo && yarn start)
Ouvrez http://127.0.0.1:4200. Sélectionnez un fichier de moins de 100 MiB via
Choose file, puis sélectionnez
Upload. Utilisez un fichier de plusieurs MiB et la limitation réseau du
navigateur pour vous laisser le temps de sélectionner Pause. Attendez
Paused. Select Upload to resume. avant de continuer.
Inspectez les requêtes réseau du navigateur. Après au moins un PATCH réussi, rechargez la
page, sélectionnez ce même fichier non modifié et sélectionnez de nouveau
Upload. Vous devriez voir un HEAD vers l’URL de téléversement
précédente, suivi d’un PATCH dont la requête contient un Upload-Offset correspondant à l’offset renvoyé
par HEAD. Cet offset doit être supérieur à zéro. Un nouveau POST signifie que le client a
créé un nouveau téléversement au lieu de reprendre l’ancien.
Attendez Upload complete.. Le dernier segment de l’URL de téléversement
affichée identifie le fichier dans resumable-upload-demo/uploads/ ; un fichier .json voisin contient les
métadonnées. Comparez les octets de ce fichier ou son empreinte SHA-256 avec votre original. Le nom
de fichier original est une métadonnée, pas un chemin sur le disque. Téléverser à nouveau après un
succès crée une ressource distincte, car le client supprime l’enregistrement de son URL terminée.
Les téléversements existants restent sur le disque.
Le pourcentage indique les octets envoyés par le navigateur et peut reculer après une récupération. La fin du téléversement est signalée par le callback de succès de tus, et non par l’atteinte de 100 % sur la barre de progression. Aucun des deux ne signifie qu’une application de production a validé le fichier, l’a soumis à une analyse de sécurité ou l’a publié.
Configuration CORS
Utilisez 127.0.0.1 de façon cohérente : localhost est une origine différente. Le récepteur
autorise l’origine Angular sur le port 4200 et fournit les en-têtes de requête tus ainsi que les
en-têtes de réponse exposés, y compris Location et Upload-Offset. Si un port est occupé,
choisissez-en un autre et mettez à jour l’URL correspondante dans server.ts, upload.service.ts ou le
script start. Les erreurs CORS apparaissent souvent côté client comme des échecs réseau ;
inspectez la réponse à la requête de pré-vérification (preflight) ainsi que la requête de
téléversement.
CORS n’est pas un mécanisme d’authentification. Ce récepteur sert aux tests locaux et n’effectue
aucune autorisation des utilisateurs. tus-js-client utilise son propre transport XMLHttpRequest ;
le HttpInterceptor d’Angular n’attache donc pas d’identifiants à ces requêtes. Pour une intégration
protégée, utilisez l’option documentée headers ou onBeforeRequest du client, autorisez tout en-tête
ajouté côté serveur et vérifiez l’autorisation de chaque opération de téléversement. Gardez les
identifiants hors de cet exemple et ne les envoyez qu’à des URL de téléversement de confiance.
Bonnes pratiques et points à considérer
L’empreinte navigateur du client utilise les métadonnées du fichier et le point de terminaison, et non un hachage du contenu. Sélectionnez de nouveau l’original non modifié. Une application multicomptes a besoin d’un stockage d’URL propre à chaque compte et d’une politique réfléchie en cas de correspondances multiples ; sélectionner la première correspondance ne constitue pas une vérification de propriété.
Si l’URL enregistrée renvoie 404 ou 410, la version 4.3.1 se rabat sur la création d’un
nouveau téléversement au point de terminaison configuré. Les octets absents du serveur ne peuvent pas
être récupérés depuis le stockage du navigateur. Ce serveur local ne planifie pas d’expiration et ne
supprime pas les fichiers abandonnés ; arrêtez les deux processus avec Ctrl+C une fois terminé, et
ne supprimez le répertoire uploads/ de la démo que lorsque vous n’avez plus besoin de ses données.
En cas d’échec de connexion, rétablissez le récepteur ou le réseau, puis réessayez. En cas de requête refusée, réglez d’abord la question de l’autorisation. Le formulaire garde la sélection de fichier et tout nouveau démarrage désactivés jusqu’à ce qu’un téléversement actif se termine, échoue ou achève sa mise en pause. Si la mise en pause échoue, réessayez Pause avant de démarrer quoi que ce soit d’autre. Le serveur applique la limite de taille indépendamment du navigateur ; en production, la validation du contenu et le nettoyage du stockage relèvent également du serveur.
