feat: App wird installierbar und uebersteht ein Funkloch beim Neuladen

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
This commit is contained in:
Daniel Michelberger 2026-09-07 23:03:24 +02:00
parent 9f075f0f6c
commit bb56945fe3
4 changed files with 155 additions and 0 deletions

View file

@ -3,6 +3,10 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Installierbar, damit die Zeitnahme an der Strecke ein Funkloch
auch dann uebersteht, wenn jemand die App neu startet. -->
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#eab308" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View file

@ -69,6 +69,12 @@ class AuthStore {
logout = async () => { logout = async () => {
api.authStore.clear() api.authStore.clear()
document.cookie = 'pb_auth="";-1; path=/' 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')
} }

View file

@ -0,0 +1,125 @@
/// <reference types="@sveltejs/kit" />
/// <reference lib="webworker" />
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<Response> {
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<Response> {
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))
}
})

View file

@ -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"
}
]
}