181 lines
7.2 KiB
JavaScript
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;
|