140 lines
4.4 KiB
JavaScript
140 lines
4.4 KiB
JavaScript
/**
|
||
* Lumen — minimal hand-written service worker (Stage 3)
|
||
* Scope: application shell only. No IDB, no dataset, no sync.
|
||
* Trace: ARCHITECTURE-DESIGN.md §8, IMPLEMENTATION-CONTRACT.md §16–§18
|
||
*/
|
||
|
||
/* eslint-disable no-restricted-globals -- SW global scope is intentional */
|
||
|
||
/** Cache version — replaced at build time via Vite define/plugin. Deterministic per shell build. */
|
||
const CACHE_NAME = "lumen-shell-0.1.0-fcc18ddc";
|
||
|
||
/**
|
||
* Precache list — replaced at build time with versioned shell list
|
||
* (index.html, hashed JS/CSS, manifest, icons, fallback.html).
|
||
* Placeholder is replaced by Vite plugin after bundle.
|
||
*/
|
||
const PRECACHE_URLS = [
|
||
"/",
|
||
"/index.html",
|
||
"/fallback.html",
|
||
"/manifest.webmanifest",
|
||
"/sol_lunar_icon.svg",
|
||
"/icon.png",
|
||
"/icon-192.png",
|
||
"/icon-512.png",
|
||
"/assets/index-CaoEjGwW.js",
|
||
"/assets/index-CaoEjGwW.js.map",
|
||
"/assets/style-D9rb7TfV.css"
|
||
];
|
||
|
||
self.addEventListener("install", (event) => {
|
||
// Safe population: atomically cache all shell URLs; if any fails, install fails
|
||
// and the new cache is not treated as valid (existing shell remains usable).
|
||
event.waitUntil(
|
||
caches
|
||
.open(CACHE_NAME)
|
||
.then((cache) => cache.addAll(PRECACHE_URLS))
|
||
// Do NOT call skipWaiting here — wait for explicit message or next full start
|
||
// per ARCHITECTURE-DESIGN.md:255 (no mid-session clients.claim).
|
||
.catch((err) => {
|
||
// Ensure failed install does not leave partial cache as valid:
|
||
// delete the newly created cache on failure.
|
||
caches.delete(CACHE_NAME).finally(() => {
|
||
// re-throw to signal install failure to the browser
|
||
throw err;
|
||
});
|
||
}),
|
||
);
|
||
});
|
||
|
||
self.addEventListener("activate", (event) => {
|
||
// Safe replacement: delete old shell caches only after new shell is installed.
|
||
// Old shell remains usable until this runs (next full start).
|
||
event.waitUntil(
|
||
caches
|
||
.keys()
|
||
.then((keys) =>
|
||
Promise.all(
|
||
keys
|
||
.filter((k) => k.startsWith("lumen-shell-") && k !== CACHE_NAME)
|
||
.map((k) => caches.delete(k)),
|
||
),
|
||
),
|
||
);
|
||
});
|
||
|
||
self.addEventListener("fetch", (event) => {
|
||
const req = event.request;
|
||
const url = new URL(req.url);
|
||
|
||
// Only handle same-origin GET
|
||
if (req.method !== "GET" || url.origin !== self.location.origin) return;
|
||
|
||
// Package endpoints are NOT intercepted — app-layer sync controls staging (C-21)
|
||
if (url.pathname === "/latest.json" || url.pathname.startsWith("/editions/")) {
|
||
return; // let network handle it
|
||
}
|
||
|
||
// Navigation requests — cache-first against shell cache, fallback to network, then fallback.html
|
||
const isNavigation =
|
||
req.mode === "navigate" ||
|
||
req.destination === "document" ||
|
||
req.headers.get("accept")?.includes("text/html");
|
||
|
||
if (isNavigation) {
|
||
event.respondWith(
|
||
caches.match(req).then((cached) => {
|
||
if (cached) return cached;
|
||
return fetch(req)
|
||
.then((res) => {
|
||
// Optional: opportunistically re-fill cache with fresh index.html on navigation fetch
|
||
// but do not block response on cache put.
|
||
if (res.ok) {
|
||
const clone = res.clone();
|
||
caches.open(CACHE_NAME).then((c) => c.put(req, clone));
|
||
}
|
||
return res;
|
||
})
|
||
.catch(() =>
|
||
caches
|
||
.match("/fallback.html")
|
||
.then((fb) => fb ?? new Response("Offline", { status: 503 })),
|
||
);
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Hashed shell assets (/assets/*) — cache-first (immutable, 1 year per ARCH §24)
|
||
if (url.pathname.startsWith("/assets/")) {
|
||
event.respondWith(
|
||
caches.match(req).then((cached) => {
|
||
if (cached) return cached;
|
||
return fetch(req).then((res) => {
|
||
if (res.ok) {
|
||
const clone = res.clone();
|
||
caches.open(CACHE_NAME).then((c) => c.put(req, clone));
|
||
}
|
||
return res;
|
||
});
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Other same-origin GETs (manifest, icons) — cache-first with network fallback
|
||
event.respondWith(
|
||
caches.match(req).then((cached) => {
|
||
if (cached) return cached;
|
||
return fetch(req).catch(() => caches.match(req));
|
||
}),
|
||
);
|
||
});
|
||
|
||
self.addEventListener("message", (event) => {
|
||
const data = event.data;
|
||
if (typeof data === "object" && data !== null && "type" in data && data.type === "SKIP_WAITING") {
|
||
self.skipWaiting();
|
||
}
|
||
});
|