Fortsetzbare Datei-Uploads in Angular mit RxJS
Setzen Sie einen unterbrochenen Angular-Upload ab den Bytes fort, die der Empfänger bestätigt hat, auch wenn seine letzte Antwort den Browser nicht erreicht hat. Diese Anleitung erstellt ein Angular/RxJS-Formular für einzelne Dateien, einen lokalen Node.js-Empfänger und den verbindenden Proxy. Nach erfolgreicher Veröffentlichung können Sie die gespeicherte Datei mit dem Original vergleichen.
Warum Uploads in Chunks aufteilen und fortsetzen?
Eine unterbrochene Anfrage kann den Server erreicht haben, auch wenn ihre Antwort nie im Browser ankam. Beim Fortsetzen wird daher der bestätigte Offset des Servers gelesen, statt einem lokalen Chunk-Zähler zu vertrauen. Dieses Beispiel sendet Chunks von 1 MiB nacheinander und unterstützt Pausieren, Fortsetzen und Abbrechen innerhalb derselben Seite. Beim Neuladen gehen das ausgewählte File und die UUID verloren; beim Neustart des Empfängers geht dessen Sitzungszustand verloren. Nach keiner dieser Aktionen lässt sich der vorherige Upload hier fortsetzen.
Wenn Sie nur eine einzelne Multipart-Anfrage benötigen, nutzen Sie unsere Anleitung für einfache Angular-Uploads. Für standardisierte fortsetzbare Uploads, die auch nach dem Neuladen der Seite funktionieren, nutzen Sie das separate Angular-tus-Tutorial.
Ihr Angular-Projekt einrichten
Verwenden Sie Bash, Node.js 24.15.0, Corepack mit Yarn 4.12.0 und einen modernen Browser. Die folgenden Versionsvorgaben verwenden Angular 22.2.0, TypeScript 6.0.3 und RxJS 7.8.2. Dieses reine Browserbeispiel wurde außerdem mit Node.js 26.8.1 und Chromium 145 unter Linux getestet. Beachten Sie die Kompatibilitätsanforderungen von Angular, wenn Sie diese Versionen ändern.
Fügen Sie diesen Block ausgehend vom gewünschten übergeordneten Verzeichnis ein. Er verweigert die Ausführung, wenn das Ziel bereits existiert oder sich in einem Yarn-Plug’n’Play-Projekt befindet, dessen Loader den Angular-Build beeinflussen kann. Falls ein Schritt fehlschlägt, lässt die Subshell Ihr aktuelles Verzeichnis und Ihre Shell-Optionen unverändert:
(
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 chunked-upload-app &&
cd chunked-upload-app &&
printf 'nodeLinker: node-modules\nenableGlobalCache: false\n' > .yarnrc.yml &&
touch yarn.lock
)
Speichern Sie alle folgenden Dateien in chunked-upload-app und erstellen Sie
src/app/ in Ihrem Editor.
package.json legt die Versionen der direkten Abhängigkeiten fest; behalten Sie die
bei der Installation erzeugte Lockdatei bei:
{
"name": "chunked-upload-app",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": {
"build": "ng build",
"start": "ng serve --host 127.0.0.1 --port 4200"
},
"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",
"rxjs": "7.8.2",
"tslib": "2.8.1",
"typescript": "6.0.3"
}
}
Speichern Sie angular.json:
{
"version": 1,
"cli": { "analytics": false, "cache": { "enabled": false } },
"projects": {
"demo": {
"projectType": "application",
"root": ".",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.json",
"index": "src/index.html",
"outputPath": "dist",
"styles": [],
"assets": []
}
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"buildTarget": "demo:build",
"proxyConfig": "proxy.conf.json"
}
}
}
}
}
}
Speichern Sie tsconfig.json. Explizite Importe mit .ts
werden beim Kompilieren umgeschrieben:
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"experimentalDecorators": true,
"strict": true,
"lib": ["ES2022", "DOM"],
"types": [],
"rewriteRelativeImportExtensions": true
},
"angularCompilerOptions": { "strictTemplates": true },
"files": ["src/main.ts"]
}
Speichern Sie src/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>Chunked Angular upload</title>
</head>
<body><app-root></app-root></body>
</html>
Speichern Sie proxy.conf.json. Der Vite-basierte Server der CLI benötigt
/api/**, um verschachtelte Upload-Routen zu erfassen;
siehe die Proxy-Konfiguration von Angular.
Der Browser verwendet relative URLs, sodass Anfragen beim Ursprung der Anwendung bleiben:
{
"/api/**": { "target": "http://127.0.0.1:4317", "secure": false }
}
Speichern Sie src/app/app.config.ts. Das standardmäßige Fetch-Backend von Angular gibt
keinen Upload-Fortschritt aus; diese Anwendung wählt explizit XHR.
Mit dem Demo-Header kann der Empfänger gewöhnliche websiteübergreifende Formularübermittlungen
ablehnen. Er ist öffentlich und kein Authentifizierungsnachweis:
import { provideHttpClient, withInterceptors, withXhr } from '@angular/common/http'
import type { ApplicationConfig } from '@angular/core'
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withXhr(),
withInterceptors([
(request, next) => next(request.clone({ setHeaders: { 'X-Demo-Upload': '1' } })),
]),
),
],
}
Den lokalen Empfänger hinzufügen
Speichern Sie receiver.mts. Dies ist ein Loopback-Empfänger für die Entwicklung
ohne Anmeldung oder Zuordnung zu einem Benutzer. Er akzeptiert Anfragen mit dem Demo-Header und,
sofern übermittelt, dem konfigurierten Browser-Ursprung. Jeder lokale Prozess kann diesen Header
senden; machen Sie diesen Server nicht öffentlich zugänglich und leiten Sie ihn nicht über einen
Proxy an einen öffentlichen Dienst weiter.
| Anfrage | Verhalten des lokalen Empfängers |
|---|---|
PUT /api/uploads/{id} mit {size} | Erstellt eine Sitzung oder gibt bei gleicher UUID und Größe {offset, complete} zurück. |
PUT /api/uploads/{id}/bytes | Validiert Bereich und Anfragekörper, schreibt den Chunk und erzwingt dessen Speicherung auf dem Datenträger, bestätigt dann mit 204. |
POST /api/uploads/{id}/complete | Erfordert jedes Byte, veröffentlicht uploads/{id}.bin, ohne eine vorhandene Datei zu ersetzen, und gibt dann 204 zurück. |
DELETE /api/uploads/{id} | Entfernt eine Teildatei und behält eine Abbruchmarkierung bei; gibt 409 zurück, falls die Veröffentlichung dem Abbruch zuvorkam. |
Zustandsprüfungen und Änderungen auf dem Datenträger laufen synchron in einem Prozess, nachdem der jeweilige Anfragekörper gelesen wurde. Ein verspäteter Chunk kann eine abgebrochene Sitzung nicht neu erstellen. Jede akzeptierte Datei ist zwischen 1 Byte und 100 MiB groß, jeder Chunk höchstens 1 MiB. Die Demo behält bis zum Neustart höchstens 32 IDs bei, einschließlich abgebrochener und abgeschlossener Uploads. Sie speichert beliebige Bytes ohne Inhaltsprüfung.
import {
closeSync,
fsyncSync,
ftruncateSync,
linkSync,
mkdirSync,
openSync,
rmSync,
statSync,
writeSync,
} from 'node:fs'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { resolve } from 'node:path'
type Session = { size: number; offset: number; complete: boolean; canceled: boolean }
class RequestError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
const maxFile = 100 * 1024 * 1024
const maxChunk = 1024 * 1024
const sessions = new Map<string, Session>()
const directory = resolve('uploads')
const port = Number(process.env.PORT ?? '4317')
const appOrigin = process.env.APP_ORIGIN ?? 'http://127.0.0.1:4200'
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT must be between 1 and 65535.')
}
if (new URL(appOrigin).origin !== appOrigin || new URL(appOrigin).hostname !== '127.0.0.1') {
throw new Error('APP_ORIGIN must be a loopback URL such as http://127.0.0.1:4200.')
}
mkdirSync(directory, { recursive: true, mode: 0o700 })
function reply(response: ServerResponse, status: number, value?: unknown): void {
response.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
response.end(value === undefined ? undefined : JSON.stringify(value))
}
async function body(request: IncomingMessage, limit: number): Promise<Buffer> {
const parts: Buffer[] = []
let length = 0
// Keep the socket available for a 413 response when stopping the body reader early.
for await (const part of request.iterator({ destroyOnReturn: false })) {
if (!(part instanceof Buffer)) throw new RequestError(400, 'Expected binary request data.')
length += part.length
if (length > limit) {
request.resume()
throw new RequestError(413, 'Request body is too large.')
}
parts.push(part)
}
return Buffer.concat(parts)
}
function reserve(id: string, size: number): Session {
const existing = sessions.get(id)
if (existing) {
if (existing.canceled) throw new RequestError(410, 'Upload was canceled.')
if (existing.size !== size) throw new RequestError(409, 'Upload size conflicts.')
return existing
}
if (sessions.size >= 32) throw new RequestError(429, 'Restart the demo receiver after 32 IDs.')
const file = openSync(resolve(directory, `${id}.part`), 'wx', 0o600)
closeSync(file)
const session = { size, offset: 0, complete: false, canceled: false }
sessions.set(id, session)
return session
}
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
if (
request.headers['x-demo-upload'] !== '1' ||
(request.headers.origin !== undefined && request.headers.origin !== appOrigin)
) {
throw new RequestError(403, 'Use the local demo application.')
}
const match = /^\/api\/uploads\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})(\/bytes|\/complete)?$/u.exec(request.url ?? '')
if (!match) throw new RequestError(404, 'Unknown upload route.')
const id = match[1]
if (!id) throw new RequestError(400, 'Missing upload ID.')
const operation = match[2] ?? ''
const part = resolve(directory, `${id}.part`)
const output = resolve(directory, `${id}.bin`)
if (request.method === 'PUT' && operation === '') {
let input: unknown
try {
input = JSON.parse((await body(request, 512)).toString('utf8'))
} catch (error) {
if (error instanceof RequestError) throw error
throw new RequestError(400, 'Expected JSON containing size.')
}
if (
typeof input !== 'object' || input === null || !('size' in input) ||
typeof input.size !== 'number' || !Number.isSafeInteger(input.size) ||
input.size < 1 || input.size > maxFile
) {
throw new RequestError(400, 'Choose a file between 1 byte and 100 MiB.')
}
const session = reserve(id, input.size)
reply(response, 200, { offset: session.offset, complete: session.complete })
return
}
// Read first, then recheck state: cancellation can arrive while a body is pending.
const data = await body(request, operation === '/bytes' ? maxChunk : 512)
let session = sessions.get(id)
if (request.method === 'DELETE' && operation === '') {
if (session?.complete) throw new RequestError(409, 'Upload was already published.')
if (!session) {
if (sessions.size >= 32) throw new RequestError(429, 'Restart the demo receiver after 32 IDs.')
session = { size: 0, offset: 0, complete: false, canceled: true }
sessions.set(id, session)
}
session.canceled = true
rmSync(part, { force: true })
reply(response, 204)
return
}
if (!session || session.canceled) throw new RequestError(410, 'Upload is unavailable.')
if (request.method === 'PUT' && operation === '/bytes') {
const range = /^bytes (\d+)-(\d+)\/(\d+)$/u.exec(request.headers['content-range'] ?? '')
if (!range) throw new RequestError(400, 'Expected Content-Range.')
const start = Number(range[1])
const end = Number(range[2]) + 1
const total = Number(range[3])
if (
!Number.isSafeInteger(start) || !Number.isSafeInteger(end) ||
!Number.isSafeInteger(total) || session.complete || start !== session.offset ||
total !== session.size || end <= start || end > total || data.length !== end - start
) {
throw new RequestError(409, 'Range or body does not match the committed offset.')
}
// These synchronous mutations cannot interleave in this single Node process.
const file = openSync(part, 'r+')
try {
let written = 0
while (written < data.length) {
const count = writeSync(file, data, written, data.length - written, start + written)
if (count === 0) throw new Error('Disk write made no progress.')
written += count
}
ftruncateSync(file, end)
fsyncSync(file)
session.offset = end
} finally {
closeSync(file)
}
reply(response, 204)
return
}
if (request.method === 'POST' && operation === '/complete') {
if (!session.complete) {
if (session.offset !== session.size || statSync(part).size !== session.size) {
throw new RequestError(409, 'Upload still has missing bytes.')
}
// A hard link publishes on this filesystem without replacing an existing output.
linkSync(part, output)
session.complete = true
rmSync(part, { force: true })
console.log(`Published uploads/${id}.bin`)
}
reply(response, 204)
return
}
throw new RequestError(405, 'Unsupported upload operation.')
}
const server = createServer((request, response) => {
handle(request, response).catch((error: unknown) => {
if (response.destroyed) return
if (error instanceof RequestError) {
reply(response, error.status, { message: error.message })
return
}
console.error('Receiver could not process the upload.')
reply(response, 500, { message: 'Receiver could not process the upload.' })
})
})
server.requestTimeout = 30_000
server.on('error', () => {
console.error('Receiver failed to listen. Check whether its port is already in use.')
process.exitCode = 1
})
server.listen(port, '127.0.0.1', () => console.log(`Receiver ready on http://127.0.0.1:${port}`))
Der Empfänger ruft fsyncSync von Node auf,
bevor er seinen Offset im Arbeitsspeicher erhöht. So lässt sich eine Anfrage nach einer verlorenen
Antwort wiederholen, solange der Prozess läuft; eine Wiederherstellung nach einem Absturz ist das
nicht. Fertige Dateien mit der Endung .bin bleiben nach einem Neustart auf
dem Datenträger, ihre Sitzungsmetadaten jedoch nicht. Auch aufgegebene Dateien mit der Endung
.part bleiben erhalten, bis Sie sie bei gestopptem Empfänger entfernen.
Bewahren Sie diese Demo in einem eigenen Ordner auf.
Das Aufteilen von Dateien implementieren
Der folgende Service teilt jeweils einen Chunk ab. end ist für
File.slice exklusiv, im Bereichsheader jedoch inklusiv. Eine Datei mit
2.097.155 Bytes endet daher mit bytes 2097152-2097154/2097155, nicht mit einer Gesamtgröße, die aus
dem kurzen letzten Chunk abgeleitet wird.
Eine RxJS-Pipeline für Chunk-Uploads erstellen
Speichern Sie den vollständigen Service in src/app/file-upload.service.ts.
concatMap hält jeweils eine Anfrage aktiv. Bei jedem Fortsetzen wird zuerst der
Serverzustand ermittelt, bevor weitere Bytes gesendet werden. Fehler bleiben beobachtbar, und die
ausgewählte Datei bleibt für einen weiteren Versuch zum Fortsetzen 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) {
if (event.status !== 204) throw new Error('Chunk was not committed')
this.#offset = end
this.progress(file)
}
}),
)
}),
),
// Finalization runs once, including when the server already has every byte.
concatWith(
defer(() =>
this.#http
.post(`${url}/complete`, {}, { observe: 'response', timeout: 30_000 })
.pipe(
tap((response) => {
if (response.status !== 204) throw new Error('Publication was not confirmed')
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, { observe: 'response', timeout: 30_000 })
.pipe(
tap((response) => {
if (response.status !== 204) throw new Error('Cancellation was not confirmed')
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 Abbestellen des Abonnements bricht die aktive HTTP-Anfrage ab. Es macht bereits vom Server akzeptierte Bytes nicht rückgängig. Pausieren behält das File und die UUID im Arbeitsspeicher; Fortsetzen gleicht den Offset ab. Beim Neuladen geht dieser Zustand verloren, daher setzt dieses Beispiel Uploads nach dem Neuladen der Seite nicht fort. Pausieren Sie, bevor Sie eine andere Datei auswählen. Dadurch wird eine neue UUID verwendet, und die alte Teildatei bleibt auf dem Datenträger; brechen Sie zuerst ab, wenn der Empfänger sie entfernen soll. Beim Abbrechen wird versucht, auf dem Server aufzuräumen. Bei einem Netzwerkfehler oder einem Wettlauf mit der Veröffentlichung müssen Sie das Ausgabeverzeichnis und das Protokoll des Empfängers prüfen. Solange das Aufräumen aussteht, sind Pausieren, Fortsetzen, Abbrechen und die Dateiauswahl deaktiviert und werden vom Service ignoriert. Beim Zerstören der Komponente wird das Aufräumen unterbrochen und der Abbruch als unbestätigt gemeldet.
Den Fortschritt verfolgen
Ersetzen Sie src/app/app.ts durch diese eigenständige Root-Komponente. 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 unbestä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]
event.target.value = ''
if (file) this.upload.select(file)
}
ngOnDestroy(): void {
this.upload.destroy()
}
}
Speichern Sie src/main.ts, um die Anwendung zu initialisieren:
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.'))
Den Upload ausführen und überprüfen
Nachdem Sie alle Dateien gespeichert haben, führen Sie Installation und Build aus Ihrem übergeordneten Verzeichnis aus. Schlägt die Installation oder der Build fehl, stoppt die Befehlskette, und die Subshell bringt Sie in Ihr ursprüngliches Verzeichnis zurück:
(
cd chunked-upload-app &&
corepack yarn install &&
corepack yarn build
)
Starten Sie den Empfänger in einem Terminal aus demselben übergeordneten Verzeichnis:
(
cd chunked-upload-app &&
node receiver.mts
)
Starten Sie in einem zweiten Terminal den Angular-Entwicklungsserver:
(
cd chunked-upload-app &&
corepack yarn start
)
Öffnen Sie http://127.0.0.1:4200, wählen Sie eine nicht leere Datei mit höchstens 100 MiB
und beobachten Sie den Status. Klicken Sie bei einer großen Übertragung auf
Pause und anschließend auf
Resume, ohne die Seite neu zu laden.
Eine Übertragung kann schon vor Ihrem Klick abgeschlossen sein; nutzen Sie die Netzwerkdrosselung
des Browsers, wenn Sie Zeit zum Pausieren benötigen. Der Fortschrittsbalken bleibt unter 100, bis
der Empfänger die Veröffentlichung bestätigt. Die Seite zeigt dann
Upload complete., und der Empfänger gibt
Published uploads/<UUID>.bin aus.
Vergleichen Sie genau diese Datei .bin mit der ausgewählten Eingabedatei,
indem Sie ein Prüfsummenwerkzeug oder einen Byte-Vergleich verwenden. Die UUID stammt aus dem
Protokoll des Empfängers, nicht aus dem ursprünglichen Dateinamen. Wenn Sie dieselbe Eingabedatei
erneut auswählen, beginnt ein separater Upload. Die Komponente leert das native Eingabeelement nach
der Auswahl, damit dessen Änderungsereignis erneut ausgelöst werden kann. Stoppen Sie beide im
Vordergrund laufenden Server mit Ctrl+C.
Upload interrupted. Resume to retry. bedeutet, dass eine Anfrage fehlgeschlagen ist oder der Empfänger einen ungültigen Zustand zurückgegeben hat. Prüfen Sie, ob der Empfänger läuft und der Proxy auf dessen Port verweist, und setzen Sie den Upload dann fort. Wenn beim Abbrechen Cancellation unconfirmed. Check your uploads. gemeldet wird, prüfen Sie das Protokoll und das Ausgabeverzeichnis des Empfängers: Die Veröffentlichung könnte abgeschlossen worden sein, bevor die Abbruchanfrage eintraf.
Wenn die Standardports belegt sind, stoppen Sie Ihren eigenen kollidierenden Prozess oder wählen
Sie zwei freie Ports. Ändern Sie den Frontend-Port in package.json und den
Backend-Port in proxy.conf.json. Starten Sie den Empfänger dann mit passenden Werten
für PORT und APP_ORIGIN, beispielsweise mit
PORT=4318 APP_ORIGIN=http://127.0.0.1:4201 node receiver.mts aus dem Projektverzeichnis.
Starten Sie den Angular-Server nach Änderungen an seiner Proxy-Konfiguration neu.
Für eine öffentliche Anwendung benötigt die empfangende API außerdem bei jeder Änderung eine authentifizierte Eigentümerzuordnung und validierten CSRF-Schutz. Das hier verwendete Offset-Protokoll bietet beides nicht. Wenn Sie ein Standardprotokoll und eine etablierte Upload-Oberfläche suchen, sehen Sie sich tus und Uppy an.
