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 | null = null; async init(): Promise { 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 { 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 { 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();