Fortsetzbare Datei-Uploads in Angular mit RxJS
Die Aufteilung in Blöcke begrenzt die Datenmenge, die nach einem Verbindungsabbruch erneut gesendet werden muss. Dieses Beispiel implementiert den Upload einer einzelnen Datei innerhalb der Seite, mit Pausieren, Fortsetzen, Abbrechen und einer Fortschrittsanzeige für die gesamte Datei. Es behält den Angular/RxJS-Ansatz des ursprünglichen Artikels bei und aktualisiert die Einrichtung auf Angular 22.1.6.
Warum blockweise und fortsetzbare Uploads?
Eine unterbrochene Anfrage kann den Server erreicht haben, auch wenn ihre Antwort nie beim Browser ankam. Beim Fortsetzen muss daher der vom Server bestätigte Offset abgerufen werden. Ein lokaler Blockzähler allein kann nicht feststellen, welche Bytes gespeichert wurden.
Dies ist eine vollständige Client-Integration, kein Upload-Server. Stellen Sie vor der Ausführung folgende API unter demselben Ursprung bereit, abgesichert durch Ihre vorhandene Middleware für Anmeldung und CSRF:
| Anfrage | Erforderliches Verhalten |
|---|---|
PUT /api/uploads/{id} mit {size} | Reservieren Sie die UUID für den authentifizierten Benutzer oder geben Sie ihren bestehenden Zustand als {offset, complete} zurück. Binden Sie sie dauerhaft an diesen Eigentümer und diese Größe; weisen Sie eine widersprüchliche Wiederverwendung zurück. |
PUT /api/uploads/{id}/bytes | Akzeptieren Sie einen unverarbeiteten Anfrageinhalt mit Content-Range: bytes start-end/total. Prüfen Sie unter einer Upload-Sperre die exakte Länge, die Gesamtgröße, das Kontingent und den erwarteten Offset; speichern Sie die Bytes dauerhaft, bevor Sie dies mit 204 bestätigen. |
POST /api/uploads/{id}/complete | Prüfen Sie alle Bytes und die Einhaltung der Inhaltsrichtlinie und veröffentlichen Sie anschließend einmalig in einem atomaren Vorgang. Wiederholungen liefern dieselbe Erfolgsmeldung. |
DELETE /api/uploads/{id} | Brechen Sie einen noch nicht abgeschlossenen Upload unter derselben Sperre idempotent ab; weisen Sie den Abbruch eines bereits veröffentlichten Uploads zurück. |
Jeder Vorgang muss Eigentümer, Sitzungsauthentifizierung und CSRF prüfen. Legen Sie ein Dateilimit
von 100 MiB, ein Blocklimit von 1 MiB, Speicher- und Anfragelimits pro Benutzer sowie eine Ablauffrist
für aufgegebene Sitzungen fest. Halten Sie Teildaten privat. Unterbinden Sie Weiterleitungen und
ursprungsübergreifende Zugriffe am Gateway. Angular sendet den Header
X-XSRF-TOKEN bei relativen URLs für Änderungen, wenn der Server das Cookie
XSRF-TOKEN bereitstellt; der Server muss ihn tatsächlich validieren.
Ein Auth Secret von Transloadit gehört nicht in diesen Browsercode.
Ihr Angular-Projekt einrichten
Verwenden Sie Node.js 24.15 oder neuer aus der unterstützten Versionslinie Node 24 und erstellen Sie das Grundgerüst einer eigenständigen Anwendung:
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
Die Beispiele verwenden explizite Importe mit .ts. Ergänzen Sie diese
Option in der generierten Datei tsconfig.json im Stammverzeichnis, damit TypeScript
diese Erweiterungen bei der Ausgabe von JavaScript umschreibt:
{
"compilerOptions": {
"rewriteRelativeImportExtensions": true
}
}
Verwenden Sie die folgende Datei src/app/app.config.ts. Das standardmäßige Fetch-Backend
von Angular meldet keinen Upload-Fortschritt; diese reine Browseranwendung wählt explizit XHR aus.
Weitere Informationen finden Sie in der Angular-Dokumentation zur
HTTP-Einrichtung und zum
Lebenszyklus von Anfragen.
import { provideHttpClient, withXhr } from '@angular/common/http'
import type { ApplicationConfig } from '@angular/core'
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withXhr())],
}
Stellen Sie die erstellte Anwendung und die API unter demselben Ursprung bereit. Leiten Sie für die
lokale Entwicklung /api über den CLI-Proxy an Ihr Entwicklungs-Backend
weiter; verweisen Sie dabei nicht auf die Produktionsumgebung.
Die Datei in Blöcke aufteilen
Der folgende Dienst schneidet jeweils einen Block aus. end ist bei
File.slice exklusiv, im Bereichsheader jedoch inklusiv. Eine Datei mit
2.097.155 Bytes endet daher mit bytes 2097152-2097154/2097155 und nicht mit einer Gesamtgröße,
die aus dem kurzen letzten Block abgeleitet wird.
Eine RxJS-Pipeline für blockweise Uploads erstellen
Speichern Sie den vollständigen Dienst in src/app/file-upload.service.ts.
concatMap hält jeweils eine Anfrage aktiv. Bei jedem Fortsetzen wird der
Serverzustand ermittelt, bevor weitere Bytes gesendet werden. Fehler bleiben beobachtbar und die
ausgewählte Datei bleibt für einen erneuten Fortsetzungsversuch verfügbar.
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.',
})
}
}
Pausieren, Fortsetzen und Abbrechen hinzufügen
Das Beenden des Abonnements bricht die aktive HTTP-Anfrage ab. Bytes, die der Server bereits angenommen hat, werden dadurch nicht rückgängig gemacht. Beim Pausieren bleiben File und UUID im Arbeitsspeicher; beim Fortsetzen wird der Offset abgeglichen. Durch Neuladen geht dieser Zustand verloren, daher setzt dieses Beispiel Uploads nach einem Neuladen der Seite nicht fort. Wenn Sie eine andere Datei auswählen, wird die alte Sitzung aufgegeben. Diese muss der Server gemäß seiner Ablaufrichtlinie entfernen. Beim Abbrechen wird versucht, die Daten auf dem Server zu bereinigen; bei einem Netzwerkfehler oder einem Wettlauf mit der Veröffentlichung muss die Upload-Liste des Servers geprüft werden. Solange die Bereinigung aussteht, sind Pausieren, Fortsetzen, Abbrechen und Dateiauswahl deaktiviert und werden vom Dienst ignoriert. Das Zerstören der Komponente unterbricht die Bereinigung und meldet den Abbruch als unbestätigt.
Den Fortschritt verfolgen
Ersetzen Sie src/app/app.ts durch diese eigenständige Stammkomponente. Die
integrierte Kontrollflusssyntax benötigt kein NgIf;
AsyncPipe wird explizit importiert. Der Fortschritt bleibt unter 100, bis die
Veröffentlichung bestätigt ist. Beim Fortsetzen kann der Fortschritt zurückgehen, wenn nicht
bestätigte Bytes erneut gesendet werden müssen.
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()
}
}
Verwenden Sie diese Datei src/main.ts und behalten Sie die generierte Datei
index.html mit ihrem Element app-root bei:
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.'))
Wenn Sie ein standardisiertes Protokoll für fortsetzbare Uploads anstelle dieser anwendungsspezifischen Vereinbarung bevorzugen, ziehen Sie tus und Uppy in Betracht. Weder clientseitige Validierung noch ein Fortschrittsbalken ersetzen die serverseitige Autorisierung und die garantierte dauerhafte Speicherung bestätigter Daten.
