From 582d902c62101b2c9d341ed3ec40cb51e364a16f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:38:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(pwa):=20Service-Worker-Ger=C3=BCst=20mit?= =?UTF-8?q?=20Shell-Cache=20+=20Fetch-Dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/service-worker.ts installiert die App-Shell-Assets (build + files aus $service-worker) beim install-Event in kochwas-shell- , räumt alte Shell-Caches beim activate und dispatcht jeden Fetch via resolveStrategy — shell/images cache-first, swr stale-while-revalidate, network-only unangetastet. Pre-Cache- Orchestrator kommt in Task 9. Client-seitig registriert sw-register.ts den SW und verdrahtet Messages vom SW in den sync-status-Store. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/client/sw-register.ts | 21 ++++++ src/routes/+layout.svelte | 2 + src/service-worker.ts | 128 ++++++++++++++++------------------ 3 files changed, 82 insertions(+), 69 deletions(-) create mode 100644 src/lib/client/sw-register.ts diff --git a/src/lib/client/sw-register.ts b/src/lib/client/sw-register.ts new file mode 100644 index 0000000..14beabb --- /dev/null +++ b/src/lib/client/sw-register.ts @@ -0,0 +1,21 @@ +// Registriert den Service-Worker und verdrahtet ihn mit dem +// Sync-Status-Store. Im Dev-Modus läuft Kochwas über HTTP; die +// SW-API ist da nur auf localhost verfügbar. SvelteKit liefert den +// SW unter /service-worker.js im Production-Build. +import { syncStatus, type SWMessage } from '$lib/client/sync-status.svelte'; + +export async function registerServiceWorker(): Promise { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; + try { + await navigator.serviceWorker.register('/service-worker.js', { type: 'module' }); + } catch (e) { + console.warn('SW-Registrierung fehlgeschlagen', e); + return; + } + navigator.serviceWorker.addEventListener('message', (event) => { + const data = event.data as SWMessage | undefined; + if (data && typeof data === 'object' && 'type' in data) { + syncStatus.handle(data); + } + }); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 979546e..4a2866d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -15,6 +15,7 @@ import Toast from '$lib/components/Toast.svelte'; import SyncIndicator from '$lib/components/SyncIndicator.svelte'; import { network } from '$lib/client/network.svelte'; + import { registerServiceWorker } from '$lib/client/sw-register'; import type { SearchHit } from '$lib/server/recipes/search-local'; import type { WebHit } from '$lib/server/search/searxng'; @@ -206,6 +207,7 @@ void searchFilterStore.load(); void pwaStore.init(); network.init(); + void registerServiceWorker(); document.addEventListener('click', handleClickOutside); document.addEventListener('keydown', handleKey); return () => { diff --git a/src/service-worker.ts b/src/service-worker.ts index 76b14da..43b0640 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -2,88 +2,78 @@ /// /// /// - import { build, files, version } from '$service-worker'; +import { resolveStrategy } from '$lib/sw/cache-strategy'; -const sw = self as unknown as ServiceWorkerGlobalScope; +declare const self: ServiceWorkerGlobalScope; -const APP_CACHE = `kochwas-app-${version}`; -const IMAGE_CACHE = `kochwas-images-v1`; -const APP_ASSETS = [...build, ...files]; +const SHELL_CACHE = `kochwas-shell-${version}`; +const DATA_CACHE = 'kochwas-data-v1'; +const IMAGES_CACHE = 'kochwas-images-v1'; -sw.addEventListener('install', (event) => { - event.waitUntil( - caches.open(APP_CACHE).then((cache) => cache.addAll(APP_ASSETS)) - ); - // Activate new worker without waiting for old clients to close. - void sw.skipWaiting(); -}); +// App-Shell-Assets (Build-Output + statische Dateien, die SvelteKit kennt) +const SHELL_ASSETS = [...build, ...files]; -sw.addEventListener('activate', (event) => { +self.addEventListener('install', (event) => { event.waitUntil( (async () => { - const keys = await caches.keys(); - await Promise.all( - keys - .filter((k) => k.startsWith('kochwas-app-') && k !== APP_CACHE) - .map((k) => caches.delete(k)) - ); - await sw.clients.claim(); + const cache = await caches.open(SHELL_CACHE); + await cache.addAll(SHELL_ASSETS); + await self.skipWaiting(); })() ); }); -sw.addEventListener('fetch', (event) => { +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + // Alte Shell-Caches (vorherige Versionen) räumen + const keys = await caches.keys(); + await Promise.all( + keys + .filter((k) => k.startsWith('kochwas-shell-') && k !== SHELL_CACHE) + .map((k) => caches.delete(k)) + ); + await self.clients.claim(); + })() + ); +}); + +self.addEventListener('fetch', (event) => { const req = event.request; - if (req.method !== 'GET') return; + if (new URL(req.url).origin !== self.location.origin) return; // Cross-Origin unangetastet - const url = new URL(req.url); - if (url.origin !== location.origin) return; + const strategy = resolveStrategy({ url: req.url, method: req.method }); + if (strategy === 'network-only') return; - // Images served from /images/* — cache-first with background update - if (url.pathname.startsWith('/images/')) { - event.respondWith( - (async () => { - const cache = await caches.open(IMAGE_CACHE); - const cached = await cache.match(req); - const network = fetch(req) - .then((res) => { - if (res.ok) void cache.put(req, res.clone()); - return res; - }) - .catch(() => undefined); - return cached ?? (await network) ?? new Response('Offline', { status: 503 }); - })() - ); - return; - } - - // App shell assets (build/* and static files) — cache-first - if (APP_ASSETS.includes(url.pathname)) { - event.respondWith( - (async () => { - const cache = await caches.open(APP_CACHE); - const cached = await cache.match(req); - return cached ?? fetch(req); - })() - ); - return; - } - - // API and HTML pages — network-first, fall back to cache for HTML - if (req.destination === 'document') { - event.respondWith( - (async () => { - try { - const res = await fetch(req); - const cache = await caches.open(APP_CACHE); - if (res.ok) void cache.put(req, res.clone()); - return res; - } catch { - const cached = await caches.match(req); - return cached ?? new Response('Offline', { status: 503 }); - } - })() - ); + if (strategy === 'shell') { + event.respondWith(cacheFirst(req, SHELL_CACHE)); + } else if (strategy === 'images') { + event.respondWith(cacheFirst(req, IMAGES_CACHE)); + } else if (strategy === 'swr') { + event.respondWith(staleWhileRevalidate(req, DATA_CACHE)); } }); + +async function cacheFirst(req: Request, cacheName: string): Promise { + const cache = await caches.open(cacheName); + const hit = await cache.match(req); + if (hit) return hit; + const fresh = await fetch(req); + if (fresh.ok) cache.put(req, fresh.clone()).catch(() => {}); + return fresh; +} + +async function staleWhileRevalidate(req: Request, cacheName: string): Promise { + const cache = await caches.open(cacheName); + const hit = await cache.match(req); + const fetchPromise = fetch(req) + .then((res) => { + if (res.ok) cache.put(req, res.clone()).catch(() => {}); + return res; + }) + .catch(() => hit ?? Response.error()); + return hit ?? fetchPromise; +} + +export {};