/* * Craftvia service worker (lane L7 Offline & PWA, Spec §23, ARCHITEKTUR §4.6). * Static file on purpose (no build plugin, Next.js 16). Registered by * src/components/offline/offline-runtime-client.tsx with scope "/". * * Strategies * /_next/static/** cache-first, versioned cache (hashed file names) * navigations under /m/** network-first; offline → cached page, order views → /m/offline * documents of the offline bundle cache-first from DOC_CACHE (filled by src/lib/offline/prefetch.ts, * /api/v1/field/documents/ LRU limit 300 MB); documents not in the cache go to the network * /files/ * everything else not handled (browser default) — NEVER API mutations, auth * routes, /api/v1/sync or uploads * * Constants mirrored in src/lib/offline/doc-cache.ts (checked by scripts/test-offline-core.ts). * Bump VERSION when this file changes in a way that needs fresh static assets. */ const VERSION = "2026-09-14.1"; const STATIC_CACHE = "craftvia-static-" + VERSION; const STATIC_PREFIX = "craftvia-static-"; const PAGE_CACHE = "craftvia-pages-v1"; const DOC_CACHE = "craftvia-docs-v1"; const DOC_CACHE_MAX_BYTES = 300 * 1024 * 1024; const STATIC_MAX_ENTRIES = 600; const OFFLINE_URL = "/m/offline"; const SYNC_TAG = "craftvia-outbox"; /** Never served from or written to a cache. */ const NEVER_CACHE = ["/api/v1/sync", "/api/v1/uploads", "/api/v1/field/bundle", "/api/auth", "/api/platform-auth", "/login", "/logout", "/select-tenant", "/platform"]; const DOC_PATTERNS = [/^\/api\/v1\/field\/documents\/[^/]+$/, /^\/files\/[^/]+$/]; /** Order list/detail pages: offline they are rendered from the local bundle (/m/offline). */ const BUNDLE_VIEW = /^\/m(\/orders(\/[^/]+(\/[^/]+)?)?)?\/?$/; const STATIC_REF = /\/_next\/static\/[^"'\s)\\]+/g; const sw = self; const matchesPath = (path, prefix) => path === prefix || path.startsWith(prefix + "/"); sw.addEventListener("install", (event) => { event.waitUntil( (async () => { await precacheOffline(); // First install: take over right away. Updates wait for the user ("Neue Version verfügbar"). if (!sw.registration.active) await sw.skipWaiting(); })(), ); }); sw.addEventListener("activate", (event) => { event.waitUntil( (async () => { const names = await caches.keys(); await Promise.all(names.filter((n) => n.startsWith(STATIC_PREFIX) && n !== STATIC_CACHE).map((n) => caches.delete(n))); await sw.clients.claim(); })(), ); }); sw.addEventListener("message", (event) => { const type = event.data && event.data.type; if (type === "SKIP_WAITING") event.waitUntil(sw.skipWaiting()); else if (type === "PRECACHE_OFFLINE") event.waitUntil(precacheOffline()); else if (type === "CLEAR_USER_CACHES") event.waitUntil(Promise.all([caches.delete(PAGE_CACHE), caches.delete(DOC_CACHE)])); }); // Background Sync (bonus, not available in Safari): wake open tabs to flush the outbox. sw.addEventListener("sync", (event) => { if (event.tag !== SYNC_TAG) return; event.waitUntil( sw.clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => list.forEach((c) => c.postMessage({ type: "craftvia:sync" }))), ); }); sw.addEventListener("fetch", (event) => { const request = event.request; if (request.method !== "GET") return; const url = new URL(request.url); if (url.origin !== sw.location.origin) return; const path = url.pathname; if (NEVER_CACHE.some((p) => matchesPath(path, p))) return; if (path.startsWith("/_next/static/")) { event.respondWith(staticCacheFirst(request)); return; } if (!url.search && DOC_PATTERNS.some((re) => re.test(path))) { event.respondWith(documentCacheFirst(event, request, url)); return; } if (request.mode === "navigate" && matchesPath(path, "/m")) { event.respondWith(pageNetworkFirst(request, url)); } }); async function staticCacheFirst(request) { const cache = await caches.open(STATIC_CACHE); const hit = await cache.match(request); if (hit) return hit; const res = await fetch(request); if (res.ok && res.type === "basic") { await cache.put(request, res.clone()); trimStatic(cache); } return res; } async function trimStatic(cache) { const keys = await cache.keys(); const excess = keys.length - STATIC_MAX_ENTRIES; for (let i = 0; i < excess; i++) await cache.delete(keys[i]); } async function documentCacheFirst(event, request, url) { const cache = await caches.open(DOC_CACHE); const hit = await cache.match(url.pathname); if (!hit) return fetch(request); // LRU bookkeeping: refresh the last-used stamp at most once per hour const used = Number(hit.headers.get("x-craftvia-used") || 0); if (Date.now() - used > 60 * 60 * 1000) { event.waitUntil( (async () => { const copy = hit.clone(); const headers = new Headers(copy.headers); headers.set("x-craftvia-used", String(Date.now())); await cache.put(url.pathname, new Response(await copy.blob(), { status: 200, headers })); })().catch(() => undefined), ); } return hit; } async function pageNetworkFirst(request, url) { const path = url.pathname; try { const res = await fetch(request); if (res.status >= 500) throw new Error("server unavailable"); const html = (res.headers.get("content-type") || "").includes("text/html"); if (res.ok && res.type === "basic" && !res.redirected && html) { const cache = await caches.open(PAGE_CACHE); await cache.put(path === OFFLINE_URL ? OFFLINE_URL : request, res.clone()); } return res; } catch { const cache = await caches.open(PAGE_CACHE); const offline = await cache.match(OFFLINE_URL, { ignoreSearch: true, ignoreVary: true }); if (path === OFFLINE_URL) return offline || Response.error(); if (BUNDLE_VIEW.test(path) && offline) { return Response.redirect(OFFLINE_URL + "?from=" + encodeURIComponent(path + url.search), 302); } const cached = await cache.match(request, { ignoreVary: true }); if (cached) return cached; if (offline) return Response.redirect(OFFLINE_URL + "?from=" + encodeURIComponent(path + url.search), 302); return Response.error(); } } /** Caches the offline fallback page and the static assets it references (runs with the user's session cookie). */ async function precacheOffline() { try { const res = await fetch(OFFLINE_URL, { credentials: "include", cache: "no-store" }); if (!res.ok || res.redirected) return; const html = await res.clone().text(); await (await caches.open(PAGE_CACHE)).put(OFFLINE_URL, res); const assets = Array.from(new Set(html.match(STATIC_REF) || [])); const cache = await caches.open(STATIC_CACHE); await Promise.all( assets.concat(["/site.webmanifest"]).map(async (asset) => { if (await cache.match(asset)) return; const r = await fetch(asset).catch(() => null); if (r && r.ok) await cache.put(asset, r); }), ); } catch { // not signed in or offline — retried via PRECACHE_OFFLINE after the next login } } // referenced for documentation/tests: the doc cache limit is enforced by the client (prefetch.ts) void DOC_CACHE_MAX_BYTES;