Files
kochwas/src/lib/client/pwa.svelte.ts
hsiegeln 830c740747 refactor(constants): zentrale SW-Timing-Konstanten + minor cleanups
- src/lib/constants.ts: SW_VERSION_QUERY_TIMEOUT_MS, SW_UPDATE_POLL_INTERVAL_MS
- pwa.svelte.ts: nutzt die Konstanten statt 1500/30*60_000
- cache-strategy.ts / diff-manifest.ts: RequestShape/ManifestDiff entkapselt
  (intern statt export, da nirgends extern importiert)
- recipes/[id]/image: deutsche Fehlermeldungen auf Englisch (Konsistenz
  mit allen anderen Endpoints)

Findings aus REVIEW-2026-04-18.md (Quick-Wins 6+7) und dead-code.md
2026-04-18 22:14:38 +02:00

118 lines
4.2 KiB
TypeScript

import { SW_UPDATE_POLL_INTERVAL_MS, SW_VERSION_QUERY_TIMEOUT_MS } from '$lib/constants';
// Service-Worker-Update-Pattern: Workbox-Style Handshake (kein
// skipWaiting im install-Handler, User bestätigt via Toast) mit
// zusätzlichem Zombie-Schutz.
//
// Warum der Zombie-Schutz nötig ist: Chromium hält auf diesem Deploy
// reproduzierbar nach einem SKIP_WAITING+Reload einen bit-identischen
// waiting-SW im Registration-Slot — wohl durch einen Race zwischen
// SW-Update-Check und activate. Der reine Workbox-Standard würde den
// als „neues Update" interpretieren und den Toast bei jedem Reload
// erneut zeigen. Wir fragen darum per MessageChannel GET_VERSION an
// beiden SWs, vergleichen und räumen identische Bytes still auf.
//
// Kritisch: Der Reload beim controllerchange darf NUR durch User-Klick
// passieren, nicht automatisch beim silent Cleanup — sonst ergibt der
// Zombie-Refresh einen Endlos-Reload-Loop, weil der Browser jede neue
// Seite wieder mit frischem Zombie ausstattet.
class PwaStore {
updateAvailable = $state(false);
private registration: ServiceWorkerRegistration | null = null;
private pollTimer: ReturnType<typeof setInterval> | null = null;
async init(): Promise<void> {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
try {
this.registration = await navigator.serviceWorker.ready;
} catch {
return;
}
if (!this.registration) return;
if (this.registration.waiting && this.registration.active) {
await this.evaluateWaiting(this.registration.waiting, this.registration.active);
}
this.registration.addEventListener('updatefound', () => this.onUpdateFound());
// Alle 30 Minuten aktiv nach Updates fragen, damit der User sie auch
// mitbekommt, wenn er die Seite lange offen lässt ohne zu navigieren.
this.pollTimer = setInterval(() => {
void this.registration?.update().catch(() => {});
}, SW_UPDATE_POLL_INTERVAL_MS);
}
private onUpdateFound(): void {
const installing = this.registration?.installing;
if (!installing) return;
installing.addEventListener('statechange', () => {
if (installing.state !== 'installed' || !navigator.serviceWorker.controller) return;
const active = this.registration?.active;
if (active && active !== installing) {
void this.evaluateWaiting(installing, active);
} else {
this.updateAvailable = true;
}
});
}
private async evaluateWaiting(waiting: ServiceWorker, active: ServiceWorker): Promise<void> {
const [waitingVersion, activeVersion] = await Promise.all([
queryVersion(waiting),
queryVersion(active)
]);
if (waitingVersion && activeVersion && waitingVersion === activeVersion) {
// Bit-identischer Zombie: silent aufräumen, KEIN reload — die Seite
// läuft nahtlos unter dem neuen SW weiter (funktional identisch).
waiting.postMessage({ type: 'SKIP_WAITING' });
return;
}
// Versions-Unterschied oder unbekannt: User entscheidet via Toast.
this.updateAvailable = true;
}
reload(): void {
this.updateAvailable = false;
const waiting = this.registration?.waiting;
if (!waiting) {
// Kein wartender SW — reicht ein normaler Reload.
location.reload();
return;
}
// Klassisches Pattern: User-Klick → SKIP_WAITING → controllerchange
// feuert, wenn der neue SW übernimmt → dann reloaden wir einmalig.
navigator.serviceWorker.addEventListener(
'controllerchange',
() => location.reload(),
{ once: true }
);
waiting.postMessage({ type: 'SKIP_WAITING' });
}
dismiss(): void {
this.updateAvailable = false;
}
}
function queryVersion(sw: ServiceWorker): Promise<string | null> {
return new Promise((resolve) => {
const channel = new MessageChannel();
const timer = setTimeout(() => resolve(null), SW_VERSION_QUERY_TIMEOUT_MS);
channel.port1.onmessage = (e) => {
clearTimeout(timer);
const v = (e.data as { version?: unknown } | null)?.version;
resolve(typeof v === 'string' ? v : null);
};
try {
sw.postMessage({ type: 'GET_VERSION' }, [channel.port2]);
} catch {
clearTimeout(timer);
resolve(null);
}
});
}
export const pwaStore = new PwaStore();