Files
kochwas/src/lib/client/pwa.svelte.ts

110 lines
3.9 KiB
TypeScript
Raw Normal View History

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;
// Wenn beim Mount schon ein waiting-SW existiert, ist das nicht
// automatisch ein echtes Update: Der Browser behält manchmal einen
// bit-identischen Zombie im waiting-Slot (Artefakt aus einer vorigen
// Session). Erst ein Version-Vergleich klärt, ob der neue SW wirklich
// anderen Code ausführen würde.
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(() => {});
}, 30 * 60_000);
}
private onUpdateFound(): void {
const installing = this.registration?.installing;
if (!installing) return;
installing.addEventListener('statechange', () => {
// 'installed' UND ein laufender controller = Update für bestehenden Tab.
// (Ohne controller wäre das die erste Installation, kein Update.)
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;
}
});
}
// Fragt active- und waiting-SW nach ihrer Version (per MessageChannel)
// und zeigt den Toast nur, wenn sie sich unterscheiden. Bei gleicher
// Version räumen wir den Zombie stillschweigend via SKIP_WAITING auf —
// sonst bleibt registration.waiting bei jedem Reload belegt und der
// Toast taucht endlos wieder auf.
private async evaluateWaiting(waiting: ServiceWorker, active: ServiceWorker): Promise<void> {
const [waitingVersion, activeVersion] = await Promise.all([
queryVersion(waiting),
queryVersion(active)
]);
if (waitingVersion && activeVersion && waitingVersion === activeVersion) {
waiting.postMessage({ type: 'SKIP_WAITING' });
return;
}
this.updateAvailable = true;
}
reload(): void {
this.updateAvailable = false;
const waiting = this.registration?.waiting;
if (!waiting) {
// Kein wartender SW — entweder war es nur eine Toast-Anzeige, oder
// der SW ist schon aktiv. In beiden Fällen reicht ein Reload.
location.reload();
return;
}
// Klassisches Pattern: User-Klick → SKIP_WAITING an den wartenden
// SW → controllerchange feuert, wenn der neue SW übernimmt → dann
// reloaden wir die Seite, damit sie unter dem neuen SW läuft.
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), 1500);
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();