Ausfallsichere Datei-Uploads mit Background Sync
Zuverlässige Wiederholungsversuche beginnen mit dauerhaft gespeicherten Daten. Dieses Beispiel speichert kleine Dateien vor einem Upload-Versuch in IndexedDB und arbeitet die Warteschlange anschließend von einer Seite oder einem Service Worker aus erneut ab. Es behält die Erläuterungen zu Chunking und Workern aus dem ursprünglichen Artikel bei, grenzt diese Techniken aber von Wiederholungsversuchen auf Basis dauerhaft gespeicherter Daten ab.
Background Sync arbeitet nach dem Best-Effort-Prinzip: Browser können einen Worker stoppen, Website-Daten löschen oder die Planung eines weiteren Versuchs ablehnen. Die Funktion erfordert einen sicheren Kontext und ist nicht überall verfügbar. Prüfen Sie ihre Verfügbarkeit und bieten Sie weiterhin einen expliziten Wiederholungsversuch im Vordergrund an, wie im MDN-Leitfaden zur Hintergrundsynchronisierung beschrieben.
Uploads in Teilstücken verstehen
Chunking reduziert die Datenmenge, die nach einer Unterbrechung erneut gesendet werden muss. Parallele Übertragungen können bei einer bestimmten Verbindung helfen, zusätzliche Anfragen können aber auch die Konkurrenz um Ressourcen verstärken. Weder das Aufteilen eines Blobs noch das Erstellen eines Workers macht eine Warteschlange dauerhaft. Ein File-Objekt, das nur im Arbeitsspeicher der Seite gehalten wird, geht beim Neuladen verloren.
Uploads in Teilstücken implementieren
Der hier ausführbare Ablauf akzeptiert bewusst höchstens zehn Dateien mit jeweils 10 MiB und sendet jede Datei in einer einzigen Anfrage. Bei Wiederholungsversuchen werden ganze Dateien erneut übertragen. Verwenden Sie für größere Dateien ein fortsetzbares Protokoll und speichern Sie dessen Upload-URL und Offset zusammen mit dem Blob dauerhaft; fügen Sie keine anonymen Teilstücke auf einem Server zusammen.
Die folgenden vollständigen Browserdateien setzen eine bestehende Anwendung mit Authentifizierung unter demselben Ursprung voraus, deren Server die folgenden Anforderungen erfüllt. Sie implementieren weder Authentifizierung noch Speicherung oder ein Upload-Backend:
| Endpunkt | Anforderungen |
|---|---|
GET /api/upload-session | Geben Sie {owner, csrf} für die aktuelle Cookie-Sitzung mit Cache-Control: no-store zurück; erlauben Sie niemals ursprungsübergreifende Lesezugriffe. Geben Sie bei abgemeldeten Nutzern 401 zurück. |
POST /api/queued-uploads | Verlangen Sie die Sitzung, einen gültigen Wert für X-CSRF-TOKEN und einen zu dieser Sitzung passenden Wert für X-Upload-Owner. Akzeptieren Sie das Multipart-Feld file und eine UUID als Idempotency-Key. |
Der Server muss (owner, key) in einem dauerhaften Speicher mit dem Hashwert und der
Größe des Inhalts verknüpfen, gleichzeitige Versuche serialisieren, geänderte Inhalte mit 409 ablehnen
und bei einer erfolgreichen Wiederholung denselben Wert für {id} zurückgeben.
Sowohl beim ersten Erfolg als auch bei jeder erfolgreichen Wiederholung muss
id exakt dem übermittelten Wert Idempotency-Key entsprechen
(entry.id in queue.js), nicht einer vom Server erzeugten Objekt-ID.
Setzen Sie dort das Dateilimit, die Medienrichtlinie, das Zeitlimit für Anfragen, das Kontingent pro
Nutzer und die Ratenbegrenzung durch. Speichern Sie die Bytes zunächst ohne öffentlichen Zugriff;
validieren und scannen Sie sie nach Bedarf und veröffentlichen Sie dann das Objekt und seine
Bestätigung atomar. Geben Sie 200 oder 201 erst nach diesem Commit zurück, niemals 202. Bewahren
Sie Bestätigungen mindestens für die Lebensdauer der Warteschlange von 24 Stunden zuzüglich einer
Nachfrist für Wiederholungsversuche auf. Lehnen Sie abgelaufene Schlüssel nach diesem Zeitraum ab,
anstatt eine alte Wiederholung als neuen Upload zu behandeln. Bewahren Sie nach dem Entfernen der
Bestätigungen für jeden Wert von (owner, key) einen dauerhaften Ablaufdatensatz auf
und prüfen Sie ihn, bevor Sie einen Upload akzeptieren, damit ein abgelaufener Schlüssel niemals
wieder als neu gelten kann. Es wird kein Auth Secret eines Drittanbieters an den Browser gesendet.
Parallele Uploads mit Web Workern
Fetch führt Netzwerk-Ein- und -Ausgaben bereits asynchron aus. Dieses Beispiel verwendet jeweils nur eine Anfrage und einen Web Lock für den gesamten Ursprung, auch über Tabs und den Service Worker hinweg. So wird vermieden, für jedes Teilstück ohne Obergrenze einen Worker zu erstellen. Außerdem bleiben keine dedizierten Worker übrig, die bei einem Fehler beendet werden müssten. Ein Service Worker kann dennoch zwischen dem Server-Commit und dem lokalen Löschen beendet werden; der stabile Idempotenzschlüssel macht diese Wiederholung sicher.
Die Seite benötigt IndexedDB und Web Locks. Falls eine der beiden Funktionen nicht verfügbar ist, zeigen Sie einen deutlichen Hinweis auf die fehlende Unterstützung an und verwenden Sie stattdessen den direkten Upload-Ablauf Ihrer Anwendung. Background Sync selbst ist optional.
Das tus-Protokoll für fortsetzbare Uploads verwenden
tus ermöglicht die Aushandlung des Offsets bei teilweise abgeschlossenen Uploads. Der Server benötigt weiterhin eine Zuordnung zum Eigentümer, Kontingente und eine Zwischenspeicherung ohne öffentlichen Zugriff. Eine tus-Upload-URL ist keine öffentliche Download-URL, und das lokale Speichern der URL bewahrt nicht die Bytes der Originaldatei auf. Das Client-Beispiel für fortsetzbare Uploads zeigt eine Alternative zum vollständigen erneuten Übertragen kleiner Dateien.
Background Sync integrieren
Installieren Sie den IndexedDB-Promise-Wrapper und den Bundler in den festgelegten Versionen in einem separaten Client-Projekt:
corepack yarn add --exact idb@8.0.3
corepack yarn add --dev --exact esbuild@0.27.3
Erstellen Sie queue.js. Innerhalb einer Transaktion wird nur auf IndexedDB-Anfragen
gewartet; die Netzwerkanfrage endet, bevor eine neue Löschtransaktion beginnt. Das Warten auf
tx.done stellt sicher, dass Schreibvorgänge festgeschrieben wurden.
Den Unterschied erläutert die
Transaktionsdokumentation von idb.
import { openDB } from 'idb'
const maxSize = 10 * 1024 * 1024
const lifetime = 24 * 60 * 60 * 1000
const lockName = 'durable-file-uploads'
async function database() {
return openDB('durable-file-uploads', 1, {
upgrade(db) {
db.createObjectStore('uploads', { keyPath: 'id' })
},
})
}
export async function enqueue(file, owner) {
if (!owner || !(file instanceof File) || file.size === 0 || file.size > maxSize) {
throw new Error('Choose a file between 1 byte and 10 MiB while signed in.')
}
const db = await database()
try {
const tx = db.transaction('uploads', 'readwrite')
const id = crypto.randomUUID()
// Observe request and transaction failures together, including quota-induced aborts.
await Promise.all([
(async () => {
// Counting and inserting in one transaction also bounds concurrent tabs.
if ((await tx.store.count()) >= 10) {
throw new Error('The queue is full. Retry or discard queued files first.')
}
await tx.store.add({ id, owner, file, name: file.name, created: Date.now() })
})(),
tx.done,
])
return id
} finally {
db.close()
}
}
export async function drain(signal = new AbortController().signal) {
return navigator.locks.request(lockName, { signal }, async () => {
const db = await database()
try {
// The count is bounded at ten. No readwrite transaction spans a fetch.
const entries = await db.getAll('uploads')
if (entries.length === 0) return
const sessionResponse = await fetch('/api/upload-session', {
credentials: 'same-origin',
cache: 'no-store',
redirect: 'error',
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
})
if (!sessionResponse.ok) throw new Error('Sign in again before retrying.')
const session = await sessionResponse.json()
if (
typeof session.owner !== 'string' ||
!session.owner ||
typeof session.csrf !== 'string' ||
!session.csrf
) {
throw new Error('Invalid upload session.')
}
for (const entry of entries) {
signal.throwIfAborted()
if (entry.owner !== session.owner) continue
if (Date.now() - entry.created >= lifetime) {
throw new Error('A queued file expired. Discard it before retrying.')
}
const body = new FormData()
body.append('file', entry.file, entry.name)
const response = await fetch('/api/queued-uploads', {
method: 'POST',
credentials: 'same-origin',
redirect: 'error',
body,
headers: {
'X-CSRF-TOKEN': session.csrf,
'X-Upload-Owner': entry.owner,
'Idempotency-Key': entry.id,
},
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
})
if (!response.ok || ![200, 201].includes(response.status)) {
throw new Error(`Upload not confirmed (HTTP ${response.status}).`)
}
const receipt = await response.json()
if (receipt.id !== entry.id) throw new Error('Invalid upload receipt.')
const tx = db.transaction('uploads', 'readwrite')
await Promise.all([tx.store.delete(entry.id), tx.done])
}
} finally {
db.close()
}
})
}
export async function discard(owner) {
// Serialize with uploads so a deletion cannot race a foreground or background replay.
await navigator.locks.request(lockName, async () => {
const db = await database()
try {
const tx = db.transaction('uploads', 'readwrite')
await Promise.all([
(async () => {
for (const entry of await tx.store.getAll()) {
if (entry.owner === owner) await tx.store.delete(entry.id)
}
})(),
tx.done,
])
} finally {
db.close()
}
})
}
Erstellen Sie upload-sw.js. Wird waitUntil abgelehnt, bleibt die
Warteschlange erhalten, und der Browser erfährt, dass der Synchronisierungsversuch fehlgeschlagen ist.
Ein späterer Wiederholungsversuch ruft ein neues CSRF-Token ab, anstatt Zugangsdaten dauerhaft zu
speichern.
import { drain } from './queue.js'
self.addEventListener('install', (event) => event.waitUntil(self.skipWaiting()))
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()))
self.addEventListener('sync', (event) => {
if (event.tag === 'file-upload-sync') event.waitUntil(drain())
})
Geben Sie auf Ihrer authentifizierten, serverseitig gerenderten Seite die opake ID des aktuellen
Nutzers im Attribut data-upload-owner von <html> aus und maskieren Sie
den Wert dabei korrekt. Binden Sie diese Bedienelemente und das kompilierte Modul ein:
<label>File <input id="file" type="file" /></label>
<button id="queue" type="button">Queue file</button>
<button id="retry" type="button">Retry queued files</button>
<button id="stop" type="button">Stop foreground retry</button>
<button id="discard" type="button">Discard my queued files</button>
<p id="status" role="status"></p>
<script type="module" src="/upload-page.js"></script>
Erstellen Sie upload-page.js. Das Speichern gelingt auch dann, wenn die Registrierung
oder die Planung der Synchronisierung abgelehnt wird; der explizite Wiederholungsversuch verwendet
dieselbe Warteschlange ohne Background Sync.
import { discard, drain, enqueue } from './queue.js'
const owner = document.documentElement.dataset.uploadOwner
const input = document.getElementById('file')
const status = document.getElementById('status')
let controller = null
async function retry() {
if (controller) return
controller = new AbortController()
try {
await drain(controller.signal)
status.textContent = 'Retry finished. Other accounts’ files remain queued.'
} catch {
status.textContent =
'Retry stopped or failed. Sign in as the original owner and retry, or discard expired files.'
} finally {
controller = null
}
}
document.getElementById('queue').onclick = async () => {
const file = input.files?.[0]
if (!file) return
try {
await enqueue(file, owner)
input.value = ''
status.textContent = 'Saved locally. Use Retry queued files to upload now.'
} catch {
status.textContent =
'Could not save. Check the 10 MiB file limit, ten-file queue limit, and available storage.'
return
}
try {
if ('serviceWorker' in navigator) {
await navigator.serviceWorker.register('/upload-sw.js')
const registration = await navigator.serviceWorker.ready
if ('sync' in registration) await registration.sync.register('file-upload-sync')
}
} catch {
status.textContent = 'Saved locally. Background retry unavailable; use Retry queued files.'
}
}
document.getElementById('retry').onclick = retry
document.getElementById('stop').onclick = () => controller?.abort()
document.getElementById('discard').onclick = async () => {
controller?.abort()
try {
await discard(owner)
status.textContent = 'Local queue discarded. Already accepted uploads remain on the server.'
} catch {
status.textContent = 'Could not discard queued files.'
}
}
if (!owner || !('indexedDB' in globalThis) || !navigator.locks) {
document.getElementById('queue').disabled = true
document.getElementById('retry').disabled = true
document.getElementById('discard').disabled = true
status.textContent = 'Durable uploads require sign-in, IndexedDB, and Web Locks.'
}
Bündeln Sie beide Einstiegspunkte und liefern Sie anschließend public/ vom
HTTPS-Ursprung Ihrer authentifizierten Anwendung aus:
corepack yarn esbuild upload-page.js --bundle --format=esm --outfile=public/upload-page.js
corepack yarn esbuild upload-sw.js --bundle --format=iife --outfile=public/upload-sw.js
Bewährte Verfahren
Behandeln Sie IndexedDB als lokale Kopie, die Kontingenten unterliegt und gelöscht werden kann. Fragen Sie Nutzer, bevor Sie sensible Dateien auf gemeinsam genutzten Geräten aufbewahren. Verwerfen Sie beim Abmelden die Warteschlange des bisherigen Kontos, bevor Sie das Konto wechseln; der Server muss einen veralteten Eigentümer-Header weiterhin ablehnen. Das Stoppen von Wiederholungsversuchen im Vordergrund beendet keinen separat geplanten Hintergrundversuch und macht keinen bereits festgeschriebenen Upload rückgängig. Ein serverseitiger Abbruch erfordert einen eigenen authentifizierten, serialisierten Vorgang.
Prüfen Sie das Offline-Speichern, das Neuladen, Fehler durch ausgeschöpfte Kontingente, abgelaufene Sitzungen, Kontowechsel, HTTP 429/500, fehlerhafte Bestätigungen und den Fall, dass ein Worker direkt nach dem Server-Commit gestoppt wird. Bei dauerhaften Validierungsfehlern muss der Nutzer die Warteschlange verwerfen und die Datei korrigieren; Wiederholungsversuche beheben keine ungültigen Eingaben.
Fazit
Dauerhaftigkeit entsteht durch festgeschriebene IndexedDB-Schreibvorgänge und eine idempotente Veröffentlichung auf dem Server. Background Sync bietet eine zusätzliche Gelegenheit für einen Wiederholungsversuch; das Bedienelement im Vordergrund bleibt unverzichtbar. Kombinieren Sie für größere Uploads eine dauerhafte Dateiquelle mit einem fortsetzbaren Serverprotokoll, anstatt die Anzahl der Worker zu erhöhen, ohne die Auswirkungen zu messen.
