/** * Service Worker für das Terminal im Gerätehaus. * * Aufgabe: das Tablet soll auch dann etwas anzeigen, wenn das WLAN in der * Halle gerade weg ist. Drei Strategien, je nachdem worum es geht: * * Navigation (HTML) Netz zuerst, bei Fehlschlag der zwischengespeicherte * Stand, sonst die Offline-Seite. So sieht man immer die * aktuelle Fassung, wenn es geht — und trotzdem etwas, * wenn nicht. * * Abfragen (tRPC GET) Zwischenspeicher zuerst anzeigen, im Hintergrund * erneuern. Für Stammdaten wie Fahrzeuge und Geräte ist * ein paar Minuten alt völlig ausreichend. * * Änderungen (POST) Gar nicht anfassen. Was geschrieben wird, geht über * die Warteschlange in der IndexedDB (siehe * lib/kiosk/outbox.ts) — ein Service Worker, der * Schreibvorgänge zwischenspeichert, führt zu Daten, die * niemand mehr zuordnen kann. * * Bewusst von Hand geschrieben statt über ein Werkzeug erzeugt: es sind * achtzig Zeilen, sie sind lesbar, und sie bringen keine Abhängigkeit mit, die * bei jedem Update mitgezogen werden müsste. */ const VERSION = "florian-kiosk-v1"; const HUELLE = `${VERSION}-huelle`; const DATEN = `${VERSION}-daten`; /** Was beim Einrichten schon einmal geholt wird. */ const HUELLE_DATEIEN = ["/kiosk", "/kiosk/offline", "/manifest.webmanifest"]; self.addEventListener("install", (ereignis) => { ereignis.waitUntil( caches .open(HUELLE) .then((speicher) => speicher.addAll(HUELLE_DATEIEN)) // Ein fehlender Eintrag darf die Einrichtung nicht verhindern. .catch(() => undefined) .then(() => self.skipWaiting()), ); }); self.addEventListener("activate", (ereignis) => { ereignis.waitUntil( caches .keys() .then((namen) => Promise.all( namen .filter((name) => !name.startsWith(VERSION)) .map((name) => caches.delete(name)), ), ) .then(() => self.clients.claim()), ); }); self.addEventListener("fetch", (ereignis) => { const anfrage = ereignis.request; const adresse = new URL(anfrage.url); // Nur eigene Adressen. Alles andere geht den Service Worker nichts an. if (adresse.origin !== self.location.origin) return; // Schreibende Aufrufe niemals abfangen. if (anfrage.method !== "GET") return; // Der Anmeldeweg bleibt außen vor: ein zwischengespeicherter Anmeldezustand // wäre eine Sicherheitslücke, keine Bequemlichkeit. if (adresse.pathname.startsWith("/api/auth")) return; if (anfrage.mode === "navigate") { ereignis.respondWith(navigationBeantworten(anfrage)); return; } if (adresse.pathname.startsWith("/api/trpc")) { ereignis.respondWith(abfrageBeantworten(anfrage)); return; } // Statische Dateien: erst nachsehen, dann holen. if (adresse.pathname.startsWith("/_next/static") || adresse.pathname.startsWith("/icons")) { ereignis.respondWith(zuerstSpeicher(anfrage)); } }); /** Netz zuerst, Zwischenspeicher als Rückfall, Offline-Seite als letzte Rettung. */ async function navigationBeantworten(anfrage) { try { const antwort = await fetch(anfrage); const speicher = await caches.open(HUELLE); speicher.put(anfrage, antwort.clone()); return antwort; } catch (fehler) { const speicher = await caches.open(HUELLE); const gespeichert = await speicher.match(anfrage); if (gespeichert) return gespeichert; const offlineSeite = await speicher.match("/kiosk/offline"); if (offlineSeite) return offlineSeite; return new Response( "Offline" + "

Kein Netz und nichts im Zwischenspeicher.

", { status: 503, headers: { "Content-Type": "text/html; charset=utf-8" } }, ); } } /** Zwischenspeicher zuerst, im Hintergrund erneuern. */ async function abfrageBeantworten(anfrage) { const speicher = await caches.open(DATEN); const gespeichert = await speicher.match(anfrage); const netz = fetch(anfrage) .then((antwort) => { if (antwort.ok) speicher.put(anfrage, antwort.clone()); return antwort; }) .catch(() => null); if (gespeichert) { // Die Erneuerung läuft weiter, das Ergebnis interessiert hier nicht mehr. void netz; return gespeichert; } const antwort = await netz; if (antwort) return antwort; return new Response(JSON.stringify({ fehler: "offline" }), { status: 503, headers: { "Content-Type": "application/json" }, }); } async function zuerstSpeicher(anfrage) { const speicher = await caches.open(HUELLE); const gespeichert = await speicher.match(anfrage); if (gespeichert) return gespeichert; try { const antwort = await fetch(anfrage); if (antwort.ok) speicher.put(anfrage, antwort.clone()); return antwort; } catch (fehler) { return new Response("", { status: 504 }); } } /** * Die Anwendung kann den Zwischenspeicher der Daten leeren lassen — etwa nach * dem Abmelden am Terminal, damit der nächste Anwender keinen fremden Stand * sieht. */ self.addEventListener("message", (ereignis) => { if (ereignis.data === "daten-leeren") { ereignis.waitUntil(caches.delete(DATEN)); } }); /** * Web-Push (Phase 7). * * Ein Push erreicht den Browser auch dann, wenn FLORIAN nicht offen ist — * deshalb muss der Service Worker ihn annehmen. Er tut zwei Dinge und sonst * nichts: die Meldung anzeigen und beim Antippen die passende Seite öffnen. * * `tag` sorgt dafür, dass eine neue Meldung zum selben Ereignis die alte * ersetzt statt sich daneben zu legen. Sonst stapeln sich nachts fünf * Erinnerungen an denselben Termin. */ self.addEventListener("push", (ereignis) => { if (!ereignis.data) return; let inhalt = {}; try { inhalt = ereignis.data.json(); } catch (fehler) { inhalt = { titel: "FLORIAN", text: ereignis.data.text() }; } ereignis.waitUntil( self.registration.showNotification(inhalt.titel || "FLORIAN", { body: inhalt.text || "", icon: "/icons/florian-192.png", badge: "/icons/florian-192.png", tag: inhalt.kennzeichen || "florian", renotify: Boolean(inhalt.kennzeichen), data: { verweis: inhalt.verweis || "/" }, }), ); }); self.addEventListener("notificationclick", (ereignis) => { ereignis.notification.close(); const ziel = (ereignis.notification.data && ereignis.notification.data.verweis) || "/"; ereignis.waitUntil( self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((fenster) => { // Ein schon offenes FLORIAN in den Vordergrund holen, statt ein zweites // aufzumachen — sonst hat man nach einer Woche zwölf Tabs. for (const eintrag of fenster) { if (eintrag.url.includes(new URL(ziel, self.location.origin).pathname)) { return eintrag.focus(); } } return self.clients.openWindow(ziel); }), ); });