diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9cb46d5..3494f04 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -120,11 +120,11 @@ Bei Schema-Änderung: - **Pre-Cache** (alle Rezepte + Bilder beim Initial-Sync), über paginierten Fetch von `/api/recipes/all`. - **Delta-Sync** beim App-Start (diff vs. Cache-Manifest, nur Delta laden). -- **Drei Cache-Strategien** (dispatcht per `resolveStrategy`): Shell = cache-first, Daten = SWR, Bilder = cache-first. +- **Drei Cache-Strategien** (dispatcht per `resolveStrategy`): Shell = cache-first, Daten = network-first mit 3 s-Timeout-Fallback auf Cache, Bilder = cache-first. - **Message-Protokoll** (`sync-start`, `sync-progress`, `sync-done`, `sync-error`) zwischen SW und Client. Reine Logik-Einheiten (testbar, Unit-Tests in `tests/unit/`): -- `src/lib/sw/cache-strategy.ts` — `resolveStrategy({url, method})` → `'shell' | 'swr' | 'images' | 'network-only'` +- `src/lib/sw/cache-strategy.ts` — `resolveStrategy({url, method})` → `'shell' | 'network-first' | 'images' | 'network-only'` - `src/lib/sw/diff-manifest.ts` — `diffManifest(current, cached)` → `{toAdd, toRemove}` Client-Stores (SSR-safe via typeof-Guards): diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index ef7f2ae..155412c 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -155,7 +155,7 @@ Kochwas ist eine installierbare PWA. Erkennbar an: Caches im Browser (siehe DevTools → Application → Cache Storage): - `kochwas-shell-` — App-Shell (JS/CSS/Static-Icons), cache-first -- `kochwas-data-v1` — Rezept-HTMLs + API-JSON (SWR) +- `kochwas-data-v1` — Rezept-HTMLs + API-JSON (network-first, 3 s Timeout → Cache-Fallback) - `kochwas-images-v1` — Bilder (cache-first) - `kochwas-meta` — Cache-Manifest (Liste der gecachten Rezept-IDs unter `/__cache-manifest__`) diff --git a/src/lib/sw/cache-strategy.ts b/src/lib/sw/cache-strategy.ts index 6454ae9..28624a9 100644 --- a/src/lib/sw/cache-strategy.ts +++ b/src/lib/sw/cache-strategy.ts @@ -1,4 +1,4 @@ -export type CacheStrategy = 'shell' | 'swr' | 'images' | 'network-only'; +export type CacheStrategy = 'shell' | 'network-first' | 'images' | 'network-only'; type RequestShape = { url: string; method: string }; @@ -37,6 +37,7 @@ export function resolveStrategy(req: RequestShape): CacheStrategy { return 'shell'; } - // Everything else: recipe pages, API reads, lists — all SWR. - return 'swr'; + // Everything else: recipe pages, API reads, lists — network-first with + // timeout fallback to cache (handled in service-worker.ts). + return 'network-first'; } diff --git a/src/service-worker.ts b/src/service-worker.ts index 361eecb..395f65d 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -56,11 +56,13 @@ self.addEventListener('fetch', (event) => { 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)); + } 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); @@ -70,16 +72,36 @@ async function cacheFirst(req: Request, cacheName: string): Promise { return fresh; } -async function staleWhileRevalidate(req: Request, cacheName: string): Promise { +// 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 hit = await cache.match(req); - const fetchPromise = fetch(req) + const networkPromise: Promise = fetch(req) .then((res) => { if (res.ok) cache.put(req, res.clone()).catch(() => {}); return res; }) - .catch(() => hit ?? Response.error()); - return hit ?? fetchPromise; + .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'; diff --git a/tests/unit/cache-strategy.test.ts b/tests/unit/cache-strategy.test.ts index 9a96d22..2c4202a 100644 --- a/tests/unit/cache-strategy.test.ts +++ b/tests/unit/cache-strategy.test.ts @@ -6,14 +6,16 @@ describe('resolveStrategy', () => { expect(resolveStrategy({ url: '/images/favicon-abc.png', method: 'GET' })).toBe('images'); }); - it('swr for recipe HTML pages', () => { - expect(resolveStrategy({ url: '/recipes/42', method: 'GET' })).toBe('swr'); + it('network-first for recipe HTML pages', () => { + expect(resolveStrategy({ url: '/recipes/42', method: 'GET' })).toBe('network-first'); }); - it('swr for recipe API reads', () => { - expect(resolveStrategy({ url: '/api/recipes/42', method: 'GET' })).toBe('swr'); - expect(resolveStrategy({ url: '/api/recipes/all?sort=name', method: 'GET' })).toBe('swr'); - expect(resolveStrategy({ url: '/api/wishlist', method: 'GET' })).toBe('swr'); + it('network-first for recipe API reads', () => { + expect(resolveStrategy({ url: '/api/recipes/42', method: 'GET' })).toBe('network-first'); + expect(resolveStrategy({ url: '/api/recipes/all?sort=name', method: 'GET' })).toBe( + 'network-first' + ); + expect(resolveStrategy({ url: '/api/wishlist', method: 'GET' })).toBe('network-first'); }); it('network-only for write methods', () => { @@ -34,8 +36,8 @@ describe('resolveStrategy', () => { expect(resolveStrategy({ url: '/manifest.webmanifest', method: 'GET' })).toBe('shell'); }); - it('falls through to swr for other same-origin GETs (e.g. root page)', () => { - expect(resolveStrategy({ url: '/', method: 'GET' })).toBe('swr'); - expect(resolveStrategy({ url: '/wishlist', method: 'GET' })).toBe('swr'); + it('falls through to network-first for other same-origin GETs (e.g. root page)', () => { + expect(resolveStrategy({ url: '/', method: 'GET' })).toBe('network-first'); + expect(resolveStrategy({ url: '/wishlist', method: 'GET' })).toBe('network-first'); }); });