Robuste Datei-Uploads mit Background Sync
Speichern Sie die Datei, bevor Sie versuchen, sie zu senden. Diese Anleitung stellt eine kleine Datei in IndexedDB in eine Warteschlange, erhält sie beim Neuladen der Seite im Offline-Modus und liefert sie an einen lokalen authentifizierten Empfänger. Ein stabiler Schlüssel und eine Empfangsbestätigung, die an die Bytes der Datei gebunden ist, machen Wiederholungen sicher, wenn der Server den Upload gespeichert hat, seine Antwort aber nie angekommen ist.
Background Sync gibt einem Service Worker eine weitere Gelegenheit für einen erneuten Versuch. Dafür ist ein sicherer Kontext erforderlich, und die Browserunterstützung ist begrenzt. Die Zustellung nach dem Schließen des Browsers ist nicht garantiert. Behalten Sie den Button für einen erneuten Versuch im Vordergrund bei und bedenken Sie, dass der Browser gespeicherte Daten entfernen kann.
Uploads in Teilstücken verstehen
Dieses Beispiel sendet bei einem erneuten Versuch die gesamte Datei: ein Byte bis 10 MiB, höchstens 10 Dateien in der Warteschlange über alle Konten hinweg und ein Zeitfenster von 24 Stunden für erneute Versuche. Das Aufteilen in Teilstücke reduziert die erneute Übertragung nach einer Unterbrechung, erhält aber keine Datei, die nur im Arbeitsspeicher der Seite liegt. Bei großen Dateien handelt tus Offsets aus; Sie müssen weiterhin die ursprünglichen Bytes vorhalten und den Server authentifizieren.
Die Anleitung für einen Client mit fortsetzbaren Uploads behandelt diese separate Aufgabe. Ihr Serververtrag unterscheidet sich von dem des folgenden Empfängers für Rohbytes.
Fetch führt Netzwerk-I/O bereits asynchron aus. Hier serialisiert ein Web Lock die erneuten Versuche von Seite und Service Worker innerhalb des gesamten Ursprungs. Dedizierte Web Worker würden weder Persistenz hinzufügen noch die Veröffentlichung auf dem Server atomar machen.
Das lokale Projekt einrichten
Verwenden Sie Node.js 24.15.0 mit verfügbarem Corepack, Yarn 4.12.0 und einen Browser mit IndexedDB, Web Locks und Service Workern. Der Browserablauf wurde mit Chromium 145 getestet. Verwenden Sie exakt den Loopback-Ursprung, den der Server ausgibt; HTTP auf Loopback gilt als sicherer Kontext. Für die Bereitstellung sind HTTPS und die Authentifizierung Ihrer Anwendung erforderlich.
Fügen Sie dies in Bash im übergeordneten Verzeichnis ein, in dem die Demo entstehen soll. Das Skript lehnt ein bereits vorhandenes Projektverzeichnis ab und lässt Ihre Shell sowohl bei Erfolg als auch bei einem Fehler im ursprünglichen Verzeichnis. Die lokale Lockdatei und die explizite Bundler-Konfiguration isolieren das Projekt von einem umschließenden Projekt.
(
mkdir background-upload-demo &&
cd background-upload-demo &&
printf '%s\n' '{"name":"background-upload-demo","private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn add --exact idb@8.0.3 esbuild@0.27.3
)
Speichern Sie die folgenden Dateien in background-upload-demo. Erstellen Sie darin das
Verzeichnis public, wenn Sie public/index.html speichern.
Sie benötigen weder eine vorhandene Anwendung noch ein Cloud-Konto oder einen Upload-Endpunkt.
Einen Empfänger starten, der Bytes und Bestätigungen gemeinsam festschreibt
Speichern Sie server.mts. Der Empfänger lauscht nur auf Loopback und gibt beim
Start neue Passwörter für die beiden Demokonten aus. Die Anmeldung erstellt eine Sitzung mit einem
HttpOnly-Cookie. Änderungsanfragen müssen außerdem mit Ursprung, Sitzungsinhaber und CSRF-Token
übereinstimmen. Der Browser speichert bei seiner Warteschlange nur eine Konto-ID; bei jedem erneuten
Versuch ruft er das Token der aktuellen Sitzung ab.
Der Empfänger speichert BLOB, Größe, SHA-256-Hash, Schlüssel und Eigentümer in einer SQLite-Zeile. Eine Transaktion serialisiert die Kontingentprüfung und das Einfügen. Nach dem Commit gibt er 201 zurück, bei einer identischen Wiederholung 200 mit derselben Empfangsbestätigung. Geänderte Bytes oder Zeitstempel erhalten 409. Abgelaufene Schlüssel erhalten 410, auch wenn eine Wiederholung einen neueren Zeitstempel zu übermitteln versucht. Diese Demo behält Zeilen dauerhaft bei. Das Bereinigen von Empfangsbestätigungen kann daher einen bereits akzeptierten Schlüssel nicht versehentlich wieder als neu erscheinen lassen.
Die SQLite-Dokumentation zu atomaren Commits erläutert die Speichergrenze. Hier kommt die integrierte SQLite-API von Node zum Einsatz, die in der getesteten Node-Version ein Release Candidate ist. Persistenz setzt einen funktionierenden lokalen Datenträger voraus; dies ist kein replizierter Speicherdienst.
import { createHash, randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { DatabaseSync } from 'node:sqlite'
import { parseArgs } from 'node:util'
const { values } = parseArgs({ options: { port: { type: 'string', default: '8090' } } })
const port = Number(values.port)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port')
const maxSize = 10 * 1024 * 1024
const lifetime = 24 * 60 * 60 * 1000
const passwords = { alice: randomUUID(), bob: randomUUID() }
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
let origin = ''
class HttpError extends Error {
status: number
constructor(status: number, message: string) { super(message); this.status = status }
}
async function body(request: IncomingMessage, limit: number): Promise<Buffer> {
const chunks: Buffer[] = []
let size = 0
for await (const chunk of request) {
if (!Buffer.isBuffer(chunk)) throw new HttpError(400, 'Invalid body')
size += chunk.length
if (size > limit) throw new HttpError(413, 'File too large')
chunks.push(chunk)
}
return Buffer.concat(chunks)
}
async function main(): Promise<void> {
const db = new DatabaseSync('uploads.sqlite')
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, owner TEXT NOT NULL, csrf TEXT NOT NULL, expires INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS uploads (
owner TEXT NOT NULL, id TEXT NOT NULL, created INTEGER NOT NULL,
sha256 TEXT NOT NULL, size INTEGER NOT NULL, bytes BLOB NOT NULL,
PRIMARY KEY (owner, id)
);
`)
const reply = (response: ServerResponse, status: number, value: unknown) => {
response.writeHead(status, { 'Content-Type': 'application/json' })
response.end(JSON.stringify(value))
}
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
response.setHeader('Cache-Control', 'no-store')
response.setHeader('X-Content-Type-Options', 'nosniff')
if (request.headers.host !== new URL(origin).host) throw new HttpError(403, 'Wrong host')
const path = new URL(request.url ?? '/', origin).pathname
if (request.method === 'POST' && request.headers.origin !== origin) {
throw new HttpError(403, 'Wrong origin')
}
if (path === '/login' && request.method === 'GET') {
response.writeHead(200, { 'Content-Type': 'text/html' })
response.end(`<!doctype html><html lang="en"><meta name="viewport" content="width=device-width">
<meta name="color-scheme" content="light dark"><title>Local upload login</title><form method="post" action="/login">
<label>Account <select name="owner"><option>alice</option><option>bob</option></select></label>
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
<button>Sign in</button></form></html>`)
return
}
if (path === '/login' && request.method === 'POST') {
const form = new URLSearchParams((await body(request, 2048)).toString('utf8'))
const owner = form.get('owner')
if ((owner !== 'alice' && owner !== 'bob') || form.get('password') !== passwords[owner]) {
throw new HttpError(401, 'Invalid local login')
}
const id = randomUUID()
db.prepare('INSERT INTO sessions VALUES (?, ?, ?, ?)').run(id, owner, randomUUID(), Date.now() + lifetime)
response.setHeader('Set-Cookie', `session=${id}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400`)
response.writeHead(303, { Location: '/' }).end()
return
}
const cookie = request.headers.cookie?.split(';').map(part => part.trim()).find(part => part.startsWith('session='))
const session = db.prepare('SELECT * FROM sessions WHERE id = ? AND expires > ?').get(cookie?.slice(8) ?? '', Date.now())
if (path.startsWith('/api/') || path === '/received') {
if (!session) throw new HttpError(401, 'Sign in again')
}
if (path === '/api/upload-session' && request.method === 'GET') {
reply(response, 200, { owner: session?.owner, csrf: session?.csrf })
return
}
if (path === '/api/queued-uploads' && request.method === 'POST') {
if (!session || request.headers['x-upload-owner'] !== session.owner || request.headers['x-csrf-token'] !== session.csrf) {
throw new HttpError(403, 'Owner or CSRF mismatch')
}
const id = request.headers['idempotency-key']
const created = Number(request.headers['x-upload-created'])
if (typeof id !== 'string' || !uuid.test(id) || !Number.isSafeInteger(created)) {
throw new HttpError(400, 'Invalid upload identity')
}
if (request.headers['content-type'] !== 'application/octet-stream') throw new HttpError(415, 'Send raw bytes')
const bytes = await body(request, maxSize)
if (bytes.length === 0) throw new HttpError(400, 'Choose a nonempty file')
const sha256 = createHash('sha256').update(bytes).digest('hex')
let status = 200
db.exec('BEGIN IMMEDIATE')
try {
const previous = db.prepare('SELECT * FROM uploads WHERE owner = ? AND id = ?').get(session.owner, id)
const originalCreated = previous?.created ?? created
if (typeof originalCreated !== 'number' || Date.now() - originalCreated >= lifetime) {
throw new HttpError(410, 'Upload expired')
}
if (created > Date.now() + 60_000) throw new HttpError(400, 'Clock is ahead')
if (previous) {
if (previous.sha256 !== sha256 || previous.size !== bytes.length || previous.created !== created) {
throw new HttpError(409, 'Key already belongs to different content')
}
} else {
const usage = db.prepare('SELECT COALESCE(SUM(size), 0) AS total FROM uploads WHERE owner = ?').get(session.owner)
if (typeof usage?.total !== 'number' || usage.total + bytes.length > 100 * 1024 * 1024) {
throw new HttpError(429, 'Local account quota reached')
}
db.prepare('INSERT INTO uploads VALUES (?, ?, ?, ?, ?, ?)').run(session.owner, id, created, sha256, bytes.length, bytes)
status = 201
}
db.exec('COMMIT')
} catch (error) {
db.exec('ROLLBACK')
throw error
}
reply(response, status, { id, owner: session.owner, size: bytes.length, sha256 })
return
}
if (path === '/received' && request.method === 'GET') {
const rows = db.prepare('SELECT id, size, sha256 FROM uploads WHERE owner = ? ORDER BY id').all(session?.owner ?? '')
response.writeHead(200, { 'Content-Type': 'text/html' })
response.end(`<!doctype html><html lang="en"><meta name="color-scheme" content="light dark"><title>Received uploads</title>
<h1>Received uploads</h1><p>${rows.length} stored file(s)</p><ul>${rows.map(row =>
`<li><a href="/api/files/${row.id}">${row.id}</a>: ${row.size} bytes, SHA-256 ${row.sha256}</li>`
).join('')}</ul><a href="/">Queue</a></html>`)
return
}
if (path.startsWith('/api/files/') && request.method === 'GET') {
const row = db.prepare('SELECT bytes FROM uploads WHERE owner = ? AND id = ?').get(session?.owner ?? '', path.slice(11))
if (!row || !(row.bytes instanceof Uint8Array)) throw new HttpError(404, 'File not found')
response.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Disposition': 'attachment; filename="upload.bin"' })
response.end(row.bytes)
return
}
const files: Record<string, [string, string]> = {
'/': ['index.html', 'text/html'],
'/upload-page.js': ['upload-page.js', 'text/javascript'],
'/upload-sw.js': ['upload-sw.js', 'text/javascript'],
}
const file = files[path]
if (request.method !== 'GET' || !file) throw new HttpError(404, 'Not found')
const content = await readFile(new URL(`./public/${file[0]}`, import.meta.url))
response.writeHead(200, { 'Content-Type': file[1] })
response.end(content)
}
const server = createServer((request, response) => {
void handle(request, response).catch(error => {
if (!(error instanceof HttpError)) console.error('Receiver request failed')
response.setHeader('Connection', 'close')
reply(response, error instanceof HttpError ? error.status : 500, {
error: error instanceof HttpError ? error.message : 'Upload not confirmed',
})
})
})
server.requestTimeout = 30_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('No listening address')
origin = `http://127.0.0.1:${address.port}`
console.log(JSON.stringify({ ready: origin, passwords }))
const stop = () => {
server.close(() => db.close())
server.closeAllConnections()
}
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
}
main().catch(error => { console.error('Receiver startup failed:', error.message); process.exitCode = 1 })
Background Sync integrieren
Speichern Sie queue.ts. Die Hash-Berechnung erfolgt vor der Einfügetransaktion,
und der Abruf endet, bevor eine neue Löschtransaktion beginnt. IndexedDB-Transaktionen können sich
schließen, während unabhängige asynchrone Aufgaben noch ausstehen; siehe die
idb-Dokumentation zur Lebensdauer von Transaktionen. Das Warten auf
tx.done bestätigt den Commit des lokalen Schreibvorgangs. Zum Löschen ist eine
Empfangsbestätigung mit Status 200/201 erforderlich, deren Schlüssel, Eigentümer, Größe und Hash
übereinstimmen, nicht nur eine erfolgreiche HTTP-Antwort.
import { openDB } from 'idb'
const maxSize = 10 * 1024 * 1024
const lifetime = 24 * 60 * 60 * 1000
const lockName = 'durable-file-uploads'
async function digest(file) {
const bytes = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
return Array.from(new Uint8Array(bytes), byte => byte.toString(16).padStart(2, '0')).join('')
}
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 sha256 = await digest(file)
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, sha256, 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 { sent: 0, remaining: 0 }
let sent = 0
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 response = await fetch('/api/queued-uploads', {
method: 'POST',
credentials: 'same-origin',
redirect: 'error',
body: entry.file,
headers: {
'Content-Type': 'application/octet-stream',
'X-Upload-Created': String(entry.created),
'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 || receipt.owner !== entry.owner ||
receipt.size !== entry.file.size || receipt.sha256 !== entry.sha256) {
throw new Error('Invalid upload receipt.')
}
const tx = db.transaction('uploads', 'readwrite')
await Promise.all([tx.store.delete(entry.id), tx.done])
sent++
}
return { sent, remaining: entries.length - sent }
} 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()
}
})
}
Speichern Sie upload-sw.ts. Der Worker legt nur die statische Seite und ihr Bundle
im Cache ab. So lässt sich die Seite nach dem ersten erfolgreichen Online-Aufruf auch offline
wieder öffnen. Anmeldung, Sitzungen, Empfangsbestätigungen und Dateiantworten speichert er nie im
Cache. Eine fehlgeschlagene Synchronisierung weist waitUntil zurück und lässt
unbestätigte Einträge für einen erneuten Versuch verfügbar.
import { drain } from './queue.ts'
const cacheName = 'upload-shell-v1'
const shell = ['/', '/upload-page.js']
self.addEventListener('install', event => {
event.waitUntil((async () => {
const cache = await caches.open(cacheName)
await cache.addAll(shell)
await self.skipWaiting()
})())
})
self.addEventListener('activate', event => event.waitUntil(self.clients.claim()))
self.addEventListener('fetch', event => {
const url = new URL(event.request.url)
if (event.request.method === 'GET' && url.origin === self.location.origin && shell.includes(url.pathname)) {
event.respondWith(fetch(event.request).catch(async () => {
const response = await caches.match(url.pathname)
return response ?? Response.error()
}))
}
})
self.addEventListener('sync', event => {
if (event.tag === 'file-upload-sync') event.waitUntil(drain())
})
Speichern Sie public/index.html:
<!doctype html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<title>Offline file queue</title>
</head>
<body>
<h1>Offline file queue</h1>
<p><a href="/login">Sign in or switch account</a> · <a href="/received">Received uploads</a></p>
<p id="account"></p>
<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>
</body>
</html>
Speichern Sie upload-page.ts. Die Bedienelemente werden während eines Vorgangs im
Vordergrund deaktiviert. So verhindern sie, dass wiederholte Klicks dieselbe Auswahl doppelt in die
Warteschlange stellen. Das lokale Speichern funktioniert auch ohne verfügbares Background Sync.
Nach dem erneuten Öffnen im Offline-Modus kennzeichnet die zuletzt erfasste Konto-ID die
Warteschlange; der Server prüft weiterhin die aktuelle Cookie-Sitzung, bevor er Bytes annimmt.
import { discard, drain, enqueue } from './queue.ts'
const input = document.getElementById('file')
const status = document.getElementById('status')
const account = document.getElementById('account')
const controls = ['queue', 'retry', 'discard'].map(id => document.getElementById(id))
let owner = localStorage.getItem('upload-owner')
let busy = false
let controller = null
let registration = null
function available() {
return owner && 'indexedDB' in globalThis && navigator.locks
}
function render() {
account.textContent = owner ? `Local queue owner: ${owner}` : 'Sign in online before queuing files.'
input.disabled = busy || !available()
for (const button of controls) button.disabled = busy || !available()
}
async function schedule() {
if (!registration?.sync) return false
await registration.sync.register('file-upload-sync')
return true
}
async function action(operation) {
if (busy) return
busy = true
render()
try { await operation() }
catch { status.textContent = 'Retry stopped or failed. Some files may already be received; others remain queued.' }
finally { busy = false; controller = null; render() }
}
document.getElementById('queue').onclick = () => action(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 (await schedule()) status.textContent = 'Saved locally. Background retry requested.'
} catch { status.textContent = 'Saved locally. Background retry unavailable; use Retry queued files.' }
})
document.getElementById('retry').onclick = () => action(async () => {
controller = new AbortController()
const result = await drain(controller.signal)
status.textContent = `Delivered ${result.sent} file(s). ${result.remaining} remain queued.`
})
document.getElementById('stop').onclick = () => controller?.abort()
document.getElementById('discard').onclick = () => action(async () => {
await discard(owner)
status.textContent = 'Local queue discarded. Already received uploads remain on the server.'
})
render()
try {
const response = await fetch('/api/upload-session', { cache: 'no-store', redirect: 'error' })
if (response.ok) {
const session = await response.json()
if (!['alice', 'bob'].includes(session.owner)) throw new Error('Invalid session')
owner = session.owner
localStorage.setItem('upload-owner', owner)
} else if (response.status === 401) {
owner = null
localStorage.removeItem('upload-owner')
}
} catch { /* Offline: retain only the previously observed account ID, never credentials. */ }
render()
if (!available()) status.textContent = 'Durable uploads require sign-in, IndexedDB, and Web Locks.'
try {
if ('serviceWorker' in navigator) {
registration = await navigator.serviceWorker.register('/upload-sw.js')
await navigator.serviceWorker.ready
status.textContent = 'Ready for offline use. Background Sync is optional.'
}
} catch { status.textContent = 'Offline page setup failed. Keep this page open to queue and retry.' }
Erstellen Sie vom ursprünglichen übergeordneten Verzeichnis aus beide Bundles und starten Sie den
Empfänger. Die Befehlskette mit && stoppt vor dem Start, wenn einer der
Builds fehlschlägt, selbst wenn ältere Bundles vorhanden sind. Strg+C stoppt den Empfänger und führt
Sie in dieselbe Shell zurück. Ist Port 8090 belegt, wählen Sie einen anderen Port und verwenden Sie
weiterhin diesen Ursprung; der Browserspeicher ist an den Ursprung einschließlich Port gebunden.
(
cd background-upload-demo &&
corepack yarn esbuild upload-page.ts --bundle --format=esm --target=es2022 --tsconfig-raw='{}' --outfile=public/upload-page.js &&
corepack yarn esbuild upload-sw.ts --bundle --format=iife --target=es2022 --tsconfig-raw='{}' --outfile=public/upload-sw.js &&
node server.mts --port=8090
)
Das Terminal gibt ein JSON-Objekt mit ready und temporären
passwords aus. Öffnen Sie http://127.0.0.1:8090/login, wählen Sie
alice, geben Sie das dafür ausgegebene Passwort ein und drücken Sie
Sign in. Warten Sie auf
Ready for offline use. Background Sync is optional., bevor Sie die Verbindung trennen.
Passwörter ändern sich beim Neustart; bestehende, nicht abgelaufene Sitzungen und empfangene Uploads
bleiben in background-upload-demo/uploads.sqlite erhalten.
- Schalten Sie den Browser offline oder stoppen Sie den Empfänger, wählen Sie eine nicht leere Datei mit bis zu 10 MiB und drücken Sie Queue file. Eine Nachricht, die mit Saved locally. beginnt, bestätigt den IndexedDB-Commit, nicht die Zustellung.
- Laden Sie die Seite offline am selben Ursprung neu oder schließen und öffnen Sie sie erneut. Die Seite wird aus dem Cache geladen; Sie müssen die Datei nicht erneut auswählen.
- Stellen Sie die Verbindung wieder her. Browser mit entsprechender Unterstützung können die Warteschlange per Background Sync zustellen. Um einen Versuch im Vordergrund anzufordern, drücken Sie Retry queued files.
- Öffnen Sie Received uploads. Jede gespeicherte
Datei hat einen Schlüssel, eine Byteanzahl, einen SHA-256-Hash und einen Download-Link. Laden Sie
upload.binherunter und vergleichen Sie die Bytes mit Ihrem Original. Nach einem erneuten Versuch mit demselben Warteschlangeneintrag müssen weiterhin genau eine gespeicherte Zeile und dieselbe Empfangsbestätigung vorliegen.
Wenn Sie in den DevTools nur die Anfragen einer Seite vom Netz trennen, kann ihr Worker online bleiben. Für einen manuellen Offline-Test stoppen Sie den Empfänger, nachdem Sie die Seite vorbereitet haben. Stellen Sie dann eine Datei in die Warteschlange und laden Sie die Seite neu. Starten Sie den Empfänger vor dem erneuten Versuch auf demselben Port. Die Ausführungsplanung des Browsers erfolgt nach bestem Bemühen. Ein Hintergrundversuch, der nicht ausgeführt wird, ist daher ein Grund, den Button zu verwenden, kein Beweis für den Verlust der Warteschlange.
Fehler beheben, ohne unbestätigte Uploads als erfolgreich auszugeben
Ein Status 401 bedeutet, dass Sie sich erneut anmelden müssen. Ist ein anderes Konto aktiv, überspringen erneute Versuche die Einträge des ursprünglichen Eigentümers. Melden Sie sich wieder als dieser Eigentümer an, um sie zuzustellen, oder verwenden Sie Discard my queued files, um ihre lokalen Kopien zu entfernen. Das Verwerfen löscht niemals bereits empfangene Dateien auf dem Server. Bewahren Sie sensible Dateien auf einem gemeinsam genutzten Gerät nicht ohne Zustimmung der nutzenden Person auf.
Bei einer abgelaufenen Datei, einem HTTP-Fehler, einer falschen Empfangsbestätigung oder einer unterbrochenen Anfrage bleibt die Datei in der Warteschlange. Einige frühere Einträge können bereits angekommen sein. Prüfen Sie vor dem Verwerfen die Liste der empfangenen Dateien; ein erneuter Versuch mit einem unveränderten Eintrag ist innerhalb seines Zeitfensters sicher. Das Serverkontingent von 100 MiB pro Konto umfasst alle aufbewahrten Dateien. Ein voller Server erfordert daher gezielte Datenverwaltung, nicht wiederholtes Klicken. Ein fehlgeschlagener lokaler Schreibvorgang muss einen Speicherfehler anzeigen, niemals eine Zustellungsnachricht.
Stop foreground retry bricht nur den Versuch der aktuellen Seite ab. Ein bereits festgeschriebener Upload lässt sich damit weder rückgängig machen noch ein separat eingeplanter Hintergrundversuch abbrechen. Der Web Lock serialisiert außerdem das lokale Verwerfen mit der Zustellung, sodass kein Eintrag mitten im jeweils anderen Vorgang gelöscht werden kann.
Ersetzen Sie für die Bereitstellung die Demopasswörter und Sitzungen durch Ihr bestehendes Anmeldesystem, behalten Sie beim Löschen von Objekten Empfangsbestätigungen oder dauerhafte Ablaufdatensätze bei und setzen Sie Ihre Medienrichtlinie vor der Veröffentlichung durch. Dieses Loopback-Beispiel akzeptiert beliebige nicht leere Bytefolgen als private Downloads. Es führt keine Sicherheits- oder Malware-Scans von Dateien durch, begrenzt keine Anmeldeversuche, repliziert keine gespeicherten Daten und verspricht keine Wiederherstellung, nachdem der Browser Daten entfernt hat. Behalten Sie bei größeren Dateien dieselben Eigentümer- und Bestätigungsprüfungen rund um ein Protokoll für fortsetzbare Uploads bei.
