Stage 3: offline/PWA foundation — SW registration + minimal shell SW

- Normalize branch to main
- public/sw.js: hand-written, tiny (install precache + activate cleanup + fetch navigation/assets cache-first + package passthrough /latest.json /editions/** C-21 + message SKIP_WAITING). Safe population: open+addAll atomically, delete partial on failure; versioned cache lumen-shell-<version>-<gitHash> deterministic per build (injected via Vite plugin, not static); no skipWaiting in install (next full start per ARCH §8), no IDB/sync/dataset/B-5
- public/fallback.html: Ring-0 last-resort, no IDB, preserves future emergency baseline (src/emergency-baseline remains empty until Stage 10)
- src/platform/sw/register.ts: offline-safe bridge, never blocks boot, scope /, handles unsupported/failed, skipWaitingForUpdate
- src/app/main.ts: import + void registerSW().catch (non-blocking)
- vite.config.ts: lumenShellPlugin closeBundle injects CACHE_NAME + PRECACHE_URLS (core /, index.html, fallback.html, manifest, icons + hashed assets/*) + precache-manifest.json; hashed immutable assets via rollup output
- Build: dist/index.html 0.92kB, style 3.06kB/1.18gz, js 8.15kB/2.74gz (shell <1MB/150kB budgets)
- Tests: tests/unit/sw.test.ts (16 tests: file minimal, B-5, install/activate/fetch/message, package passthrough, fallback last-resort, registration unsupported/registered/failed, skipWaiting, versioning)
- CI: typecheck + lint + format + test (34 tests) + build — desktop automation; iOS lifecycle/persistence/eviction/jetsam/install still physical-device validation per ARCHITECTURE-VALIDATION.md §4
This commit is contained in:
Lumen Stage1 2026-08-30 23:36:23 -05:00
commit 615ae2565c
7 changed files with 418 additions and 10 deletions

136
public/sw.js Normal file
View file

@ -0,0 +1,136 @@
/**
* 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-v1";
/**
* 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",
"/icon.png",
"/icon-192.png",
"/icon-512.png",
];
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();
}
});