Online-Datei-Uploads mit Chunking und Parallel-Uploads optimieren
Um Chunks parallel hochzuladen, geben Sie jedem Chunk eine feste Position und veröffentlichen Sie die Datei erst, nachdem der Empfänger das vollständige Ergebnis geprüft hat. Hier erstellen Sie beide Seiten: Ein Browser sendet jeweils drei Chunks gleichzeitig, und ein lokaler Node.js-Server stellt die zusammengesetzte Datei zum Download bereit, nachdem er ihren SHA-256-Hash geprüft hat.
Wählen Sie einen kleinen, reproduzierbaren Upload
Dieses Beispiel richtet sich an Entwickler, die das Zusammenspiel paralleler Chunk-Uploads lernen möchten. Verwenden Sie Node.js 24.15.0 oder eine neuere, weiterhin gepflegte Version und einen aktuellen Chromium-Browser. Das Beispiel wurde unter Linux mit Node.js 24.15.0 und Chromium 145 getestet. Node.js 24 ist eine LTS-Version. Sie müssen keine Pakete installieren.
Die Seite akzeptiert eine nicht leere JPEG-, PNG- oder PDF-Datei mit bis zu 8 MiB. Diese niedrige
Grenze ist bewusst gewählt: Der Empfänger speichert Dateien im Arbeitsspeicher, und der Browser
berechnet Hashes ganzer Dateien mit
crypto.subtle.digest().
Diese API akzeptiert keine Streaming-Eingabe. Dies ist eine lokale Übung zum Protokoll, kein
Speicherdienst für große Dateien. Bei einem Neustart des Servers gehen alle Dateien verloren.
Wenn Sie die Seite neu laden, geht der Upload-Zustand des Clients verloren.
Geben Sie jedem Chunk eine Position
Verwenden Sie Chunks mit 256 KiB, beginnend mit der Nummer null. Eine Datei mit 524.295 Bytes hat drei
Chunks: zwei mit 262.144 Bytes und einen mit sieben Bytes.
Blob.slice(start, end)
schließt die Endposition aus, sodass benachbarte Abschnitte weder überlappen noch Lücken lassen.
Das Protokoll umfasst fünf Vorgänge:
POST /uploadsreserviert eine Dateigröße und einen SHA-256-Hash und gibt eine vom Server erzeugte ID zurück.PUT /uploads/:id/:indexschreibt den unverarbeiteten Chunk an seine nummerierte Position. Eine identische Wiederholung ist erfolgreich; ein anderer Anfragekörper an einer bereits akzeptierten Position schlägt mit HTTP 409 fehl.POST /uploads/:id/completeprüft, ob jede Position vorhanden ist und der Hash der gesamten Datei übereinstimmt. Eine Wiederholung dieses Vorgangs gibt denselben Hash zurück.GET /uploads/:id/filegibt Bytes erst nach dem Abschluss zurück. Der Browser prüft auch diese Bytes.DELETE /uploads/:identfernt den Upload einschließlich einer abgeschlossenen Datei.
Die Reihenfolge des Eintreffens bestimmt nicht die Reihenfolge in der Datei. Eine verlorene Bestätigung kann zu einer doppelten Anfrage führen. Deshalb müssen das Schreiben von Chunks und der Abschluss idempotent sein. Die Erstellung wird nicht automatisch wiederholt: Geht die Antwort auf die Erstellung verloren, bleibt eine unbekannte ID zurück, die der Server später ablaufen lässt.
Erstellen Sie die Seite
Erstellen Sie ein neues, leeres Verzeichnis und speichern Sie die folgenden drei Dateien darin.
Speichern Sie diese erste Datei als index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Parallel chunk upload</title>
</head>
<body>
<main>
<h1>Parallel chunk upload</h1>
<label for="file">JPEG, PNG, or PDF, up to 8 MiB</label>
<input id="file" type="file" accept="image/jpeg,image/png,application/pdf" />
<button id="upload" type="button">Upload</button>
<button id="cancel" type="button" disabled>Cancel</button>
<p id="status" role="status">Choose a file.</p>
<a id="download" hidden download="upload.bin">Download verified file</a>
</main>
<script type="module" src="/client.js"></script>
</body>
</html>
Empfangen und veröffentlichen Sie die Chunks
Speichern Sie dies als server.mts. Die Erweiterung
.mts macht die Datei auch innerhalb eines CommonJS-Projekts zu einem
ES-Modul. Der Server verwendet
stripTypeScriptTypes()
von Node, um die nächste Datei als JavaScript auszuliefern. In Node.js 24.15.0 gibt diese API eine
Warnung zu ihrem experimentellen Status aus.
Es dürfen vier Uploads gleichzeitig existieren, einschließlich abgeschlossener Uploads. Jeder läuft 60 Sekunden nach der Erstellung ab, auch wenn noch Anfragen eintreffen. Sechs Anfragen dürfen aktiv sein, jeweils mit einer Frist von 10 Sekunden. Eingehende Anfragekörper werden gelesen, bevor Sitzungsdaten nachgeschlagen werden. So kann ein noch ausstehender Anfragekörper eine gelöschte Sitzung weder am Leben halten noch später in sie schreiben.
import { createHash, randomUUID } from 'node:crypto'
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 = 256 * 1024
const MAX_FILE_SIZE = 8 * 1024 * 1024
const MAX_UPLOADS = 4
const MAX_REQUESTS = 6
const TTL_MS = 60_000
type Upload = {
bytes: Buffer
digest: string
seen: Set<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
// Leave the socket open long enough to send a useful error response.
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 html = await readFile(new URL('./index.html', import.meta.url))
const client = stripTypeScriptTypes(
await readFile(new URL('./client.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 body = await readBody(req, method === 'PUT' ? CHUNK_SIZE : 0)
expire()
res.setHeader('Cache-Control', 'no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
if (method === 'GET' && (path === '/' || path === '/client.js')) {
res.setHeader('Content-Type', path === '/' ? 'text/html; charset=utf-8' : 'text/javascript')
res.end(path === '/' ? html : client)
return
}
if (method === 'POST' && path === '/uploads') {
const length = req.headers['upload-length']
const digest = req.headers['upload-sha256']
const size = Number(length)
if (typeof length !== 'string' || !/^[1-9]\d*$/.test(length) ||
!Number.isSafeInteger(size) || size > MAX_FILE_SIZE) {
throw new HttpError(400, 'File must be between 1 byte and 8 MiB')
}
if (typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest)) {
throw new HttpError(400, 'Expected a SHA-256 digest')
}
if (uploads.size >= MAX_UPLOADS) throw new HttpError(503, 'Upload capacity reached')
const id = randomUUID()
uploads.set(id, {
bytes: Buffer.alloc(size), digest, seen: new Set(), complete: false,
expires: Date.now() + TTL_MS,
})
res.writeHead(201).end(id)
return
}
const match = /^\/uploads\/([a-f0-9-]{36})(?:\/(\d+|complete|file))?$/.exec(path)
if (!match) throw new HttpError(404, 'Unknown route')
const [, id, operation] = match
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')
const count = Math.ceil(upload.bytes.length / CHUNK_SIZE)
if (method === 'PUT' && operation !== undefined && /^\d+$/.test(operation)) {
const index = Number(operation)
if (!Number.isSafeInteger(index) || index >= count) {
throw new HttpError(400, 'Invalid chunk index')
}
const start = index * CHUNK_SIZE
const target = upload.bytes.subarray(start, Math.min(start + CHUNK_SIZE, upload.bytes.length))
if (body.length !== target.length) throw new HttpError(400, 'Wrong chunk length')
if (upload.seen.has(index)) {
if (!body.equals(target)) throw new HttpError(409, 'Conflicting chunk')
} else {
body.copy(target)
upload.seen.add(index)
}
res.writeHead(204).end()
return
}
if (method === 'POST' && operation === 'complete') {
if (upload.seen.size !== count) throw new HttpError(409, 'Missing chunks')
if (createHash('sha256').update(upload.bytes).digest('hex') !== upload.digest) {
throw new HttpError(422, 'Digest mismatch')
}
upload.complete = true
res.end(upload.digest)
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 >= MAX_REQUESTS) {
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()
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 Sitzungspuffer belegen zusammen höchstens 32 MiB. Unabhängig davon kann eine zugelassene Anfrage einen Eingangspuffer mit 256 KiB halten oder eine ausgehende Datei mit 8 MiB referenzieren, bis ihre Antwort geschlossen wird. Löschen und Ablauf geben den Slot dieser Anfrage nicht vorzeitig frei. Dies sind Grenzen für Anwendungspuffer, keine Obergrenze für den gesamten Speicherverbrauch von Node oder Vorgaben für den Zeitpunkt der Speicherbereinigung. Header-Timeouts und das Verbindungslimit begrenzen auch die wartenden Verbindungen dieses lokalen Servers. Siehe dazu die HTTP-Dokumentation von Node.js.
Senden Sie höchstens drei Chunks gleichzeitig
Speichern Sie dies als client.ts. Jeder Stapel wartet, bis alle seine Anfragen
beendet sind, bevor der nächste startet. Ein langsamer Chunk hält daher seinen Stapel auf. Dafür
lässt sich die Grenze leicht nachvollziehen, und Retries können die Anzahl aktiver Chunk-Anfragen
nicht vervielfachen.
const CHUNK_SIZE = 256 * 1024
const MAX_FILE_SIZE = 8 * 1024 * 1024
const CONCURRENCY = 3
const input = document.getElementById('file')
const uploadButton = document.getElementById('upload')
const cancelButton = document.getElementById('cancel')
const status = document.getElementById('status')
const download = document.getElementById('download')
if (!(input instanceof HTMLInputElement) || !(uploadButton instanceof HTMLButtonElement) ||
!(cancelButton instanceof HTMLButtonElement) || !(status instanceof HTMLParagraphElement) ||
!(download instanceof HTMLAnchorElement)) throw new Error('Missing upload controls')
class HttpError extends Error {
status: number
constructor(status: number) { super(`HTTP ${status}`); this.status = status }
}
async function validateFile(file: File): Promise<void> {
if (file.size === 0 || file.size > MAX_FILE_SIZE) throw new Error('Choose a file between 1 byte and 8 MiB.')
const signatures: Record<string, number[]> = {
'image/jpeg': [0xff, 0xd8, 0xff],
'image/png': [0x89, 0x50, 0x4e, 0x47],
'application/pdf': [0x25, 0x50, 0x44, 0x46],
}
const expected = signatures[file.type]
if (!expected) throw new Error('Choose a JPEG, PNG, or PDF.')
const header = new Uint8Array(await file.slice(0, 4).arrayBuffer())
if (!expected.every((byte, index) => header[index] === byte)) throw new Error('Invalid file signature.')
}
async function sha256(blob: Blob): Promise<string> {
const hash = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer())
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, '0')).join('')
}
function waitForRetry(ms: number, signal: AbortSignal): Promise<void> {
signal.throwIfAborted()
return new Promise((resolve, reject) => {
const onAbort = () => { clearTimeout(timer); reject(signal.reason) }
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
signal.addEventListener('abort', onAbort, { once: true })
})
}
async function request(
path: string, options: RequestInit, signal: AbortSignal, retry = true,
): Promise<Blob> {
for (let attempt = 0; ; attempt++) {
signal.throwIfAborted()
try {
const response = await fetch(path, {
...options,
signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]),
headers: { ...options.headers, 'X-Upload-Demo': '1' },
})
if (!response.ok) throw new HttpError(response.status)
return await response.blob()
} catch (error) {
signal.throwIfAborted()
if (!retry || attempt === 2 ||
(error instanceof HttpError && ![408, 429, 500, 502, 503, 504].includes(error.status))) {
throw error
}
await waitForRetry(250 * 2 ** attempt, signal)
}
}
}
let running: AbortController | null = null
cancelButton.addEventListener('click', () => running?.abort())
input.addEventListener('change', () => {
if (running) return
download.hidden = true
status.textContent = 'Ready to upload.'
})
uploadButton.addEventListener('click', async () => {
if (running) return
const file = input.files?.[0]
if (!file) { status.textContent = 'Choose a file.'; return }
const controller = new AbortController()
const signal = controller.signal
running = controller
input.disabled = uploadButton.disabled = true
cancelButton.disabled = false
download.hidden = true
let id: string | undefined
let verified = false
try {
status.textContent = 'Checking file…'
await validateFile(file)
const digest = await sha256(file)
signal.throwIfAborted()
// Finish creation so cancellation can learn the ID and delete it.
id = await (await request('/uploads', {
method: 'POST', headers: { 'Upload-Length': String(file.size), 'Upload-SHA256': digest },
}, new AbortController().signal, false)).text()
signal.throwIfAborted()
const count = Math.ceil(file.size / CHUNK_SIZE)
let acknowledged = 0
for (let first = 0; first < count; first += CONCURRENCY) {
signal.throwIfAborted()
const batch = []
for (let index = first; index < Math.min(first + CONCURRENCY, count); index++) {
const chunk = file.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE)
batch.push(request(`/uploads/${id}/${index}`, { method: 'PUT', body: chunk }, signal).then(() => {
signal.throwIfAborted()
acknowledged += chunk.size
status.textContent = `${Math.round(100 * acknowledged / file.size)}% of bytes acknowledged.`
}))
}
const results = await Promise.allSettled(batch)
const failure = results.find((result) => result.status === 'rejected')
if (failure) throw failure.reason
}
status.textContent = 'All chunks acknowledged. Verifying…'
const confirmation = await request(`/uploads/${id}/complete`, { method: 'POST' }, signal)
if (await confirmation.text() !== digest) throw new Error('Unexpected confirmation.')
const result = await request(`/uploads/${id}/file`, {}, signal)
if (result.size !== file.size || await sha256(result) !== digest) throw new Error('Downloaded bytes differ.')
signal.throwIfAborted()
verified = true
download.href = `/uploads/${id}/file`
download.hidden = false
status.textContent = 'Upload verified. Download is available until the upload expires.'
} catch (error) {
status.textContent = signal.aborted ? 'Upload canceled.' :
`Upload failed: ${error instanceof Error ? error.message : 'Please try again.'}`
} finally {
if (id && !verified) {
try {
await request(`/uploads/${id}`, { method: 'DELETE' }, new AbortController().signal)
} catch {
status.textContent += ' Cleanup could not be confirmed; the server will expire the upload.'
}
}
running = null
input.disabled = uploadButton.disabled = false
cancelButton.disabled = true
}
})
Die Prozentangabe zählt bestätigte Bytes, einschließlich eines kürzeren letzten Chunks. Sie misst nicht die Bytes, die gerade übertragen werden. Selbst 100 % bedeuten noch keinen Erfolg: Der Abschluss und die anschließende Download-Verifizierung müssen erfolgreich sein, bevor der Link erscheint. Mehr parallele Anfragen können den Durchsatz verbessern, wenn eine einzelne Anfrage Kapazität ungenutzt lässt. Sie verursachen aber auch zusätzlichen Aufwand. Messen Sie mit Ihrem eigenen Empfänger und Netzwerk; diese Demo verspricht keine bestimmte Geschwindigkeit.
Starten und unterbrechen Sie einen Upload
Führen Sie im Verzeichnis mit den drei gespeicherten Dateien Folgendes aus:
node server.mts
Öffnen Sie die ausgegebene URL http://127.0.0.1:PORT. Öffnen Sie
index.html nicht direkt. Wählen Sie eine Datei aus und klicken Sie auf
Upload. Nach
All chunks acknowledged. Verifying… erscheint der Link
Download verified file. Die Seite hat die Datei vom Server
abgerufen und geprüft. Ein Klick auf den Link startet einen separaten Download, dessen Speicherort
Ihr Browser bestimmt. Der Server schlägt immer upload.bin als Dateinamen für
den Download vor.
Solange die Verarbeitung aussteht, sind die Dateiauswahl und der Upload-Button deaktiviert. Klicken Sie auf Cancel, um Anfragen und Wartezeiten vor Retries abzubrechen. Die Erstellung und die lokale Hash-Berechnung werden beendet, bevor der Abbruch berücksichtigt wird. Die anschließende Bereinigung versucht dann, die bekannte ID zu löschen. Die Bedienelemente bleiben deaktiviert, bis die Bereinigung beendet ist. Schlägt sie fehl oder geht die Antwort auf die Erstellung verloren, können Daten bis zum Ablauf erhalten bleiben. Das Abbrechen einer Anfrage kann einen bereits vom Server verarbeiteten Abschluss nicht rückgängig machen. Deshalb löscht die Bereinigung auch abgeschlossene Uploads.
AbortSignal.any() und AbortSignal.timeout()
kombinieren den Abbruch durch den Nutzer mit einem Timeout von fünf Sekunden pro Anfrageversuch.
Bei Netzwerkfehlern und den aufgeführten temporären HTTP-Statuscodes gibt es höchstens drei
Versuche, mit Wartezeiten von 250 ms und 500 ms vor den Wiederholungen. Andere HTTP-Fehler führen
sofort zum Fehlschlag. Diese Browser-Timeouts zählen die aktive Zeit und können pausieren, während
die Seite angehalten ist. Die Frist und der Ablauf auf dem Server sind davon unabhängig.
Eine Antwort mit 409 bedeutet, dass Chunks fehlen oder eine doppelte Anfrage einen Konflikt auslöst.
422 bedeutet, dass der Hash der zusammengesetzten Datei falsch ist. 503 kann bedeuten, dass alle
vier Upload-Slots belegt sind, einschließlich abgeschlossener Dateien. Warten Sie auf den Ablauf
und starten Sie erneut. Fehlende oder abgelaufene IDs liefern 404 zurück. Stoppen Sie den Server
nach Abschluss mit Ctrl+C. Um einen bestimmten freien Port zu verwenden, geben Sie dessen Nummer
nach server.mts an. Bei einem belegten Port wird das Programm mit einem Fehler
beendet, statt eine einsatzbereite URL auszugeben.
Halten Sie die lokale Grenze explizit
Der Empfänger bindet sich nur an 127.0.0.1, prüft den Host und den Ursprung des
Browsers, verlangt einen benutzerdefinierten Header für Änderungen und verwendet übergebene
Dateinamen niemals als Pfad. Er liefert Bytes als Anhänge aus, ohne ihren Inhalt zu interpretieren.
Die Signaturprüfung im Client erkennt eine versehentlich ausgewählte falsche Datei. Weder ein
passendes Präfix noch ein übereinstimmender Hash beweist, dass eine Datei sicher ist. Der Empfänger
erzwingt unabhängig davon Längen, Positionen, Kapazität und Hash, validiert jedoch weder die
Bild-/PDF-Struktur noch führt er einen Malware-Scan durch.
Stellen Sie diesen Server nicht als öffentlichen Upload-Dienst bereit. Ein produktiv eingesetzter Empfänger benötigt Authentifizierung, nutzerspezifische Autorisierung und Kontingente, HTTPS, dauerhaften Speicher und eine Inhaltsvalidierung, die auf seine Verbraucher abgestimmt ist. Die IDs hier isolieren lokale Uploads; sie sind kein Berechtigungssystem für Konten.
Wählen Sie den nächsten Schritt
Probieren Sie eine Datei aus, die etwas größer als zwei Chunks ist, und beobachten Sie die Anfragen im Netzwerkbereich Ihres Browsers. Der letzte Chunk sollte kleiner sein, und die Datei-URL sollte erst nach dem Abschluss nutzbar werden. Diese Grenze sollten Sie beibehalten, wenn Sie den Arbeitsspeicher durch dauerhaften Speicher ersetzen.
Um Uploads nach dem Neuladen fortsetzen zu können, verwenden Sie ein gepflegtes Protokoll und
speichern Sie genügend Zustandsdaten dauerhaft, um sie mit dem Empfänger abzugleichen.
tus definiert die Ermittlung des Offsets mit
HEAD und die Fortsetzung mit PATCH. Parallele
Teil-Uploads verwenden die optionale Erweiterung zum Zusammenfügen. Diese Demo mit nummerierten
Chunks ist ein separates Protokoll, kein tus-Client.
Das Tus-Plugin von Uppy ist ein praktischer nächster Schritt, wenn
Sie einen gepflegten Browser-Client für einen kompatiblen tus-Server möchten.
