Datei-Uploads mit Web Workers und begrenzten Streams
Nutzen Sie einen Worker, wenn Ihre Upload-Pipeline Dateien außerhalb des Hauptthreads der Seite
verarbeiten muss. Dieses Beispiel liest ein File schrittweise, sendet pro
Worker jeweils einen 1 MiB großen Chunk und wartet auf die abschließende Bestätigung des Empfängers,
bevor es einen Download-Link anzeigt. Zwei Worker können unterschiedliche Dateien bearbeiten,
während die übrigen Dateien in einer begrenzten Warteschlange warten.
Herausforderungen bei herkömmlichen Datei-Uploads
Ein gewöhnlicher asynchroner Upload kann ein File direkt an
FormData anhängen. Sie müssen es nicht zuerst mit
FileReader lesen; siehe die FormData-Beispiele von MDN.
Beginnen Sie damit, wenn Sie lediglich eine Datei senden möchten. Worker erhöhen nicht die
Netzwerkbandbreite, und dieses Tutorial macht keine Aussage zu Geschwindigkeit oder Gesamtspeicherbedarf.
Die separate Aufgabe hier ist, schrittweises Lesen und Bestätigungen über mehrere Dateien hinweg zu steuern. Die Einführung in Worker behandelt grundlegende Worker-Nachrichten. Das Tutorial zu parallelen Chunks behandelt gleichzeitige Anfragen innerhalb einer Datei; hier erfolgen die Anfragen pro Datei sequenziell.
Web Workers und Streams kennenlernen
Web Workers
Worker können Dateien verarbeiten und Anfragen senden, aber das DOM der Seite nicht aktualisieren.
Die Seite sendet jedes File per Structured Cloning und stellt die Nachrichten
seines Workers dar. Sie liest die Datei zum Senden nicht in einen
ArrayBuffer ein. Siehe Web Workers verwenden.
JavaScript-Streams
Blob.stream(), das von
File geerbt wird, liefert einen Byte-Stream und ist innerhalb von Workern
verfügbar. Seine Lesegrenzen entsprechen nicht den Grenzen unseres Upload-Protokolls. Der folgende
Generator fasst diese Lesevorgänge zu 1 MiB großen Chunks zusammen und behält einen kürzeren letzten
Chunk unverändert bei.
Architekturübersicht
Die Seite lässt bis zu vier Dateien mit jeweils höchstens 8 MiB zu, einschließlich leerer Dateien. Der Pool führt höchstens zwei Aufträge aus. Jeder Worker erstellt einen Upload, liest einen Chunk, wartet auf die Bestätigung seines Multipart-POSTs und fordert den Empfänger abschließend auf, den Upload abzuschließen. Der Fortschritt zählt bestätigte Datei-Bytes, nicht Bytes, die gerade übertragen werden.
Der Empfänger ist unten enthalten. Er prüft Offsets und Längen, hält bis zu vier Dateien im Speicher und stellt abgeschlossene Dateien als Downloads bereit. Uploads verfallen 60 Sekunden nach ihrer Erstellung, auch abgeschlossene Uploads. Beim Stoppen des Servers gehen alle Dateien verloren. Dies ist eine lokale Protokolldemonstration ohne Retries, Wiederherstellung nach Neuladen oder dauerhafte Speicherung.
Die lokale Upload-Demo einrichten
Verwenden Sie für dieses lokale Beispiel Node.js 26.8.1 und einen Browser, der Modul-Worker,
Blob.stream() und AbortSignal.timeout() unterstützt. Der gesamte Ablauf wurde
unter Linux mit Chromium 145 getestet. Node 26 ist mit Stand vom 1. Oktober 2026 eine gepflegte
Current-Version; prüfen Sie den Veröffentlichungsplan, wenn Sie eine
Laufzeitumgebung für die Bereitstellung wählen. Dieser Empfänger ist für localhost vorgesehen.
Erstellen Sie in Bash ein neues Verzeichnis. Falls das Erstellen oder der Wechsel dorthin fehlschlägt, brechen Sie ab und wählen Sie einen anderen Namen, bevor Sie Dateien speichern. Weder die Installation von Abhängigkeiten noch die Build-Konfiguration eines übergeordneten Projekts ist erforderlich.
mkdir worker-stream-demo && cd worker-stream-demo
Speichern Sie das HTML als index.html, fügen Sie beide Hauptthread-Blöcke in der
gezeigten Reihenfolge in main.ts ein und speichern Sie Worker und Empfänger als
upload-worker.ts und server.mts. Der Empfänger entfernt
TypeScript-Annotationen beim Ausliefern der Browser-Skripte; Browser führen die Quelldateien im
Format .ts nie direkt aus.
Hauptthread (main.js)
Speichern Sie dies als index.html. Die Skripte werden vom selben lokalen Ursprung
ausgeliefert.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Worker stream uploads</title>
</head>
<body>
<main>
<h1>Worker stream uploads</h1>
<label for="file-input">Up to four files, each at most 8 MiB</label>
<input id="file-input" type="file" multiple />
<button id="upload-files" type="button">Upload files</button>
<button id="cancel-uploads" type="button" disabled>Cancel uploads</button>
<p id="status" role="status">Choose files.</p>
<ul id="results" aria-label="Upload results"></ul>
</main>
<script type="module" src="/main.js"></script>
</body>
</html>
Beginnen Sie main.ts mit diesem Pool. Er entfernt einen abgeschlossenen Auftrag,
bevor er die Benutzeroberfläche aufruft, und arbeitet die Warteschlange auch dann weiter ab, wenn ein
Callback eine Ausnahme auslöst. Ein Fehler im Worker-Skript oder bei der Nachrichtenserialisierung
bricht den gesamten Pool ab, statt einen defekten Worker wieder einzusetzen.
export type WorkerReply =
| { type: 'progress'; progress: number; message: string }
| { type: 'complete'; message: string; url: string }
| { type: 'error'; message: string }
type Callbacks = {
onProgress?: (progress: number, message: string) => void
onComplete?: (message: string, url: string) => void
onError?: (message: string) => void
}
type Task = { id: string; file: File }
class WorkerPool {
closed = false
workers: Worker[] = []
idleWorkers: Worker[] = []
taskQueue: Task[] = []
taskCallbacks = new Map<string, Callbacks>()
currentTasks = new Map<Worker, Task>()
constructor(script: string, size = 2) {
if (!Number.isInteger(size) || size < 1 || size > 2) throw new Error('Use one or two workers')
try {
for (let index = 0; index < size; index++) {
const worker = new Worker(script, { type: 'module' })
worker.onmessage = (event: MessageEvent<WorkerReply>) => this.handleWorkerMessage(worker, event.data)
worker.onerror = (event) => {
event.preventDefault()
this.terminate('An upload worker failed. Try again.')
}
worker.onmessageerror = () => this.terminate('An upload worker failed. Try again.')
this.workers.push(worker)
this.idleWorkers.push(worker)
}
} catch (error) {
this.terminate()
throw error
}
}
processFile(file: File, callbacks: Callbacks, id = crypto.randomUUID()): string {
if (this.closed) throw new Error('The upload pool is closed.')
if (this.taskCallbacks.size >= 4) throw new Error('The upload queue is full.')
const task = { id, file }
this.taskCallbacks.set(task.id, callbacks)
const worker = this.idleWorkers.pop()
if (worker) this.runTask(worker, task)
else this.taskQueue.push(task)
return task.id
}
runTask(worker: Worker, task: Task): void {
this.currentTasks.set(worker, task)
try {
worker.postMessage(task)
} catch {
this.terminate('An upload worker failed. Try again.')
}
}
handleWorkerMessage(worker: Worker, data: WorkerReply): void {
if (this.closed) return
const task = this.currentTasks.get(worker)
if (!task) return
const callbacks = this.taskCallbacks.get(task.id)
if (!callbacks) return
if (data.type === 'progress') {
callbacks.onProgress?.(data.progress, data.message)
return
}
this.currentTasks.delete(worker)
this.taskCallbacks.delete(task.id)
try {
if (data.type === 'complete') callbacks.onComplete?.(data.message, data.url)
else callbacks.onError?.(data.message)
} finally {
if (!this.closed) {
const next = this.taskQueue.shift()
if (next) this.runTask(worker, next)
else this.idleWorkers.push(worker)
}
}
}
terminate(message = 'Upload canceled.'): void {
if (this.closed) return
this.closed = true
for (const worker of this.workers) worker.terminate()
this.workers = []
this.idleWorkers = []
this.taskQueue = []
this.currentTasks.clear()
const callbacks = [...this.taskCallbacks.values()]
this.taskCallbacks.clear()
const errors: unknown[] = []
for (const callback of callbacks) {
try {
callback.onError?.(message)
} catch (error) {
errors.push(error)
}
}
if (errors.length > 0) throw new AggregateError(errors, 'Upload cancellation callbacks failed.')
}
}
Hängen Sie diesen Block an main.ts an. Dateiauswahl und erneutes Absenden sind
deaktiviert, während ein Stapel ausgeführt oder bereinigt wird. Jede Zeile gehört zu einer Datei,
sodass ein Fehler nicht mit dem Ergebnis einer anderen Datei verwechselt werden kann. Namen werden
als Text eingefügt und nie als HTML oder Speicherpfade interpretiert.
const input = document.getElementById('file-input')
const upload = document.getElementById('upload-files')
const cancel = document.getElementById('cancel-uploads')
const status = document.getElementById('status')
const results = document.getElementById('results')
if (!(input instanceof HTMLInputElement) || !(upload instanceof HTMLButtonElement) ||
!(cancel instanceof HTMLButtonElement) || !status || !results) throw new Error('Missing controls')
let pool: WorkerPool | null = null
let running = false
cancel.addEventListener('click', () => pool?.terminate())
window.addEventListener('pagehide', () => pool?.terminate())
input.addEventListener('change', () => {
if (!running) status.textContent = 'Ready to upload.'
})
upload.addEventListener('click', async () => {
if (running) return
const files = Array.from(input.files ?? [])
if (files.length < 1 || files.length > 4 || files.some((file) => file.size > 8 * 1024 * 1024)) {
status.textContent = 'Choose one to four files, each at most 8 MiB.'
return
}
running = true
input.disabled = upload.disabled = true
cancel.disabled = false
results.replaceChildren()
status.textContent = 'Uploading…'
const failedIds: string[] = []
let failures = 0
try {
const batchPool = new WorkerPool('/upload-worker.js')
pool = batchPool
const jobs = files.map((file) => new Promise<void>((resolve) => {
const id = crypto.randomUUID()
const row = document.createElement('li')
const name = document.createElement('p')
name.textContent = file.name
const progress = document.createElement('progress')
progress.max = 100
progress.value = 0
progress.setAttribute('aria-label', `Acknowledged bytes for ${file.name}`)
const message = document.createElement('p')
message.setAttribute('role', 'status')
message.textContent = 'Queued.'
row.append(name, progress, message)
results.append(row)
batchPool.processFile(file, {
onProgress(value, text) { progress.value = value; message.textContent = text },
onComplete(text, url) {
progress.value = 100
message.textContent = text
const link = document.createElement('a')
link.href = url
link.download = 'upload.bin'
link.textContent = `Download ${file.name}`
row.append(link)
resolve()
},
onError(text) {
failures++
failedIds.push(id)
message.textContent = text
resolve()
},
}, id)
}))
await Promise.all(jobs)
status.textContent = failures === 0 ? 'All uploads complete.' : 'Some uploads did not complete.'
} catch {
status.textContent = 'Could not start the upload workers. Try again.'
} finally {
pool?.terminate()
pool = null
let cleanupFailed = false
for (const id of failedIds) {
try {
const response = await fetch(`/uploads/${id}`, {
method: 'DELETE', headers: { 'X-Upload-Demo': '1' }, signal: AbortSignal.timeout(5000),
})
if (!response.ok) cleanupFailed = true
} catch { cleanupFailed = true }
}
if (cleanupFailed) status.textContent += ' Cleanup was not confirmed; uploads will expire.'
running = false
input.disabled = upload.disabled = false
cancel.disabled = true
}
})
Worker-Implementierung (upload-worker.js)
Speichern Sie dies als upload-worker.ts. Das Warten auf jeden POST verhindert, dass der
Generator einen weiteren Anwendungs-Chunk erzeugt, solange die vorherige Anfrage noch aussteht.
Der Browser kann Stream-Lesevorgänge und Anfragedaten intern dennoch puffern. Die Chunk-Größe von
1 MiB begrenzt nicht den Gesamtspeicherbedarf des Browsers.
import type { WorkerReply } from './main.js'
declare const self: DedicatedWorkerGlobalScope
const CHUNK_SIZE = 1024 * 1024
async function* readChunks(file: Blob): AsyncGenerator<Uint8Array<ArrayBuffer>> {
const reader = file.stream().getReader()
let buffer = new Uint8Array(CHUNK_SIZE)
let used = 0
let finished = false
try {
while (true) {
const { done, value } = await reader.read()
if (done) { finished = true; break }
let offset = 0
while (offset < value.length) {
const length = Math.min(CHUNK_SIZE - used, value.length - offset)
buffer.set(value.subarray(offset, offset + length), used)
used += length
offset += length
if (used === CHUNK_SIZE) {
yield buffer
buffer = new Uint8Array(CHUNK_SIZE)
used = 0
}
}
}
if (used > 0) yield buffer.subarray(0, used)
} finally {
if (!finished) await reader.cancel().catch(() => {})
reader.releaseLock()
}
}
function send(message: WorkerReply): void { self.postMessage(message) }
async function request(path: string, options: RequestInit): Promise<Response> {
const response = await fetch(path, {
...options, headers: { ...options.headers, 'X-Upload-Demo': '1' },
signal: AbortSignal.timeout(5000),
})
if (!response.ok) throw new Error('Upload request rejected')
return response
}
self.onmessage = async (event: MessageEvent<{ file: File; id: string }>) => {
const { file, id } = event.data
try {
if (!(file instanceof File) || file.size > 8 * CHUNK_SIZE) throw new Error('Invalid file')
await request(`/uploads/${id}`, { method: 'POST', headers: { 'Upload-Length': String(file.size) } })
send({ type: 'progress', progress: 0, message: 'Uploading…' })
let acknowledged = 0
for await (const chunk of readChunks(file)) {
const form = new FormData()
form.append('offset', String(acknowledged))
form.append('chunk', new Blob([chunk]), 'chunk.bin')
await request(`/uploads/${id}/chunks`, { method: 'POST', body: form })
acknowledged += chunk.length
send({ type: 'progress', progress: 100 * acknowledged / file.size,
message: `${acknowledged} / ${file.size} bytes acknowledged.` })
}
send({ type: 'progress', progress: 100, message: 'Confirming…' })
const response = await request(`/uploads/${id}/complete`, { method: 'POST' })
if (await response.text() !== String(file.size)) throw new Error('Unexpected acknowledgment')
send({ type: 'complete', message: 'Upload complete.', url: `/uploads/${id}/file` })
} catch {
send({ type: 'error', message: 'Upload failed. Try again.' })
}
}
Hier kommen einzelne Multipart-Anfragen zum Einsatz, kein Anfragekörper vom Typ
ReadableStream. Lassen Sie den Browser die Multipart-Grenze in
Content-Type setzen. Eine leere Datei sendet keine Chunks, erfordert aber dennoch
einen Abschluss.
Warum nicht die gesamte Datei einmal im Worker lesen?
Auch wenn Sie einen Aufruf von arrayBuffer() für die gesamte Datei in einen Worker
verlagern, entsteht weiterhin ein Anwendungspuffer für die gesamte Datei. Hier hält die
Zusammenführung einen 1 MiB großen Puffer plus die aktuell aus dem Stream gelesenen Daten vor.
Bei jedem aktiven Auftrag wartet nur eine Chunk-Anfrage auf Bestätigung. Ein
Blob und die Netzwerkimplementierung können zusätzliche Kopien erzeugen.
Aufträge in der Warteschlange halten Datei-Handles statt vorab gelesener Datei-Bytes.
Einen kleinen Worker-Pool erstellen
Der obige Pool hat zwei Worker und höchstens vier ausstehende Aufträge. Dies ist eine Grenze für die Ablaufplanung, keine Empfehlung auf Grundlage der CPU-Anzahl. Mehr Worker können den Aufwand für Verarbeitung, Speicher und Anfragen erhöhen, ohne den nutzbaren Durchsatz zu steigern. Messen Sie dies mit Ihrem eigenen Verarbeitungsschritt und Empfänger.
Chunks lokal empfangen
Speichern Sie dies als server.mts. Die Erweiterung
.mts stellt die Ausführung im ES-Modulmodus sicher, auch innerhalb eines
CommonJS-Projekts. stripTypeScriptTypes()
entfernt Annotationen; eine Typprüfung der Skripte führt es nicht durch.
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { stripTypeScriptTypes } from 'node:module'
const CHUNK_SIZE = 1024 * 1024
const MAX_BODY = CHUNK_SIZE + 4096
const TTL_MS = 60_000
type Upload = { bytes: Buffer; received: number; complete: boolean; expires: number }
const uploads = new Map<string, Upload>()
class HttpError extends Error {
status: number
constructor(status: number, message: string) { super(message); this.status = status }
}
function expire(): void {
for (const [id, upload] of uploads) {
if (upload.expires <= Date.now()) uploads.delete(id)
}
}
async function readBody(req: IncomingMessage, limit: number): Promise<Buffer> {
const bytes = Buffer.alloc(limit)
let length = 0
// Keep the connection long enough to report an oversize body.
for await (const part of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(part)) throw new HttpError(400, 'Expected bytes')
if (length + part.length > limit) throw new HttpError(413, 'Body too large')
part.copy(bytes, length)
length += part.length
}
return bytes.subarray(0, length)
}
async function main(): Promise<void> {
const assets = new Map<string, string>([['/', await readFile(new URL('./index.html', import.meta.url), 'utf8')]])
for (const name of ['main', 'upload-worker']) {
assets.set(`/${name}.js`, stripTypeScriptTypes(await readFile(new URL(`./${name}.ts`, import.meta.url), 'utf8')))
}
const port = Number(process.argv[2] ?? 0)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port')
let origin = ''
let active = 0
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.headers.host !== new URL(origin).host ||
(req.headers.origin !== undefined && req.headers.origin !== origin)) throw new HttpError(403, 'Use the printed local URL')
const method = req.method
if (method !== 'GET' && req.headers['x-upload-demo'] !== '1') throw new HttpError(403, 'Missing demo header')
const path = new URL(req.url ?? '/', origin).pathname
const isChunk = method === 'POST' && path.endsWith('/chunks')
const body = await readBody(req, isChunk ? MAX_BODY : 0)
// Parse before looking up an upload: pending work cannot retain a deleted session.
let chunk: Uint8Array | undefined
let offset: string | undefined
if (isChunk) {
const contentType = req.headers['content-type']
if (typeof contentType !== 'string' || !contentType.startsWith('multipart/form-data;')) throw new HttpError(400, 'Expected multipart data')
let form: FormData
try {
form = await new Request(origin, { method: 'POST', headers: { 'Content-Type': contentType }, body: new Uint8Array(body) }).formData()
} catch { throw new HttpError(400, 'Invalid multipart data') }
const value = form.get('chunk')
const position = form.get('offset')
if (!(value instanceof Blob) || value.size > CHUNK_SIZE || typeof position !== 'string' ||
!/^(0|[1-9]\d*)$/.test(position) || [...form.keys()].length !== 2) throw new HttpError(400, 'Invalid chunk fields')
chunk = new Uint8Array(await value.arrayBuffer())
offset = position
}
expire()
res.setHeader('Cache-Control', 'no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
const asset = assets.get(path)
if (method === 'GET' && asset !== undefined) {
res.setHeader('Content-Type', path === '/' ? 'text/html; charset=utf-8' : 'text/javascript')
res.end(asset)
return
}
const match = /^\/uploads\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\/(chunks|complete|file))?$/.exec(path)
if (!match) throw new HttpError(404, 'Unknown route')
const [, id, operation] = match
if (method === 'POST' && operation === undefined) {
const size = req.headers['upload-length']
if (typeof size !== 'string' || !/^(0|[1-9]\d*)$/.test(size) || Number(size) > 8 * CHUNK_SIZE) throw new HttpError(400, 'Expected a size from 0 to 8 MiB')
if (uploads.has(id)) throw new HttpError(409, 'Upload already exists')
if (uploads.size >= 4) throw new HttpError(503, 'Upload capacity reached')
uploads.set(id, { bytes: Buffer.alloc(Number(size)), received: 0, complete: false, expires: Date.now() + TTL_MS })
res.writeHead(201).end()
return
}
if (method === 'DELETE' && operation === undefined) {
uploads.delete(id)
res.writeHead(204).end()
return
}
const upload = uploads.get(id)
if (!upload) throw new HttpError(404, 'Upload missing or expired')
if (isChunk && operation === 'chunks') {
const expected = Math.min(CHUNK_SIZE, upload.bytes.length - upload.received)
if (!chunk || chunk.length === 0 || chunk.length !== expected || offset !== String(upload.received) || upload.complete) throw new HttpError(409, 'Unexpected chunk offset or length')
upload.bytes.set(chunk, upload.received)
upload.received += chunk.length
res.writeHead(204).end()
return
}
if (method === 'POST' && operation === 'complete') {
if (upload.received !== upload.bytes.length) throw new HttpError(409, 'Missing bytes')
upload.complete = true
res.end(String(upload.received))
return
}
if (method === 'GET' && operation === 'file') {
if (!upload.complete) throw new HttpError(409, 'Upload is not complete')
res.setHeader('Content-Type', 'application/octet-stream')
res.setHeader('Content-Disposition', 'attachment; filename="upload.bin"')
res.end(upload.bytes)
return
}
throw new HttpError(405, 'Unsupported operation')
}
const server = createServer({ requestTimeout: 10_000, headersTimeout: 10_000 }, (req, res) => {
if (active >= 6) { res.writeHead(503, { Connection: 'close' }).end('Too many requests'); return }
active++
let handled = false
let closed = false
const deadline = setTimeout(() => { req.destroy(); res.destroy() }, 10_000)
function release(): void {
if (handled && closed) { clearTimeout(deadline); active-- }
}
res.once('close', () => { closed = true; release() })
handle(req, res).catch((error: unknown) => {
const status = error instanceof HttpError ? error.status : 500
const message = error instanceof HttpError ? error.message : 'Request failed'
if (!res.destroyed) res.writeHead(status, { Connection: 'close' }).end(message)
}).finally(() => { handled = true; release() })
})
server.maxConnections = 16
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', () => resolve())
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing server address')
origin = `http://127.0.0.1:${address.port}`
setInterval(expire, 1000).unref()
process.once('SIGINT', () => {
server.close()
server.closeAllConnections()
})
console.log(`Open ${origin}`)
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : 'Could not start the server')
process.exitCode = 1
})
Die vier registrierten Dateipuffer belegen zusammen höchstens 32 MiB. Das Parsen von Anfragen nutzt zusätzliche Puffer; jeder akzeptierte Anfragekörper ist auf 1 MiB plus 4 KiB Multipart-Overhead begrenzt, einschließlich nachgestellter Daten. Es gibt höchstens sechs aktive Handler. Ein Download kann seinen Dateipuffer nach dem Löschen oder Verfallen behalten. Deshalb bleibt der Platz des Handlers belegt, bis sowohl die Verarbeitung als auch das Schließen der Antwort abgeschlossen sind. Diese Grenzen beschreiben Anwendungsvorgänge, nicht den gesamten Prozessspeicher oder den Zeitpunkt der Garbage Collection.
Führen Sie im Verzeichnis mit den vier gespeicherten Dateien diesen Befehl im Vordergrund aus:
node server.mts
Öffnen Sie die ausgegebene URL http://127.0.0.1:PORT. Wählen Sie Dateien aus und klicken Sie
auf Upload files. Jede Zeile erreicht
Confirming…, nachdem ihre Chunks bestätigt wurden;
nur eine erfolgreiche Abschlussantwort erzeugt
Upload complete. und einen Download-Link.
Klicken Sie auf den Link, um die empfangenen Bytes über Ihren Browser als
upload.bin zu speichern. Eine übereinstimmende Byte-Anzahl ist keine Prüfsumme für
die Integrität; vergleichen Sie beim Prüfen dieses Beispiels die heruntergeladene Datei mit dem Original.
Stoppen Sie den Server mit Ctrl+C; dadurch werden seine Verbindungen geschlossen. Um einen bestimmten
freien Port auszuwählen, hängen Sie dessen Nummer an node server.mts an.
Bei einem belegten Port oder einer fehlenden Quelldatei wird die Ausführung mit einem Fehler beendet,
statt eine betriebsbereite URL auszugeben.
Browserkompatibilität
Prüfen Sie die von Ihnen unterstützten Browserversionen auf Worker,
Blob.stream() und
AbortSignal.timeout().
Dieses Beispiel überträgt keinen Stream zwischen Threads. Sein Browser-Anfrage-Timeout von fünf
Sekunden nutzt aktive Zeit und kann in einem angehaltenen Worker pausieren; der Empfänger hat eine
separate Frist von zehn Sekunden. Andere Browser und Betriebssysteme wurden für diese Anleitung
nicht getestet.
Bewährte Verfahren für die Speicherverwaltung
Halten Sie pro Worker nur einen Anwendungs-Chunk in Übertragung und eine kleine Warteschlange mit
Datei-Handles vor. Der Generator bricht einen noch nicht abgeschlossenen Stream ab und gibt seine
Lesesperre frei, wenn eine Anfrage fehlschlägt. Die Seite beendet alle Worker am Ende eines Stapels,
bei einem Abbruch und bei pagehide.
Klicken Sie auf Cancel uploads, während ein Stapel
noch aussteht. Aktive Zeilen und Zeilen in der Warteschlange erhalten
Upload canceled.; bereits abgeschlossene Zeilen behalten
ihre Ergebnisse. Die Seite versucht, fehlgeschlagene oder abgebrochene Upload-IDs zu löschen, und
lässt die Bedienelemente deaktiviert, bis die Bereinigung beendet ist. Eine verlorene
Erstellungsantwort, das Schließen der Seite oder ein fehlgeschlagener Löschvorgang kann dazu führen,
dass Bytes bis zum Verfallen erhalten bleiben. Das Beenden eines Workers führt dessen Blöcke
finally nicht aus und macht keine Anfrage rückgängig, die der Empfänger bereits
akzeptiert hat.
Sicherheit und Ausfallsicherheit
Der Server bindet sich an 127.0.0.1, prüft Host und Browser-Ursprung und verlangt
für Änderungen einen benutzerdefinierten Header. Er ermöglicht keinen ursprungsübergreifenden
Zugriff, interpretiert Dateinamen nicht als Pfade und liefert hochgeladene Bytes nicht als
ausführbares HTML aus. Dies sind Grenzen der lokalen Demo, keine Authentifizierung oder
Inhaltsvalidierung. Stellen Sie diesen Empfänger nicht als öffentlichen Upload-Dienst bereit.
Ein bereitgestellter Dienst benötigt Authentifizierung, Autorisierung, Kontingente pro Benutzer, HTTPS, dauerhafte Speicherung und eine für seine Nutzer geeignete Validierung. Dieses Offset-Protokoll lehnt doppelte Chunks oder Chunks in falscher Reihenfolge bewusst ab. Retries hinzuzufügen erfordert eine Idempotenzvereinbarung; die Wiederherstellung nach Neuladen erfordert einen dauerhaft gespeicherten Zustand und einen Abgleich mit dem Empfänger.
Fehlersuche bei Web Workers
Untersuchen Sie Worker-Anfragen und Nachrichten in den Browser-Entwicklertools. Ein Fehler beim Laden des Skripts bricht den Pool ab; ein HTTP-Fehler wird in der entsprechenden Zeile als Upload failed. Try again. angezeigt. Eine Antwort mit Status 409 weist auf einen falschen Offset, eine falsche Chunk-Länge oder eine unvollständige Datei hin. Ein Status 503 kann bedeuten, dass alle vier Upload-Plätze belegt sind, auch durch abgeschlossene Dateien; warten Sie vor einem erneuten Versuch, bis diese verfallen. Fehlende oder verfallene IDs liefern 404 zurück.
Häufige Stolperfallen
- 100 % bestätigte Bytes als Abschluss betrachten. Der letzte POST kann noch fehlschlagen.
- Die Eingabe wieder aktivieren, bevor abgebrochene Vorgänge beendet sind. Diese Seite wartet auf ihren Bereinigungsversuch.
- Annehmen, dass die gelesenen Stream-Datenmengen den HTTP-Chunk-Größen entsprechen. Die Zusammenführung verarbeitet einen kürzeren Rest.
- Annehmen, dass ein beendeter Worker den Serverzustand löscht. Das Verfallen bleibt notwendig.
- Jede Datei in der Warteschlange vorab lesen und damit die schrittweise Verarbeitung aushebeln.
Das Wichtigste im Überblick
Testen Sie eine leere Datei und eine Binärdatei, die etwas größer als 1 MiB ist, und vergleichen Sie anschließend jedes heruntergeladene Ergebnis mit seiner Eingabe. Entscheidend ist der vollständige Ablauf vom schrittweisen Lesen über einen bestätigten Chunk bis zur bestätigten Datei, mit wenigen aktiven Aufträgen. Für wiederaufnehmbare Uploads im Produktivbetrieb beginnen Sie mit einem gepflegten Protokoll wie tus und einem kompatiblen Empfänger. Das Tus-Plugin von Uppy bietet einen gepflegten Browser-Client.
