/// /// /// /// import { build, files, version } from '$service-worker'; import { resolveStrategy } from '$lib/sw/cache-strategy'; import { diffManifest } from '$lib/sw/diff-manifest'; declare const self: ServiceWorkerGlobalScope; const SHELL_CACHE = `kochwas-shell-${version}`; const DATA_CACHE = 'kochwas-data-v1'; const IMAGES_CACHE = 'kochwas-images-v1'; // App-Shell-Assets (Build-Output + statische Dateien, die SvelteKit kennt) const SHELL_ASSETS = [...build, ...files]; self.addEventListener('install', (event) => { event.waitUntil( (async () => { const cache = await caches.open(SHELL_CACHE); await cache.addAll(SHELL_ASSETS); // Kein self.skipWaiting() hier — der Client (pwaStore) fragt den // User via UpdateToast, ob der neue SW sofort übernehmen soll, und // schickt dann eine SKIP_WAITING-Message. Ohne diese Trennung // würde pwaStore beim Install-Event fälschlich "Neue Version" // zeigen (weil statechange='installed' + controller=alter SW), und // der neue SW würde einen Tick später ungefragt übernehmen. })() ); }); 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 (new URL(req.url).origin !== self.location.origin) return; // Cross-Origin unangetastet const strategy = resolveStrategy({ url: req.url, method: req.method }); if (strategy === 'network-only') return; if (strategy === 'shell') { event.respondWith(cacheFirst(req, SHELL_CACHE)); } else if (strategy === 'images') { event.respondWith(cacheFirst(req, IMAGES_CACHE)); } else if (strategy === 'network-first') { event.respondWith(networkFirstWithTimeout(req, DATA_CACHE, NETWORK_TIMEOUT_MS)); } }); const NETWORK_TIMEOUT_MS = 3000; 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; } // Network-first mit Timeout-Fallback: frische Daten gewinnen, wenn das Netz // innerhalb von NETWORK_TIMEOUT_MS antwortet. Sonst wird der Cache geliefert // (falls vorhanden), während der Netz-Fetch noch im Hintergrund weiterläuft // und den Cache für den nächsten Request aktualisiert. Ohne Cache wartet der // Client trotzdem aufs Netz, weil ein Error-Response hier nichts nützt. async function networkFirstWithTimeout( req: Request, cacheName: string, timeoutMs: number ): Promise { const cache = await caches.open(cacheName); const networkPromise: Promise = fetch(req) .then((res) => { if (res.ok) cache.put(req, res.clone()).catch(() => {}); return res; }) .catch(() => null); const timeoutPromise = new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), timeoutMs) ); const winner = await Promise.race([networkPromise, timeoutPromise]); if (winner instanceof Response) return winner; // Timeout oder Netzwerk-Fehler: Cache bevorzugen, sonst auf Netz warten. const hit = await cache.match(req); if (hit) return hit; const late = await networkPromise; return late ?? Response.error(); } const META_CACHE = 'kochwas-meta'; const MANIFEST_KEY = '/__cache-manifest__'; const PAGE_SIZE = 50; // /api/recipes/all limitiert auf 50 const CONCURRENCY = 4; type RecipeSummary = { id: number; image_path: string | null }; self.addEventListener('message', (event) => { const data = event.data as { type?: string } | undefined; if (!data) return; if (data.type === 'sync-start') { event.waitUntil(runSync(false)); } else if (data.type === 'sync-check') { event.waitUntil(runSync(true)); } else if (data.type === 'SKIP_WAITING') { // Wird vom pwaStore nach User-Klick auf "Neu laden" geschickt. void self.skipWaiting(); } else if (data.type === 'GET_VERSION') { // Zombie-Schutz: Chromium hält nach einem SKIP_WAITING-Zyklus // mitunter einen bit-identischen waiting-SW im Registration-Slot // (Race zwischen SW-Update-Check während activate). Ohne diesen // Version-Handshake zeigt init() den „Neue Version"-Toast bei jedem // Reload erneut, obwohl es nichts zu aktualisieren gibt. const port = event.ports[0] as MessagePort | undefined; port?.postMessage({ version }); } }); async function runSync(isUpdate: boolean): Promise { try { // Storage-Quota-Check vor dem Pre-Cache if (navigator.storage?.estimate) { const est = await navigator.storage.estimate(); const freeBytes = (est.quota ?? 0) - (est.usage ?? 0); if (freeBytes < 100 * 1024 * 1024) { await broadcast({ type: 'sync-error', message: `Nicht genug Speicher für Offline-Modus (${Math.round(freeBytes / 1024 / 1024)} MB frei)` }); return; } } const summaries = await fetchAllSummaries(); const currentIds = summaries.map((s) => s.id); const cachedIds = await loadCachedIds(); const { toAdd, toRemove } = diffManifest(currentIds, cachedIds); const worklist = isUpdate ? toAdd : currentIds; // initial: alles laden await broadcast({ type: 'sync-start', total: worklist.length }); const successful = new Set(); let done = 0; const tasks = worklist.map((id) => async () => { const summary = summaries.find((s) => s.id === id); const ok = await cacheRecipe(id, summary?.image_path ?? null); if (ok) successful.add(id); done += 1; await broadcast({ type: 'sync-progress', current: done, total: worklist.length }); }); await runPool(tasks, CONCURRENCY); if (isUpdate && toRemove.length > 0) { await removeRecipes(toRemove); } // Manifest: für Update = (cached - toRemove) + neue successes // Für Initial = nur die diesmal erfolgreich gecachten const finalManifest = isUpdate ? Array.from( new Set([...cachedIds.filter((id) => !toRemove.includes(id)), ...successful]) ) : Array.from(successful); await saveCachedIds(finalManifest); await broadcast({ type: 'sync-done', lastSynced: Date.now() }); } catch (e) { await broadcast({ type: 'sync-error', message: (e as Error).message ?? 'Unbekannter Sync-Fehler' }); } } async function fetchAllSummaries(): Promise { const result: RecipeSummary[] = []; let offset = 0; for (;;) { const res = await fetch(`/api/recipes/all?sort=name&limit=${PAGE_SIZE}&offset=${offset}`); if (!res.ok) throw new Error(`/api/recipes/all HTTP ${res.status}`); const body = (await res.json()) as { hits: { id: number; image_path: string | null }[] }; result.push(...body.hits.map((h) => ({ id: h.id, image_path: h.image_path }))); if (body.hits.length < PAGE_SIZE) break; offset += PAGE_SIZE; } return result; } async function cacheRecipe(id: number, imagePath: string | null): Promise { const data = await caches.open(DATA_CACHE); const images = await caches.open(IMAGES_CACHE); const [htmlOk, apiOk] = await Promise.all([ addToCache(data, `/recipes/${id}`), addToCache(data, `/api/recipes/${id}`) ]); if (imagePath && !/^https?:\/\//i.test(imagePath)) { // Image-Fehler soll den Recipe-Eintrag nicht invalidieren (bei // manchen Rezepten gibt es schlicht kein Bild) await addToCache(images, `/images/${imagePath}`); } return htmlOk && apiOk; } async function addToCache(cache: Cache, url: string): Promise { try { const res = await fetch(url); if (!res.ok) { console.warn(`[sw] cache miss ${url}: HTTP ${res.status}`); return false; } await cache.put(url, res); return true; } catch (e) { console.warn(`[sw] cache error ${url}:`, e); return false; } } async function removeRecipes(ids: number[]): Promise { const data = await caches.open(DATA_CACHE); for (const id of ids) { await data.delete(`/recipes/${id}`); await data.delete(`/api/recipes/${id}`); } // Orphan-Bilder: wir räumen nicht aktiv — neuer Hash = neuer Entry, // alte Einträge stören nicht. } async function loadCachedIds(): Promise { const meta = await caches.open(META_CACHE); const res = await meta.match(MANIFEST_KEY); if (!res) return []; try { return (await res.json()) as number[]; } catch { return []; } } async function saveCachedIds(ids: number[]): Promise { const meta = await caches.open(META_CACHE); await meta.put( MANIFEST_KEY, new Response(JSON.stringify(ids), { headers: { 'content-type': 'application/json' } }) ); } async function runPool(tasks: (() => Promise)[], limit: number): Promise { const executing: Promise[] = []; for (const task of tasks) { const p: Promise = task().then(() => { executing.splice(executing.indexOf(p), 1); }); executing.push(p); if (executing.length >= limit) await Promise.race(executing); } await Promise.all(executing); } async function broadcast(msg: unknown): Promise { const clients = await self.clients.matchAll(); for (const client of clients) client.postMessage(msg); } export {};