Fortsetzbare Datei-Uploads in Angular
Mit tus-js-client und einem tus-Server setzen Sie einen Angular-Datei-Upload ab den
Bytes fort, die der Server bereits hat. Dieses Beispiel erstellt ein eigenständiges Angular-Formular
und einen lokalen Empfänger, der auf Festplatte speichert. Sie können eine Übertragung pausieren,
die Seite neu laden, dieselbe Datei auswählen und den Upload abschließen.
Warum fortsetzbare Uploads?
Das tus-Protokoll erstellt mit POST eine
Upload-URL, sendet Bytes mit Anfragen vom Typ PATCH und ermittelt beim
Fortsetzen mit einer Anfrage vom Typ HEAD den gespeicherten Wert von
Upload-Offset. Der Offset des Servers bestimmt, wo es weitergeht. Ein
Fortschrittsbalken im Browser allein zeigt nicht, welche Bytes eine unterbrochene Verbindung
überstanden haben.
Der Browser speichert die Upload-URL, keine Kopie der Datei. Nach dem Neuladen muss der Nutzer die unveränderte Originaldatei im selben Browserprofil und unter demselben Seitenursprung erneut auswählen. Werden Website-Daten gelöscht, die Datei verändert oder die gespeicherten Upload-Daten auf dem Server verloren, kann ein Neustart von vorn nötig sein.
Ihr Angular-Projekt einrichten
Verwenden Sie für diese Anleitung Node.js 24.15.0 und Yarn 4.12.0. Die folgenden Paketversionen wurden unter Linux mit Chromium 145 gebaut und getestet. Angular hat eigene Kompatibilitätsanforderungen für Node.js und TypeScript; diese festgelegten Versionen beschreiben die getestete Konfiguration.
Erstellen Sie im gewünschten Arbeitsverzeichnis ein neues Projektverzeichnis für das Beispiel. Diese Bash-Befehle verweigern die Ausführung bei einem vorhandenen Verzeichnis oder einem übergeordneten Yarn-Plug’n’Play-Projekt, dessen Loader den Angular-Build beeinflussen kann. Ihre Shell bleibt dabei im ursprünglichen Verzeichnis:
(
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
)
Speichern Sie jede der folgenden Dateien in resumable-upload-demo. Die lokale Lockdatei
und die Yarn-Konfiguration sorgen für eine eigene Abhängigkeitsinstallation dieses Beispiels.
package.json legt die direkten Abhängigkeiten und das Browser-Build-Ziel fest:
{
"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"
}
}
Speichern Sie angular.json, um Build und Entwicklungsserver mit
main.ts zu verbinden:
{
"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" }
}
}
}
}
}
Speichern Sie tsconfig.json. Die Node-Typen erfüllen die Anforderungen der
geteilten Deklarationen des Client-Pakets; sie fügen dem Browser keine Node-APIs hinzu:
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"experimentalDecorators": true,
"strict": true,
"types": ["node"],
"lib": ["ES2022", "DOM"],
"rewriteRelativeImportExtensions": true
},
"angularCompilerOptions": { "strictTemplates": true },
"files": ["main.ts"]
}
Speichern Sie 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>
Den lokalen tus-Server hinzufügen
Speichern Sie server.ts. Der Node-tus-Server
verarbeitet das Protokoll und die CORS-Header; sein
FileStore speichert sowohl hochgeladene Bytes als auch Metadaten in
uploads/. Wenn Sie diesen Prozess aus demselben Verzeichnis neu starten,
bleiben sie erhalten. Dieser lokale Empfänger hat ein Dateigrößenlimit von 100 MiB und keine
Authentifizierung. Lassen Sie ihn an die Loopback-Adresse gebunden.
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
})
Einen Upload-Service erstellen
Speichern Sie upload.service.ts. Der Service setzt den Belegtstatus, bevor er
nach gespeicherten URLs sucht, ignoriert Callbacks eines gestoppten Uploads und wartet auf
abort(), bevor er einen weiteren Start zulässt. Seine Signale benachrichtigen
Angular, wenn die asynchronen Callbacks des Clients die Oberfläche aktualisieren, auch bei
Änderungserkennung ohne Zone.js.
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.')
})
}
}
Die Client-API definiert abort() als
Pausieren; abort(true) fordert stattdessen eine Löschung an. Dieses Beispiel
verwirft die gestoppte Client-Instanz und sucht beim nächsten Start nach ihrer URL. Deshalb setzt
sowohl das Fortsetzen per Schaltfläche als auch die Wiederaufnahme nach dem Neuladen voraus, dass
der Browser die URL speichert. Es wählt den ersten gespeicherten Treffer aus, was für diese
Einzelnutzer-Demo geeignet ist.
Die Blöcke von 1 MiB erleichtern das Untersuchen einzelner Anfragen. Für die Fortsetzbarkeit sind sie nicht erforderlich; der Client verwendet standardmäßig eine unbegrenzte Blockgröße. Seine Retry-Richtlinie begrenzt aufeinanderfolgende Retries und setzt das Kontingent bei Fortschritt zurück. Sobald die Retries aufhören, kann der Nutzer es mit Upload erneut versuchen.
Die Upload-Komponente erstellen
Speichern Sie main.ts. Dies ist sowohl die eigenständige Komponente als auch
der Einstiegspunkt der Anwendung. Es muss also kein Stammtemplate oder Modul mehr eingebunden
werden. Die Service-Instanz gehört zur Komponente und wird aufgeräumt, wenn Angular diese zerstört.
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.')
})
Ausführen und eine fortgesetzte Übertragung überprüfen
Sobald alle sieben Dateien gespeichert sind, installieren und bauen Sie das Projekt vom übergeordneten Verzeichnis aus:
(cd resumable-upload-demo && yarn install && yarn build)
Starten Sie nach einem erfolgreichen Build den Empfänger in einem Terminal und den Angular-Entwicklungsserver in einem anderen, beide aus demselben übergeordneten Verzeichnis:
(cd resumable-upload-demo && yarn server)
(cd resumable-upload-demo && yarn start)
Öffnen Sie http://127.0.0.1:4200. Wählen Sie über
Choose file eine Datei aus, die kleiner als
100 MiB ist, und wählen Sie dann
Upload. Verwenden Sie eine mehrere MiB große
Datei und drosseln Sie das Netzwerk im Browser, damit genug Zeit bleibt,
Pause auszuwählen. Warten Sie auf
Paused. Select Upload to resume., bevor Sie fortfahren.
Untersuchen Sie die Netzwerkanfragen des Browsers. Laden Sie nach mindestens einer erfolgreichen
Anfrage vom Typ PATCH die Seite neu, wählen Sie dieselbe unveränderte Datei
und dann erneut Upload aus. Sie sollten eine
Anfrage vom Typ HEAD an die bisherige Upload-URL sehen, gefolgt von einer
Anfrage vom Typ PATCH, deren Upload-Offset dem Offset
entspricht, den HEAD zurückgegeben hat. Dieser Offset sollte größer als
null sein. Eine neue Anfrage vom Typ POST bedeutet, dass der Client einen
neuen Upload erstellt hat, statt den alten fortzusetzen.
Warten Sie auf Upload complete.. Das letzte Segment
der angezeigten Upload-URL identifiziert die Datei in resumable-upload-demo/uploads/; eine
benachbarte Datei mit der Endung .json enthält Metadaten. Vergleichen Sie
die Bytes oder den SHA-256-Hash der hochgeladenen Datei mit Ihrem Original. Der ursprüngliche
Dateiname ist ein Metadatum, kein Festplattenpfad. Ein erneuter Upload nach erfolgreichem Abschluss
erstellt eine separate Ressource, weil der Client seinen abgeschlossenen URL-Eintrag entfernt.
Vorhandene Uploads bleiben auf der Festplatte.
Die Prozentanzeige zeigt die vom Browser gesendeten Bytes und kann nach einer Wiederaufnahme zurückgehen. Der Abschluss wird durch den Erfolgs-Callback von tus bestimmt, nicht dadurch, dass der Fortschrittsbalken 100 % erreicht. Keines von beidem bedeutet, dass eine Produktionsanwendung die Datei validiert, auf Schadsoftware gescannt oder veröffentlicht hat.
CORS-Konfiguration
Verwenden Sie durchgängig 127.0.0.1: localhost ist ein
anderer Ursprung. Der Empfänger erlaubt den Angular-Ursprung auf Port 4200 und liefert die
Anfrageheader sowie die offengelegten Antwortheader für tus, einschließlich
Location und Upload-Offset. Wenn ein Port belegt ist, wählen
Sie einen anderen und aktualisieren Sie die passende URL in server.ts,
upload.service.ts oder im Skript start. CORS-Fehler erscheinen
dem Client oft als Netzwerkfehler; untersuchen Sie sowohl die Preflight-Antwort als auch die
Upload-Anfrage.
CORS ist keine Authentifizierung. Dieser Empfänger dient lokalen Tests und autorisiert keine
Nutzer. tus-js-client verwendet einen eigenen XMLHttpRequest-Transport. Deshalb
fügt Angular mit HttpInterceptor diesen Anfragen keine Zugangsdaten hinzu. Verwenden
Sie für eine geschützte Integration die dokumentierte Client-Option
headers oder onBeforeRequest, erlauben Sie alle zusätzlichen
Header auf dem Server und autorisieren Sie jeden Upload-Vorgang. Hinterlegen Sie keine Zugangsdaten
in diesem Beispiel und senden Sie sie nur an vertrauenswürdige Upload-URLs.
Bewährte Verfahren und wichtige Hinweise
Der Browser-Fingerabdruck des Clients verwendet Dateimetadaten und den Endpunkt, keinen Inhalts-Hash. Wählen Sie das unveränderte Original erneut aus. Eine Anwendung mit mehreren Konten benötigt eine kontogebundene URL-Speicherung und eine bewusst festgelegte Strategie für mehrere Treffer; die Auswahl des ersten Treffers ist keine Eigentumsprüfung.
Wenn die gespeicherte URL 404 oder 410
zurückgibt, erstellt Version 4.3.1 ersatzweise einen neuen Upload am konfigurierten Endpunkt.
Fehlende Bytes auf dem Server lassen sich nicht aus dem Browserspeicher wiederherstellen. Dieser
lokale Server plant keinen Ablaufzeitpunkt und entfernt keine aufgegebenen Dateien. Stoppen Sie
beide Prozesse nach Abschluss mit Ctrl+C und entfernen Sie das Demo-Verzeichnis
uploads/ erst, wenn Sie seine Daten nicht mehr benötigen.
Stellen Sie bei Verbindungsfehlern den Empfänger oder die Netzwerkverbindung wieder her und versuchen Sie es erneut. Klären Sie bei einer abgelehnten Anfrage zuerst die Autorisierung. Das Formular lässt die Dateiauswahl und weitere Starts deaktiviert, bis ein aktiver Upload abgeschlossen ist, fehlschlägt oder vollständig pausiert wurde. Wenn das Pausieren fehlschlägt, versuchen Sie Pause erneut, bevor Sie etwas anderes starten. Der Server setzt das Größenlimit unabhängig vom Browser durch; Inhaltsvalidierung im Produktionsbetrieb und Speicherbereinigung gehören ebenfalls auf den Server.
