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:
parent
9f075f0f6c
commit
bb56945fe3
4 changed files with 155 additions and 0 deletions
|
|
@ -3,6 +3,10 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
125
frontend/src/service-worker.ts
Normal file
125
frontend/src/service-worker.ts
Normal 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))
|
||||
}
|
||||
})
|
||||
20
frontend/static/manifest.webmanifest
Normal file
20
frontend/static/manifest.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue