Téléverser des fichiers en Angular : progression et serveur local
Envoyez un fichier sélectionné sous forme de FormData via le HttpClient d’Angular, et n’affichez la réussite que
lorsque le serveur l’accepte. Ce tutoriel construit un outil de téléversement d’un seul fichier avec
progression, erreurs visibles et un récepteur Node.js exécutable. Le récepteur vérifie la requête et
renvoie une somme de contrôle ; il n’enregistre pas le fichier.
Configuration requise
Utilisez Node.js 26.8.2, Corepack avec Yarn 4.12.0 et un navigateur moderne. L’exemple fixe les versions d’Angular 22.1.7, d’Angular CLI/build 22.1.8 et de TypeScript 6.0.3. Angular 22 est une version prise en charge ; son tableau de compatibilité détaille les exigences relatives à Node et à TypeScript. Il s’agit d’une application navigateur autonome, sans rendu côté serveur. L’exemple a été testé sur macOS avec Chromium 145. Les commandes utilisent un shell POSIX, comme Bash sous macOS, Linux ou WSL.
Exécutez ceci depuis le répertoire parent dans lequel vous souhaitez créer un nouveau dossier
angular-upload-demo. mkdir refuse une destination existante, et chaque commande dépendante ne s’exécute
que si la précédente réussit. Conservez le fichier de verrouillage généré afin que les
installations ultérieures utilisent la même résolution des dépendances.
(
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
)
Une fois ces commandes réussies, ouvrez angular-upload-demo dans votre éditeur. Tous les noms de fichiers
ci-dessous sont relatifs à ce dossier. Remplacez uniquement le fichier src/main.ts généré ; créez les
trois autres fichiers sous les noms indiqués.
Utiliser l’encodage multipart du navigateur
Le champ de sélection de fichier natif vous fournit un File ; FormData.append('file', file) place ses octets dans le
champ multipart attendu par notre serveur. Ne définissez pas Content-Type. Le navigateur ajoute le
délimiteur multipart (boundary), comme l’explique le guide FormData de MDN.
Aucun FormsModule ni ngModel n’est nécessaire pour l’événement change de ce champ.
La progression du téléversement nécessite le backend XHR d’Angular. Configurez provideHttpClient(withXhr()), puis
demandez les événements avec observe: 'events' et reportProgress: true. Le backend Fetch par défaut ne signale pas
la progression du téléversement. Angular documente à la fois la
configuration XHR et la
séquence d’événements de requête.
Un événement de progression décrit la transmission du corps de la requête, y compris l’encadrement
multipart. Même un transfert à 100 % ne signifie pas que le serveur a accepté le fichier.
Ajouter le composant de téléversement
Remplacez src/main.ts par ce point d’entrée complet. Les signaux mettent à jour la vue à l’arrivée
des événements HTTP. Tant qu’une requête est en cours, les deux contrôles sont désactivés et les
gestionnaires se protègent contre les appels répétés. Une requête échouée conserve le fichier
sélectionné pour une nouvelle tentative manuelle ; une réussite vide le champ natif afin que vous
puissiez sélectionner de nouveau le même fichier.
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)
Étiqueter les contrôles et les retours
Créez src/upload.html. Le champ et le bouton natifs prennent en charge la navigation au clavier. La
zone d’état annonce les retours sans déplacer le focus. Si le total est inconnu, la barre de
progression reste indéterminée ; un petit téléversement local peut se terminer trop vite pour
afficher des pourcentages intermédiaires.
<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>
Exécuter un récepteur qui vérifie le téléversement
Créez server.ts à la racine du projet. Il accepte POST /api/upload avec exactement un champ de fichier
multipart nommé file, rejette les fichiers vides avec HTTP 422 et limite les fichiers à
1 MiB. Le plugin multipart applique les limites de taille et de nombre de
parties ; toBuffer() consomme le fichier avant que la réponse ne soit produite.
Le récepteur renvoie le nom du fichier reçu, le nom du champ, le nombre d’octets et la somme de contrôle SHA-256. Il met le fichier en mémoire tampon et le supprime après la requête. Il n’a aucun chemin de stockage et n’écrase donc jamais un fichier existant. Cette démo locale accepte tous les types de fichiers et n’écoute que sur l’adresse de bouclage.
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}`)
Créez proxy.json à la racine du projet afin que le serveur de développement Angular transmette les
requêtes /api à Node. Le navigateur utilise sa propre origine ; cet exemple ne nécessite donc
aucune configuration CORS.
{
"/api/**": {
"target": "http://127.0.0.1:3000"
}
}
Depuis le répertoire parent utilisé pour la configuration, démarrez le récepteur dans un terminal :
cd angular-upload-demo && node server.ts
Dans un second terminal, en partant également de ce répertoire parent, compilez et servez l’application :
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
Ouvrez http://127.0.0.1:4200. Si l’un des ports est occupé, utilisez un autre port libre : définissez
PORT au démarrage de Node et mettez à jour la cible du proxy en conséquence ; modifiez le
--port d’Angular si nécessaire. Arrêtez les deux serveurs avec Ctrl+C lorsque vous avez
terminé. Une nouvelle compilation remplace la sortie dist/ de cette application générée ; les
fichiers téléversés n’y sont jamais écrits.
Vérifier séparément la réussite et l’échec
Sélectionnez un fichier non vide de moins de 1 MiB et activez Upload. Le message final doit
indiquer « Server accepted » et préciser que la démo ne l’a pas enregistré. Dans le panneau Réseau
du navigateur, inspectez la réponse /api/upload : field doit valoir file, et bytes et sha256
décrivent le contenu du fichier reçu, sans l’encadrement multipart. Sélectionnez de nouveau le même
fichier pour envoyer une autre requête.
Essayez un fichier vide pour voir le message HTTP 422. Un fichier de plus de 1 MiB doit être rejeté dans l’interface avant l’envoi ; le serveur applique aussi sa propre limite pour les appelants qui contournent l’interface. Tant qu’un téléversement est en cours, le sélecteur de fichier et le bouton Upload restent désactivés, y compris une fois que la transmission atteint 100 %. Une réponse du serveur retardée ou rejetée ne doit jamais se transformer en message de téléversement accepté.
Pour tester un échec de connexion, laissez la page se charger, passez le panneau Réseau du navigateur en mode hors ligne, puis lancez le téléversement. Rétablissez la connexion avant de réessayer. Arrêter uniquement le récepteur Node peut plutôt produire une erreur HTTP provenant du proxy de développement. Une erreur réseau ou un délai d’expiration ne permet pas de savoir si le serveur a traité la requête avant l’échec de la connexion ; cet exemple ne réessaie pas automatiquement.
La vérification de la taille est une commodité pour l’utilisateur, pas une barrière de sécurité. Si
votre application a besoin de téléversements JPEG, PNG ou PDF, l’attribut accept du champ peut
guider la sélection, mais cet attribut comme File.type ne sont que des indications contrôlées par
le client. Validez le contenu réel sur le serveur récepteur. MDN explique pourquoi
les restrictions du sélecteur de fichiers ne valident pas les téléversements.
Un récepteur déployé a également besoin de l’authentification, de l’autorisation, de la protection
contre la falsification de requêtes et de la politique de stockage de votre application ; le
récepteur local ne les fournit pas.
Ajouter la reprise si vous en avez besoin
Cette requête multipart recommence depuis le début après un transfert échoué. Si vous devez reprendre des téléversements interrompus, consultez téléversements de fichiers reprenables en Angular avec tus-js-client. Pour un sélecteur de fichiers et une interface de téléversement prêts à l’emploi, le guide Uppy pour Angular officiel documente ses composants et les versions compatibles. Associez cette interface à un plugin de téléversement et à un récepteur qui utilise le même protocole.
