Dateien mit Web Worker und lokalem Empfänger hochladen
Ein Web Worker kann Dateien außerhalb des Hauptthreads Ihrer Seite verarbeiten und den Upload-Fortschritt über Nachrichten melden. Dieses Beispiel verbindet einen dedizierten Worker mit einem ausführbaren lokalen Empfänger: Wählen Sie eine Datei, laden Sie sie in sequenziellen Chunks hoch und laden Sie die Datei herunter, die der Empfänger tatsächlich angenommen hat.
Aufgaben für den Worker auswählen
Ein gewöhnlicher asynchroner Upload benötigt keinen Worker. Nutzen Sie einen, wenn Ihre Pipeline auch Dateien analysieren oder transformieren muss. Worker können Anfragen stellen, aber nicht das DOM der Seite aktualisieren. Stattdessen stellt die Seite ihre Nachrichten dar. Siehe Web Worker verwenden.
Hier veranschaulicht das Hashen einer kleinen Datei einen Verarbeitungsschritt. Web Crypto selbst arbeitet asynchron. Dieses Beispiel behauptet daher nicht, dass der Worker die Upload-Geschwindigkeit erhöht oder den gesamten Speicherbedarf senkt. Für mehrere Dateien behandelt das separate Tutorial zu Worker-Pools und Streams die Verwaltung von Warteschlangen und das inkrementelle Lesen von Streams. Auf dieser Seite bleiben wir bei einer Datei und einem Worker.
Verwenden Sie Node.js 24.15.0 oder eine neuere gepflegte Version, Corepack mit Yarn 4, Bash und einen
Browser mit Modul-Workern, Web Crypto und AbortSignal.timeout(). Das vollständige Beispiel
wurde unter Linux mit Node.js 24.15.0 und 26.8.1 sowie Chromium 145 getestet. Am 2. Oktober 2026 ist
Node 24 LTS und Node 26 Current. Wählen Sie eine aktuelle Patch-Version aus der
Node-Versionsliste. Falls Corepack fehlt, folgen Sie der
Installationsanleitung von Yarn, bevor Sie fortfahren.
Lokales Projekt und Seite erstellen
Fügen Sie diesen Code in Bash ein. Er lehnt ein vorhandenes Verzeichnis ab, isoliert das Yarn-Projekt
mit einer eigenen Lockdatei und kehrt bei einer fehlgeschlagenen Installation in Ihr ursprüngliches
Verzeichnis zurück. Dabei kann das neue Demo-Verzeichnis zurückbleiben. Prüfen Sie es und wählen Sie
vor einem erneuten Versuch einen neuen Namen. Alle nachfolgenden Dateien gehören in
worker-upload-demo.
if (
mkdir worker-upload-demo &&
cd worker-upload-demo &&
printf '%s\n' '{"name":"worker-upload-demo","private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' 'enableGlobalCache: false' 'globalFolder: .yarn/global' 'npmRegistryServer: https://registry.npmjs.org' > .yarnrc.yml &&
touch yarn.lock &&
mkdir src &&
corepack yarn add --dev --exact vite@8.3.1 typescript@6.0.3 @types/node@26.6.3
); then
cd worker-upload-demo
else
printf '%s\n' 'Setup failed; inspect the new directory before retrying.' >&2
false
fi
Speichern Sie dies als index.html. Der Empfänger stellt die gebaute Seite und die
Upload-Routen unter demselben Loopback-Ursprung bereit. Öffnen Sie die ausgegebene HTTP-URL, statt
diese Datei direkt zu öffnen.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dedicated worker upload</title>
</head>
<body>
<main>
<h1>Dedicated worker upload</h1>
<label for="fileInput">File to upload, up to 16 MiB</label>
<input type="file" id="fileInput" />
<button type="button" id="uploadBtn">Upload</button>
<button type="button" id="cancelBtn" disabled>Cancel</button>
<label id="progressLabel" for="uploadProgress">Upload progress</label>
<progress id="uploadProgress" aria-labelledby="progressLabel" value="0" max="100"></progress>
<p id="status" role="status">Choose a file.</p>
<output id="checksum" aria-label="Receiver SHA-256"></output>
<p><a id="download" hidden download="received.bin">Download received file</a></p>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Nachrichtenvertrag definieren und prüfen
Speichern Sie src/worker-types.ts. Eine Abschlussnachricht enthält die Prüfsumme und die
Download-URL des Empfängers, damit die Seite ein sichtbares Ergebnis anzeigen kann.
export interface WorkerMessage {
file: File
}
export type WorkerResponse =
| { type: 'processed' }
| { type: 'progress'; percent: number }
| { type: 'confirming' }
| { type: 'complete'; sha256: string; url: string }
| { type: 'error'; message: string }
Speichern Sie tsconfig.json für die Seite:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": [],
"strict": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src/main.ts", "src/worker-types.ts"]
}
Speichern Sie tsconfig.worker.json. Die separate Prüfung der Worker verhindert, dass globale
DOM- und Worker-Definitionen vermischt werden.
Vite baut den Modul-Worker, während TypeScript seine Typen prüft.
{
"extends": "./tsconfig.json",
"compilerOptions": { "lib": ["ES2022", "WebWorker"] },
"include": ["src/upload.worker.ts", "src/worker-types.ts"]
}
Speichern Sie tsconfig.server.json für den Node-Empfänger:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["server.mts"]
}
Chunks einzeln aus dem Worker senden
Speichern Sie src/upload.worker.ts. Für Dateien bis 5 MiB wird vor dem Senden ein Hash
berechnet. Bei größeren Dateien entfällt das Hashen der gesamten Datei. Jede Anfrage sendet ein rohes
Blob, wartet auf eine HTTP-Bestätigung und geht dann zum nächsten Offset
über. Leere Dateien benötigen keine Chunk-Anfragen, müssen aber dennoch finalisiert werden.
xhr.upload liefert den Übertragungsfortschritt.
Diese Ereignisse bestätigen nicht die Annahme durch den Server. Der Balken bleibt unter 100 %, bis
die letzte Anfrage erfolgreich ist, auch wenn alle Bytes den Browser bereits verlassen haben.
import type { WorkerMessage, WorkerResponse } from './worker-types.ts'
declare const self: DedicatedWorkerGlobalScope
const CHUNK_SIZE = 5 * 1024 * 1024
const MAX_FILE_SIZE = 16 * 1024 * 1024
function send(message: WorkerResponse): void {
self.postMessage(message)
}
function uploadChunk(chunk: Blob, url: string, onProgress: (ratio: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(event.loaded / event.total)
}
xhr.open('PUT', url)
xhr.timeout = 60_000
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve()
else reject(new Error('Chunk rejected'))
}
xhr.onerror = () => reject(new Error('Upload network error'))
xhr.ontimeout = () => reject(new Error('Upload timed out'))
xhr.onabort = () => reject(new Error('Upload canceled'))
xhr.send(chunk)
})
}
async function run({ file }: WorkerMessage): Promise<void> {
if (file.size > MAX_FILE_SIZE) throw new Error('File exceeds the demo limit')
let expectedHash: string | undefined
if (file.size <= CHUNK_SIZE) {
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
expectedHash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
send({ type: 'processed' })
}
const created = await fetch(`/uploads?size=${file.size}`, {
method: 'POST', signal: AbortSignal.timeout(60_000),
})
if (!created.ok) throw new Error('Could not create upload')
const entry: unknown = await created.json()
if (!entry || typeof entry !== 'object' || !('id' in entry)
|| typeof entry.id !== 'string' || !/^[0-9a-f-]{36}$/.test(entry.id)) {
throw new Error('Invalid upload ID')
}
const url = `/uploads/${entry.id}`
for (let start = 0; start < file.size; start += CHUNK_SIZE) {
const chunk = file.slice(start, start + CHUNK_SIZE)
await uploadChunk(chunk, `${url}?offset=${start}`, (ratio) => {
const percent = (start + ratio * chunk.size) / file.size * 100
send({ type: 'progress', percent: Math.min(99, percent) })
})
}
send({ type: 'confirming' })
const completed = await fetch(`${url}/complete`, {
method: 'POST', signal: AbortSignal.timeout(60_000),
})
if (!completed.ok) throw new Error('Finalization failed')
const result: unknown = await completed.json()
if (!result || typeof result !== 'object' || !('sha256' in result) || !('size' in result)
|| typeof result.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(result.sha256)
|| result.size !== file.size || (expectedHash !== undefined && result.sha256 !== expectedHash)) {
throw new Error('Invalid completion acknowledgment')
}
send({ type: 'complete', sha256: result.sha256, url })
}
self.onmessage = (event: MessageEvent<WorkerMessage>) => {
run(event.data).catch(() => send({ type: 'error', message: 'Upload failed. Please try again.' }))
}
Fortschritt anzeigen und Worker freigeben
Speichern Sie src/main.ts. Das ausgewählte Objekt
File wird einmal erfasst. Deaktivierte Bedienelemente verhindern
überlappende Uploads. Jeder abschließende Ausführungspfad
beendet den Worker. Die Identitätsprüfung ignoriert auch eine
Nachricht in der Warteschlange, wenn der zugehörige Worker bereits abgebrochen wurde.
import type { WorkerMessage, WorkerResponse } from './worker-types.ts'
const fileInput = document.getElementById('fileInput')
const uploadBtn = document.getElementById('uploadBtn')
const cancelBtn = document.getElementById('cancelBtn')
const uploadProgress = document.getElementById('uploadProgress')
const statusElement = document.getElementById('status')
const checksum = document.getElementById('checksum')
const download = document.getElementById('download')
if (!(fileInput instanceof HTMLInputElement) || !(uploadBtn instanceof HTMLButtonElement)
|| !(cancelBtn instanceof HTMLButtonElement) || !(uploadProgress instanceof HTMLProgressElement)
|| !(statusElement instanceof HTMLElement) || !(checksum instanceof HTMLOutputElement)
|| !(download instanceof HTMLAnchorElement)) {
throw new Error('Missing upload controls')
}
let worker: Worker | null = null
const finish = (message: string): void => {
worker?.terminate()
worker = null
uploadBtn.disabled = false
fileInput.disabled = false
cancelBtn.disabled = true
statusElement.textContent = message
}
uploadBtn.addEventListener('click', () => {
if (worker !== null) return
const file = fileInput.files?.[0]
uploadProgress.value = 0
checksum.value = ''
download.hidden = true
download.removeAttribute('href')
if (!file) {
statusElement.textContent = 'Please select a file.'
return
}
if (file.size > 16 * 1024 * 1024) {
statusElement.textContent = 'Choose a file of at most 16 MiB.'
return
}
uploadBtn.disabled = true
fileInput.disabled = true
cancelBtn.disabled = false
statusElement.textContent = 'Preparing upload.'
try {
const current = new Worker(new URL('./upload.worker.ts', import.meta.url), { type: 'module' })
worker = current
current.onmessage = (event: MessageEvent<WorkerResponse>) => {
if (worker !== current) return
const response = event.data
switch (response.type) {
case 'processed':
statusElement.textContent = 'File hashed. Uploading.'
break
case 'progress':
uploadProgress.value = response.percent
statusElement.textContent = `Uploading: ${Math.round(response.percent)}%`
break
case 'confirming':
statusElement.textContent = 'Confirming upload.'
break
case 'complete':
checksum.value = response.sha256
download.href = response.url
download.hidden = false
uploadProgress.value = 100
finish('Upload complete.')
break
case 'error':
finish(response.message)
break
}
}
current.onerror = (event) => {
if (worker !== current) return
event.preventDefault()
finish('The upload worker failed. Please try again.')
}
current.onmessageerror = () => {
if (worker === current) finish('Could not read the upload worker response.')
}
current.postMessage({ file } satisfies WorkerMessage)
} catch {
finish('Could not start the upload worker.')
}
})
cancelBtn.addEventListener('click', () => finish('Upload canceled.'))
window.addEventListener('pagehide', () => finish('Upload stopped.'))
Kompatiblen lokalen Empfänger ausführen
Speichern Sie server.mts. Node führt dieses ES-Modul mit
nativer Entfernung der TypeScript-Typangaben aus.
Der Empfänger erstellt eine Datei mit generierter ID, nimmt Chunks am nächsten erwarteten Offset an
und stellt erst nach Prüfung der vollständigen Länge und Berechnung von SHA-256 einen Download
bereit. Er begrenzt die Nutzlast jedes Chunks auf 5 MiB, hängt Chunks auf der Festplatte an und liest
die gespeicherte Datei zur Berechnung ihrer Prüfsumme als Stream.
Dies ist ein localhost-Protokoll zu Lernzwecken mit einer Dateigrößenbegrenzung von 16 MiB. Es bietet
weder Authentifizierung, Retries, Wiederaufnahme noch Ablaufzeiten oder ein Gesamtkontingent für den
Festplattenspeicher. Dateien bleiben in received/. Bei einem Neustart gehen das
Upload-Register im Arbeitsspeicher und die Download-URLs verloren. Entfernen Sie nach dem Stoppen der
Demo nicht mehr benötigte Dateien aus diesem Verzeichnis. Der Dateiname des Clients wird nie als
Speicherpfad verwendet.
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash, randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { appendFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { parseArgs } from 'node:util'
const CHUNK_SIZE = 5 * 1024 * 1024
const MAX_FILE_SIZE = 16 * 1024 * 1024
const receivedRoot = new URL('./received/', import.meta.url)
interface Upload {
path: URL
size: number
received: number
complete: boolean
busy: boolean
}
const uploads = new Map<string, Upload>()
function reply(response: ServerResponse, status: number, value: unknown): void {
response.writeHead(status, { 'Content-Type': 'application/json' })
response.end(JSON.stringify(value))
}
async function readBody(request: IncomingMessage, limit: number): Promise<Buffer> {
const parts: Buffer[] = []
let length = 0
for await (const part of request) {
if (!(part instanceof Buffer)) throw new Error('Unexpected request body')
length += part.length
if (length > limit) throw new Error('Request body exceeds its limit')
parts.push(part)
}
return Buffer.concat(parts, length)
}
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
const url = new URL(request.url ?? '/', 'http://localhost')
if (request.method === 'GET' && (url.pathname === '/' || /^\/assets\/[\w.-]+\.(js|css)$/.test(url.pathname))) {
const path = url.pathname === '/' ? '/index.html' : url.pathname
const data = await readFile(new URL(`./dist${path}`, import.meta.url))
response.writeHead(200, {
'Content-Type': path.endsWith('.html') ? 'text/html' : path.endsWith('.css') ? 'text/css' : 'text/javascript',
})
response.end(data)
return
}
if (request.method === 'POST' && url.pathname === '/uploads') {
const sizeText = url.searchParams.get('size')
const size = sizeText !== null && /^\d+$/.test(sizeText) ? Number(sizeText) : NaN
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_FILE_SIZE) {
reply(response, 400, { error: 'Invalid file size' })
return
}
await readBody(request, 0)
const id = randomUUID()
const path = new URL(`${id}.bin`, receivedRoot)
await writeFile(path, Buffer.alloc(0), { flag: 'wx' })
uploads.set(id, { path, size, received: 0, complete: false, busy: false })
reply(response, 201, { id })
return
}
const match = /^\/uploads\/([0-9a-f-]{36})(\/complete)?$/.exec(url.pathname)
const upload = match?.[1] !== undefined ? uploads.get(match[1]) : undefined
if (!upload) {
reply(response, 404, { error: 'Upload not found' })
return
}
if (request.method === 'GET' && !match?.[2] && upload.complete) {
response.writeHead(200, { 'Content-Type': 'application/octet-stream' })
createReadStream(upload.path).on('error', () => response.destroy()).pipe(response)
return
}
if (upload.busy || upload.complete) {
reply(response, 409, { error: 'Upload is busy or already complete' })
return
}
upload.busy = true
try {
if (request.method === 'PUT' && !match?.[2]) {
const offsetText = url.searchParams.get('offset')
const offset = offsetText !== null && /^\d+$/.test(offsetText) ? Number(offsetText) : NaN
const expected = Math.min(CHUNK_SIZE, upload.size - upload.received)
if (offset !== upload.received || expected <= 0) {
reply(response, 409, { error: 'Unexpected chunk offset' })
return
}
const data = await readBody(request, expected)
if (data.length !== expected) {
reply(response, 400, { error: 'Unexpected chunk length' })
return
}
await appendFile(upload.path, data)
upload.received += data.length
reply(response, 204, null)
return
}
if (request.method === 'POST' && match?.[2] === '/complete') {
await readBody(request, 0)
if (upload.received !== upload.size || (await stat(upload.path)).size !== upload.size) {
reply(response, 409, { error: 'Upload is incomplete' })
return
}
const hash = createHash('sha256')
for await (const part of createReadStream(upload.path)) hash.update(part)
upload.complete = true
reply(response, 200, { size: upload.size, sha256: hash.digest('hex') })
return
}
reply(response, 405, { error: 'Method not allowed' })
} finally {
upload.busy = false
}
}
async function main(): Promise<void> {
const { values } = parseArgs({ options: { port: { type: 'string', default: '0' } } })
const port = Number(values.port)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port')
await readFile(new URL('./dist/index.html', import.meta.url))
await mkdir(receivedRoot, { recursive: true })
const server = createServer((request, response) => {
handle(request, response).catch(() => {
if (!response.headersSent) reply(response, 500, { error: 'Request failed' })
else response.destroy()
})
})
server.requestTimeout = 60_000
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 listening address')
console.log(`Open http://127.0.0.1:${address.port}`)
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : 'Receiver startup failed')
process.exitCode = 1
})
Fügen Sie diesen Block im Demo-Verzeichnis ein, um alle drei Umgebungen zu prüfen, die Seite und den
Worker zu bauen und den Empfänger auszuführen. Die Verkettung mit &&
verhindert, dass nach einem fehlgeschlagenen Build ein älterer Build gestartet wird. Port null fordert
beim Betriebssystem einen verfügbaren Port an. Öffnen Sie die vom Empfänger ausgegebene URL.
Stoppen Sie ihn mit Ctrl+C in diesem Terminal. Um einen festen Port zu wählen, ersetzen Sie
--port 0 beispielsweise durch --port 8000.
Ist der Port belegt, schlägt der Start fehl.
corepack yarn tsc --project tsconfig.json &&
corepack yarn tsc --project tsconfig.worker.json &&
corepack yarn tsc --project tsconfig.server.json &&
corepack yarn vite build &&
node server.mts --port 0
Wählen Sie eine kleine Datei und klicken Sie auf Upload. Sobald Upload complete. erscheint, nutzen Sie Download received file und vergleichen Sie die heruntergeladenen Bytes mit Ihrem Original. Der angezeigte SHA-256-Wert beschreibt die Datei des Empfängers. Nur der Zweig für kleine Dateien vergleicht ihn zusätzlich mit einem clientseitigen Hash. Der Abschluss bestätigt diesen lokalen Upload-Vertrag, ohne zu behaupten, dass die Datei einen Malware-Scan oder eine andere Verarbeitungspipeline durchlaufen hat.
Probieren Sie eine Datei mit mehr als 5 MiB aus, um mehrere Anfragen und einen kurzen letzten Chunk zu testen. Klicken Sie während eines laufenden Uploads auf Cancel und starten Sie danach einen weiteren Upload. Der Abbruch stoppt den Worker und seine clientseitigen Aktivitäten. Angenommene Bytes können auf dem Empfänger verbleiben. Eine vom Server bereits angenommene Finalisierung lässt sich nicht rückgängig machen, indem ihre Antwort abgebrochen wird. Ein neuer Versuch erzeugt eine neue ID. Das Aufteilen einer Datei in Chunks macht dieses Protokoll nicht wiederaufnehmbar.
Für wiederaufnehmbare Übertragungen nutzt das Tus-Plugin von Uppy das tus-Protokoll mit einem kompatiblen tus-Server. Dessen Serververtrag unterscheidet sich von dem dieser Demo.
