Files
kochwas/src/service-worker.ts

225 lines
7.0 KiB
TypeScript
Raw Normal View History

/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
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);
await self.skipWaiting();
})()
);
});
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 === 'swr') {
event.respondWith(staleWhileRevalidate(req, DATA_CACHE));
}
});
async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
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<Response> {
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;
}
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));
}
});
async function runSync(isUpdate: boolean): Promise<void> {
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<RecipeSummary[]> {
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<void> {
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<void> {
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<void> {
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<number[]> {
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<void> {
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<T>(tasks: (() => Promise<T>)[], limit: number): Promise<void> {
const executing: Promise<void>[] = [];
for (const task of tasks) {
const p: Promise<void> = 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<void> {
const clients = await self.clients.matchAll();
for (const client of clients) client.postMessage(msg);
}
export {};