From bb56945fe3cedfa35cbf0054d03ebcf37b7cf81e Mon Sep 17 00:00:00 2001 From: Daniel Michelberger Date: Mon, 7 Sep 2026 23:03:24 +0200 Subject: [PATCH] feat: App wird installierbar und uebersteht ein Funkloch beim Neuladen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ohne Service Worker war "offline" nur so lange wahr, wie der Tab offen blieb: Ein Neuladen an der Stage endete in einer weissen Seite — samt den Zeiten, die noch im Ausgang lagen. Drei Sorten Anfragen, drei Strategien. Was zum Build gehoert, liegt unter seiner Versions-URL und aendert sich nie: Cache zuerst. Seitenaufrufe und lesende Anfragen an PocketBase: Netz zuerst, Antwort merken, ohne Netz die zuletzt gesehene Fassung — nur so steht nach einem Neuladen im Funkloch ueberhaupt ein Kader da, gegen den sich stoppen laesst. Schreibende Anfragen werden nie aus dem Cache beantwortet: Ein Start, der aus dem Cache "gelingt", waere eine Zeit, die es nicht gibt. Dafuer ist der Ausgang zustaendig. Und /api/clock ist ausgenommen — eine gemerkte Uhrzeit ist das Gegenteil von dem, wozu die Route da ist. Beim Abmelden raeumt der Worker seinen API-Vorrat weg. Sonst stuenden auf einem geteilten Geraet die Daten des vorigen Nutzers offline weiter da. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr --- frontend/src/app.html | 4 + frontend/src/lib/stores/pocketbase.svelte.ts | 6 + frontend/src/service-worker.ts | 125 +++++++++++++++++++ frontend/static/manifest.webmanifest | 20 +++ 4 files changed, 155 insertions(+) create mode 100644 frontend/src/service-worker.ts create mode 100644 frontend/static/manifest.webmanifest diff --git a/frontend/src/app.html b/frontend/src/app.html index f273cc5..97b3376 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -3,6 +3,10 @@ + + + %sveltekit.head% diff --git a/frontend/src/lib/stores/pocketbase.svelte.ts b/frontend/src/lib/stores/pocketbase.svelte.ts index af8f23f..5c12459 100644 --- a/frontend/src/lib/stores/pocketbase.svelte.ts +++ b/frontend/src/lib/stores/pocketbase.svelte.ts @@ -69,6 +69,12 @@ class AuthStore { logout = async () => { api.authStore.clear() document.cookie = 'pb_auth="";-1; path=/' + + // Der Service Worker haelt gelesene API-Antworten vor, damit die App + // im Funkloch etwas anzuzeigen hat. Nach dem Abmelden hat sie das + // nicht mehr: Auf einem geteilten Geraet stuenden sonst die Daten des + // vorigen Nutzers offline weiter da. + navigator.serviceWorker?.controller?.postMessage('clear-api-cache') } diff --git a/frontend/src/service-worker.ts b/frontend/src/service-worker.ts new file mode 100644 index 0000000..1549c8b --- /dev/null +++ b/frontend/src/service-worker.ts @@ -0,0 +1,125 @@ +/// +/// + +import { build, files, version } from '$service-worker' + +/** + * Service Worker für die Zeitnahme im Funkloch. + * + * Ohne ihn ist „offline" nur so lange wahr, wie der Tab offen bleibt: Ein + * Neuladen an der Stage endete in einer weißen Seite, und die Zeiten im + * Ausgang wären mit ihr verschwunden. Eine Stage lässt sich nicht + * wiederholen — deshalb dieser Aufwand. + * + * Drei Sorten Anfragen, drei Strategien: + * + * 1. Was zum Build gehört (JS, CSS, Bilder), liegt unter seiner Versions-URL + * und ändert sich nie. Cache zuerst, ohne Rückfrage. + * 2. Seitenaufrufe kommen vom Server (die App läuft mit SSR). Netz zuerst, + * die Antwort wandert in den Cache; ohne Netz die zuletzt gesehene + * Fassung derselben Seite. + * 3. Lesende Anfragen an PocketBase ebenso — nur so steht nach einem + * Neuladen im Funkloch überhaupt ein Kader da, gegen den sich stoppen + * lässt. Schreibende Anfragen werden nie aus dem Cache beantwortet: Ein + * Start, der aus dem Cache „gelingt", wäre eine Zeit, die es nicht gibt. + * Dafür ist der Ausgang in timeOutbox zuständig. + */ + +const sw = self as unknown as ServiceWorkerGlobalScope + +const ASSETS = `assets-${version}` +const PAGES = `pages-${version}` +const API = `api-${version}` + +const PRECACHE = [...build, ...files] + +sw.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(ASSETS) + .then((cache) => cache.addAll(PRECACHE)) + .then(() => sw.skipWaiting()), + ) +}) + +sw.addEventListener('activate', (event) => { + // Alte Versionen wegräumen. Ein Gerät an der Strecke soll nicht mit dem + // Vorrat von vor drei Events dastehen. + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((k) => k !== ASSETS && k !== PAGES && k !== API) + .map((k) => caches.delete(k)), + ), + ) + .then(() => sw.clients.claim()), + ) +}) + +/** + * Beim Abmelden fliegt der Vorrat an API-Antworten raus. Sonst stünden die + * Daten des vorigen Nutzers offline noch da — auf einem geteilten Gerät ein + * Einblick, den niemand gegeben hat. + */ +sw.addEventListener('message', (event) => { + if (event.data === 'clear-api-cache') { + event.waitUntil(caches.delete(API)) + } +}) + +async function cacheFirst(request: Request): Promise { + const cached = await caches.match(request) + if (cached) return cached + return fetch(request) +} + +/** Netz zuerst, Cache als Auffangnetz — und jede geglückte Antwort merken. */ +async function networkFirst(request: Request, cacheName: string): Promise { + const cache = await caches.open(cacheName) + + try { + const response = await fetch(request) + // Nur Vollstaendiges merken: Ein 206 oder ein Fehler waere im + // Funkloch schlimmer als gar nichts. + if (response.ok && response.status === 200) { + cache.put(request, response.clone()) + } + return response + } catch (err) { + const cached = await cache.match(request) + if (cached) return cached + throw err + } +} + +sw.addEventListener('fetch', (event) => { + const { request } = event + if (request.method !== 'GET') return + + const url = new URL(request.url) + + // Andere Ursprünge (Kartenkacheln, Schriften) gehen den normalen Weg. + // Kacheln zu horten waere ein Vorrat ohne Ende. + const sameOrigin = url.origin === sw.location.origin + const isApi = url.pathname.startsWith('/api/') + + if (sameOrigin && PRECACHE.includes(url.pathname)) { + event.respondWith(cacheFirst(request)) + return + } + + if (isApi) { + // Der Uhrenabgleich darf nie aus dem Cache kommen — eine gemerkte + // Uhrzeit ist das Gegenteil von dem, wozu er da ist. + if (url.pathname === '/api/clock') return + event.respondWith(networkFirst(request, API)) + return + } + + if (sameOrigin && request.mode === 'navigate') { + event.respondWith(networkFirst(request, PAGES)) + } +}) diff --git a/frontend/static/manifest.webmanifest b/frontend/static/manifest.webmanifest new file mode 100644 index 0000000..e52a07f --- /dev/null +++ b/frontend/static/manifest.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "Stammtisch Hersbruck", + "short_name": "Stammtisch", + "description": "Events, Stages, Zeiten und Trails des Stammtisch Hersbruck", + "start_url": "/dashboard", + "scope": "/", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#ffffff", + "theme_color": "#eab308", + "lang": "de", + "icons": [ + { + "src": "/schildl.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +}