From 51a88a4c5890cf2afa7ae2f02c63b646c6b2f38e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:44:48 +0200 Subject: [PATCH] feat(pwa): SW Pre-Cache-Orchestrator mit Fortschritt + Delta-Sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Message-Handler für sync-start (initial: alle Rezepte cachen) und sync-check (delta: nur neue nachladen, gelöschte räumen). Vor dem Sync ein Storage-Quota-Check (<100 MB frei → abbrechen mit Fehler- Broadcast). Concurrency-Pool mit 4 parallelen Downloads pro Rezept (HTML, API-JSON, Bild). Fortschritt per postMessage an alle Clients, die über den sync-status-Store den SyncIndicator füllen. Das Cache-Manifest wird als JSON-Response unter /__cache-manifest__ im kochwas-meta Cache persistiert. Client triggert beim App-Start entweder sync-check (bereits kontrollierter SW) oder sync-start (erstmaliger SW-Install). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/client/sw-register.ts | 12 +++ src/service-worker.ts | 145 ++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) diff --git a/src/lib/client/sw-register.ts b/src/lib/client/sw-register.ts index 14beabb..cbe78e8 100644 --- a/src/lib/client/sw-register.ts +++ b/src/lib/client/sw-register.ts @@ -18,4 +18,16 @@ export async function registerServiceWorker(): Promise { syncStatus.handle(data); } }); + + // Beim App-Start: wenn wir einen aktiven SW haben, frage ihn, ob er + // neu synct (initial oder Delta). + if (navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage({ type: 'sync-check' }); + } else { + // Erste Session: SW kommt erst mit dem nächsten Reload zum Einsatz. + // Beim nächsten Start triggert sync-check dann den Initial-Sync. + navigator.serviceWorker.ready.then((reg) => { + reg.active?.postMessage({ type: 'sync-start' }); + }); + } } diff --git a/src/service-worker.ts b/src/service-worker.ts index 43b0640..ee3a14e 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -4,6 +4,7 @@ /// 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; @@ -76,4 +77,148 @@ async function staleWhileRevalidate(req: Request, cacheName: string): Promise { + 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)); + } +}); + +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 }); + + let done = 0; + const tasks = worklist.map((id) => async () => { + const summary = summaries.find((s) => s.id === id); + await cacheRecipe(id, summary?.image_path ?? null); + done += 1; + await broadcast({ type: 'sync-progress', current: done, total: worklist.length }); + }); + await runPool(tasks, CONCURRENCY); + + if (isUpdate && toRemove.length > 0) { + await removeRecipes(toRemove); + } + + await saveCachedIds(currentIds); + 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); + await Promise.all([ + addToCache(data, `/recipes/${id}`), + addToCache(data, `/api/recipes/${id}`), + imagePath && !/^https?:\/\//i.test(imagePath) + ? addToCache(images, `/images/${imagePath}`) + : Promise.resolve() + ]); +} + +async function addToCache(cache: Cache, url: string): Promise { + try { + const res = await fetch(url); + if (res.ok) await cache.put(url, res); + } catch { + // Einzelne Fehler ignorieren — nächster Sync holt's nach. + } +} + +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 {};