- 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
118 lines
4.2 KiB
TypeScript
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();
|