fix(sw): network-first + 3s timeout statt SWR fuer Daten
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
SWR lieferte bei jedem Cache-Hit sofort die alte Antwort und aktualisierte das Cache nur fuer den naechsten Request. Folge: UI zeigte stale Daten, frische Daten erst nach Refresh. Neu: network-first mit 3 s Timeout-Fallback. Netz gewinnt bei frischer Antwort; Timeout oder Netzwerk-Fehler fallen auf Cache zurueck. Pre-Cache-Logik (runSync) bleibt unveraendert, Shell und Bilder bleiben cache-first.
This commit is contained in:
@@ -120,11 +120,11 @@ Bei Schema-Änderung:
|
|||||||
|
|
||||||
- **Pre-Cache** (alle Rezepte + Bilder beim Initial-Sync), über paginierten Fetch von `/api/recipes/all`.
|
- **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).
|
- **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.
|
- **Message-Protokoll** (`sync-start`, `sync-progress`, `sync-done`, `sync-error`) zwischen SW und Client.
|
||||||
|
|
||||||
Reine Logik-Einheiten (testbar, Unit-Tests in `tests/unit/`):
|
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}`
|
- `src/lib/sw/diff-manifest.ts` — `diffManifest(current, cached)` → `{toAdd, toRemove}`
|
||||||
|
|
||||||
Client-Stores (SSR-safe via typeof-Guards):
|
Client-Stores (SSR-safe via typeof-Guards):
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ Kochwas ist eine installierbare PWA. Erkennbar an:
|
|||||||
|
|
||||||
Caches im Browser (siehe DevTools → Application → Cache Storage):
|
Caches im Browser (siehe DevTools → Application → Cache Storage):
|
||||||
- `kochwas-shell-<version>` — App-Shell (JS/CSS/Static-Icons), cache-first
|
- `kochwas-shell-<version>` — 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-images-v1` — Bilder (cache-first)
|
||||||
- `kochwas-meta` — Cache-Manifest (Liste der gecachten Rezept-IDs unter `/__cache-manifest__`)
|
- `kochwas-meta` — Cache-Manifest (Liste der gecachten Rezept-IDs unter `/__cache-manifest__`)
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
type RequestShape = { url: string; method: string };
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ export function resolveStrategy(req: RequestShape): CacheStrategy {
|
|||||||
return 'shell';
|
return 'shell';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything else: recipe pages, API reads, lists — all SWR.
|
// Everything else: recipe pages, API reads, lists — network-first with
|
||||||
return 'swr';
|
// timeout fallback to cache (handled in service-worker.ts).
|
||||||
|
return 'network-first';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,11 +56,13 @@ self.addEventListener('fetch', (event) => {
|
|||||||
event.respondWith(cacheFirst(req, SHELL_CACHE));
|
event.respondWith(cacheFirst(req, SHELL_CACHE));
|
||||||
} else if (strategy === 'images') {
|
} else if (strategy === 'images') {
|
||||||
event.respondWith(cacheFirst(req, IMAGES_CACHE));
|
event.respondWith(cacheFirst(req, IMAGES_CACHE));
|
||||||
} else if (strategy === 'swr') {
|
} else if (strategy === 'network-first') {
|
||||||
event.respondWith(staleWhileRevalidate(req, DATA_CACHE));
|
event.respondWith(networkFirstWithTimeout(req, DATA_CACHE, NETWORK_TIMEOUT_MS));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const NETWORK_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
|
async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
|
||||||
const cache = await caches.open(cacheName);
|
const cache = await caches.open(cacheName);
|
||||||
const hit = await cache.match(req);
|
const hit = await cache.match(req);
|
||||||
@@ -70,16 +72,36 @@ async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
|
|||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function staleWhileRevalidate(req: Request, cacheName: string): Promise<Response> {
|
// 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<Response> {
|
||||||
const cache = await caches.open(cacheName);
|
const cache = await caches.open(cacheName);
|
||||||
const hit = await cache.match(req);
|
const networkPromise: Promise<Response | null> = fetch(req)
|
||||||
const fetchPromise = fetch(req)
|
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.ok) cache.put(req, res.clone()).catch(() => {});
|
if (res.ok) cache.put(req, res.clone()).catch(() => {});
|
||||||
return res;
|
return res;
|
||||||
})
|
})
|
||||||
.catch(() => hit ?? Response.error());
|
.catch(() => null);
|
||||||
return hit ?? fetchPromise;
|
|
||||||
|
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 META_CACHE = 'kochwas-meta';
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ describe('resolveStrategy', () => {
|
|||||||
expect(resolveStrategy({ url: '/images/favicon-abc.png', method: 'GET' })).toBe('images');
|
expect(resolveStrategy({ url: '/images/favicon-abc.png', method: 'GET' })).toBe('images');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('swr for recipe HTML pages', () => {
|
it('network-first for recipe HTML pages', () => {
|
||||||
expect(resolveStrategy({ url: '/recipes/42', method: 'GET' })).toBe('swr');
|
expect(resolveStrategy({ url: '/recipes/42', method: 'GET' })).toBe('network-first');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('swr for recipe API reads', () => {
|
it('network-first for recipe API reads', () => {
|
||||||
expect(resolveStrategy({ url: '/api/recipes/42', method: 'GET' })).toBe('swr');
|
expect(resolveStrategy({ url: '/api/recipes/42', method: 'GET' })).toBe('network-first');
|
||||||
expect(resolveStrategy({ url: '/api/recipes/all?sort=name', method: 'GET' })).toBe('swr');
|
expect(resolveStrategy({ url: '/api/recipes/all?sort=name', method: 'GET' })).toBe(
|
||||||
expect(resolveStrategy({ url: '/api/wishlist', method: 'GET' })).toBe('swr');
|
'network-first'
|
||||||
|
);
|
||||||
|
expect(resolveStrategy({ url: '/api/wishlist', method: 'GET' })).toBe('network-first');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('network-only for write methods', () => {
|
it('network-only for write methods', () => {
|
||||||
@@ -34,8 +36,8 @@ describe('resolveStrategy', () => {
|
|||||||
expect(resolveStrategy({ url: '/manifest.webmanifest', method: 'GET' })).toBe('shell');
|
expect(resolveStrategy({ url: '/manifest.webmanifest', method: 'GET' })).toBe('shell');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls through to swr for other same-origin GETs (e.g. root page)', () => {
|
it('falls through to network-first for other same-origin GETs (e.g. root page)', () => {
|
||||||
expect(resolveStrategy({ url: '/', method: 'GET' })).toBe('swr');
|
expect(resolveStrategy({ url: '/', method: 'GET' })).toBe('network-first');
|
||||||
expect(resolveStrategy({ url: '/wishlist', method: 'GET' })).toBe('swr');
|
expect(resolveStrategy({ url: '/wishlist', method: 'GET' })).toBe('network-first');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user