Files
craftvia/public/sw.js
T

181 lines
7.2 KiB
JavaScript

/*
* 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/<id> LRU limit 300 MB); documents not in the cache go to the network
* /files/<id>
* 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;