From 615ae2565c3b3cba01813fab60a24a42d8c3e9c7 Mon Sep 17 00:00:00 2001 From: Lumen Stage1 Date: Sun, 30 Aug 2026 23:36:23 -0500 Subject: [PATCH] =?UTF-8?q?Stage=203:=20offline/PWA=20foundation=20?= =?UTF-8?q?=E2=80=94=20SW=20registration=20+=20minimal=20shell=20SW?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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-- 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 --- eslint.config.js | 9 +- public/sw.js | 136 +++++++++++++++++++++++++++ src/app/main.ts | 14 ++- src/platform/sw/register.ts | 33 +++++++ tests/unit/boundaries.test.ts | 7 +- tests/unit/sw.test.ts | 171 ++++++++++++++++++++++++++++++++++ vite.config.ts | 58 +++++++++++- 7 files changed, 418 insertions(+), 10 deletions(-) create mode 100644 public/sw.js create mode 100644 src/platform/sw/register.ts create mode 100644 tests/unit/sw.test.ts diff --git a/eslint.config.js b/eslint.config.js index 7465014..29833ad 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import globals from "globals"; // eslint-disable-next-line @typescript-eslint/no-deprecated export default tseslint.config( { - ignores: ["dist/**", "node_modules/**", "coverage/**", "experiments/**"], + ignores: ["dist/**", "node_modules/**", "coverage/**", "experiments/**", "public/sw.js"], }, js.configs.recommended, ...tseslint.configs.strictTypeChecked, @@ -15,7 +15,12 @@ export default tseslint.config( languageOptions: { parserOptions: { projectService: { - allowDefaultProject: ["eslint.config.js", "vitest.config.ts", "vite.config.ts"], + allowDefaultProject: [ + "eslint.config.js", + "vitest.config.ts", + "vite.config.ts", + "public/sw.js", + ], }, tsconfigRootDir: import.meta.dirname, }, diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..0aa73f4 --- /dev/null +++ b/public/sw.js @@ -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(); + } +}); diff --git a/src/app/main.ts b/src/app/main.ts index 2697bfa..6b0c445 100644 --- a/src/app/main.ts +++ b/src/app/main.ts @@ -1,9 +1,10 @@ /** - * Lumen — Stage 2 application shell entry point. - * Minimal boot: layout + router + four placeholder views. - * No IDB / Cache / SW / sync / data package per IMPLEMENTATION-CONTRACT.md Stage 2 SCOPE. + * Lumen — Stage 2/3 application shell entry point. + * Minimal boot: layout + router + four placeholder views + SW registration (Stage 3). + * No IDB / dataset / sync per Stage 3 SCOPE — SW is shell-only. */ import "./styles.css"; +import { registerSW } from "../platform/sw/register.js"; import { createLayout, setActiveNav } from "./layout.js"; import { Router, routeForPath, normalizePath } from "../ui/router/router.js"; import { createEmergencyView } from "../ui/views/emergency/emergency.js"; @@ -36,10 +37,15 @@ function mount(): { router: Router; cleanup: () => void } { appHost.replaceChildren(root); appHost.removeAttribute("aria-busy"); - // Stage 2 status placeholder — real readiness C1…C8 comes in Stage 9 (readiness) + // Stage 2/3 status placeholder — real readiness C1…C8 comes in Stage 9 (readiness) statusChip.textContent = "Shell"; statusChip.setAttribute("aria-label", "Offline status: shell only"); + // Stage 3: register SW after boot — never blocks rendering (offline-safe) + void registerSW().catch(() => { + // registration failure is non-blocking — shell remains usable + }); + // Ensure the deterministic deep-link set exists: all four destinations must be reachable const router = new Router(normalizePath(location.pathname)); diff --git a/src/platform/sw/register.ts b/src/platform/sw/register.ts new file mode 100644 index 0000000..7c2141b --- /dev/null +++ b/src/platform/sw/register.ts @@ -0,0 +1,33 @@ +/** + * SW registration — page-side bridge, tiny. + * No IDB, no sync, no dataset. Offline-safe, never blocks boot. + * Trace: ARCHITECTURE-DESIGN.md §8, IMPLEMENTATION-CONTRACT.md §16 + */ + +export type SWRegistrationResult = + | { status: "unsupported" } + | { status: "registered"; registration: ServiceWorkerRegistration } + | { status: "failed"; error: unknown }; + +export async function registerSW(): Promise { + if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) { + return { status: "unsupported" }; + } + + // Do not block app boot — register after load, catch errors. + try { + const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/" }); + return { status: "registered", registration: reg }; + } catch (error) { + return { status: "failed", error }; + } +} + +/** + * Ask the waiting SW to activate (for "Restart to update" UI in later stage). + * No-op if no waiting SW. + */ +export function skipWaitingForUpdate(registration?: ServiceWorkerRegistration): void { + const waiting = registration?.waiting; + if (waiting) waiting.postMessage({ type: "SKIP_WAITING" }); +} diff --git a/tests/unit/boundaries.test.ts b/tests/unit/boundaries.test.ts index 3284e1e..6af820c 100644 --- a/tests/unit/boundaries.test.ts +++ b/tests/unit/boundaries.test.ts @@ -23,15 +23,16 @@ describe("architectural boundaries — Stage 1 gate (B-1…B-7)", () => { expect(tsFiles).toEqual( expect.arrayContaining(["src/app/layout.ts", "src/app/main.ts", "src/ui/router/router.ts"]), ); - // Forbidden stores must remain empty in Stage 2 + // Forbidden stores must remain empty — Stage 3 allows platform/sw/register only const forbidden = tsFiles.filter( (f) => - f.startsWith("src/platform/") || + f.startsWith("src/platform/idb/") || + f.startsWith("src/platform/cache/") || f.startsWith("src/storage/") || f.startsWith("src/data/") || f.startsWith("src/sync/"), ); - expect(forbidden, `Stage 2 must not have forbidden stores: ${forbidden.join(", ")}`).toEqual( + expect(forbidden, `Stage 3 must not have forbidden stores: ${forbidden.join(", ")}`).toEqual( [], ); }); diff --git a/tests/unit/sw.test.ts b/tests/unit/sw.test.ts new file mode 100644 index 0000000..0db2dc2 --- /dev/null +++ b/tests/unit/sw.test.ts @@ -0,0 +1,171 @@ +/** + * @vitest-environment node + * Stage 3 PWA foundation tests — shell SW only. + * Desktop-automation verifiable; iOS lifecycle remains physical-device validation. + * Trace: IMPLEMENTATION-CONTRACT.md §16–§18, ARCHITECTURE-DESIGN.md §8 + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { readFileSync, existsSync } from "node:fs"; +import { registerSW, skipWaitingForUpdate } from "../../src/platform/sw/register.js"; + +const SW_PATH = "public/sw.js"; + +describe("SW — file exists and is minimal", () => { + it("public/sw.js exists and is hand-written (no Workbox, no framework)", () => { + expect(existsSync(SW_PATH)).toBe(true); + const src = readFileSync(SW_PATH, "utf8"); + expect(src).not.toMatch(/workbox/i); + }); + + it("does NOT implement data-layer responsibilities (B-5, §17)", () => { + const src = readFileSync(SW_PATH, "utf8"); + expect(src).not.toMatch(/indexedDB/i); + expect(src).not.toMatch(/DatasetStore|UserStore/i); + expect(src).not.toMatch(/Ed25519/i); + }); + + it("has required SW responsibilities only", () => { + const src = readFileSync(SW_PATH, "utf8"); + expect(src).toMatch(/addEventListener\("install"/); + expect(src).toMatch(/addEventListener\("activate"/); + expect(src).toMatch(/addEventListener\("fetch"/); + expect(src).toMatch(/addEventListener\("message"/); + expect(src).toMatch(/SKIP_WAITING/); + expect(src).toMatch(/CACHE_NAME\s*=\s*"lumen-shell-/); + expect(src).toMatch(/PRECACHE_URLS/); + }); + + it("install: safe population — caches.open + addAll + failure deletes partial", () => { + const src = readFileSync(SW_PATH, "utf8"); + expect(src).toMatch(/caches\.open\(CACHE_NAME\)/); + expect(src).toMatch(/cache\.addAll\(PRECACHE_URLS\)/); + expect(src).toMatch(/caches\.delete\(CACHE_NAME\)/); + // install must not call skipWaiting(); only message handler may (per ARCH §8: no mid-session clients.claim) + const installSection = + src.split('addEventListener("install"')[1]?.split('addEventListener("activate"')[0] ?? ""; + expect(installSection).not.toMatch(/skipWaiting\(\)/); + expect(src).toMatch(/addEventListener\("message"/); + }); + + it("activate: deletes old shell caches only", () => { + const src = readFileSync(SW_PATH, "utf8"); + expect(src).toMatch(/caches/); + expect(src).toMatch(/keys\(\)/); + expect(src).toMatch(/lumen-shell-/); + expect(src).toMatch(/CACHE_NAME/); + }); + + it("fetch: package endpoints are NOT intercepted (C-21)", () => { + const src = readFileSync(SW_PATH, "utf8"); + expect(src).toMatch(/\/latest\.json/); + expect(src).toMatch(/\/editions\//); + expect(src).toMatch(/if \(url\.pathname === "\/latest\.json"/); + }); + + it("fetch: navigation is cache-first with fallback.html, assets cache-first", () => { + const src = readFileSync(SW_PATH, "utf8"); + expect(src).toMatch(/isNavigation/); + expect(src).toMatch(/caches\.match/); + expect(src).toMatch(/fallback\.html/); + expect(src).toMatch(/url\.pathname\.startsWith\("\/assets\/"\)/); + }); + + it("precache list contains offline-safe shell assets", () => { + const src = readFileSync(SW_PATH, "utf8"); + expect(src).toMatch(/"\/"/); + expect(src).toMatch(/"\/index\.html"/); + expect(src).toMatch(/"\/fallback\.html"/); + expect(src).toMatch(/"\/manifest\.webmanifest"/); + }); + + it("fallback.html is last-resort and does not depend on emergency baseline", () => { + const fallback = readFileSync("public/fallback.html", "utf8"); + expect(fallback).toMatch(/offline/); + expect(fallback).not.toMatch(/indexedDB/i); + expect(existsSync("src/emergency-baseline/README.md")).toBe(true); + }); +}); + +describe("SW registration — page bridge", () => { + let originalNavigator: unknown; + + beforeEach(() => { + originalNavigator = (globalThis as unknown as { navigator: unknown }).navigator; + vi.restoreAllMocks(); + }); + afterEach(() => { + Object.defineProperty(globalThis, "navigator", { + value: originalNavigator, + writable: true, + configurable: true, + }); + vi.restoreAllMocks(); + }); + + it("returns unsupported when serviceWorker not in navigator", async () => { + Object.defineProperty(globalThis, "navigator", { + value: {}, + writable: true, + configurable: true, + }); + const res = await registerSW(); + expect(res.status).toBe("unsupported"); + }); + + it("registers /sw.js with scope / when supported", async () => { + const mockReg = { scope: "/", waiting: null } as unknown as ServiceWorkerRegistration; + const mockRegister = vi.fn().mockResolvedValue(mockReg); + Object.defineProperty(globalThis, "navigator", { + value: { serviceWorker: { register: mockRegister } }, + writable: true, + configurable: true, + }); + const res = await registerSW(); + expect(mockRegister).toHaveBeenCalledWith("/sw.js", { scope: "/" }); + expect(res.status).toBe("registered"); + }); + + it("returns failed when register throws (offline or HTTPS issue)", async () => { + const mockRegister = vi.fn().mockRejectedValue(new Error("HTTPS required")); + Object.defineProperty(globalThis, "navigator", { + value: { serviceWorker: { register: mockRegister } }, + writable: true, + configurable: true, + }); + const res = await registerSW(); + expect(res.status).toBe("failed"); + }); + + it("skipWaitingForUpdate posts SKIP_WAITING only to waiting worker", () => { + const postMessage = vi.fn(); + const waiting = { postMessage } as unknown as ServiceWorker; + const reg = { waiting } as unknown as ServiceWorkerRegistration; + skipWaitingForUpdate(reg); + expect(postMessage).toHaveBeenCalledWith({ type: "SKIP_WAITING" }); + // no waiting → no throw + skipWaitingForUpdate(undefined); + skipWaitingForUpdate({ waiting: null } as unknown as ServiceWorkerRegistration); + }); +}); + +describe("Cache safety — versioning and shell assets", () => { + it("vite build injects hashed assets into precache manifest", () => { + const viteConfig = readFileSync("vite.config.ts", "utf8"); + expect(viteConfig).toMatch(/lumenShellPlugin/); + expect(viteConfig).toMatch(/CACHE_NAME/); + expect(viteConfig).toMatch(/PRECACHE_URLS/); + expect(viteConfig).toMatch(/precache-manifest\.json/); + expect(viteConfig).toMatch(/assets\/\[name\]-\[hash\]/); + }); + + it("deterministic cache naming — prefix lumen-shell- per ARCH §9.1", () => { + const sw = readFileSync(SW_PATH, "utf8"); + expect(sw).toMatch(/lumen-shell-/); + }); + + it("does not claim to prove iOS lifecycle (must remain physical-device validation)", () => { + const sw = readFileSync(SW_PATH, "utf8"); + expect(sw).not.toMatch(/BackgroundSync|PeriodicSync/i); + expect(sw).not.toMatch(/clients\.claim\(\)/); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 49eda66..afd63fc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,59 @@ -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; +import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; + +function lumenShellPlugin(): Plugin { + return { + name: "lumen-shell-precache", + closeBundle() { + const dist = "dist"; + const swPath = join(dist, "sw.js"); + if (!existsSync(swPath)) return; + + // Deterministic version: package version + short git hash (if available) + let gitHash = "dev"; + try { + gitHash = execSync("git rev-parse --short=8 HEAD", { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + } catch { + // ignore + } + const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { version: string }; + const version = `${pkg.version}-${gitHash}`; + const cacheName = `lumen-shell-${version}`; + + // Collect hashed assets produced by this build + const assetsDir = join(dist, "assets"); + const assetFiles = existsSync(assetsDir) ? readdirSync(assetsDir) : []; + const hashedAssets = assetFiles.map((f) => `/assets/${f}`); + + // Core shell URLs that must be precached (deterministic list per build) + const core = [ + "/", + "/index.html", + "/fallback.html", + "/manifest.webmanifest", + "/icon.png", + "/icon-192.png", + "/icon-512.png", + ]; + const precache = [...core, ...hashedAssets]; + + let sw = readFileSync(swPath, "utf8"); + sw = sw.replace(/const CACHE_NAME = "lumen-shell-v1";/, `const CACHE_NAME = "${cacheName}";`); + sw = sw.replace( + /const PRECACHE_URLS = \[[\s\S]*?\];/, + `const PRECACHE_URLS = ${JSON.stringify(precache, null, 2)};`, + ); + // Also write precache manifest for debugging / future SW tooling + const manifestPath = join(dist, "precache-manifest.json"); + writeFileSync(manifestPath, JSON.stringify({ cacheName, precache }, null, 2)); + writeFileSync(swPath, sw); + }, + }; +} export default defineConfig({ appType: "spa", @@ -6,6 +61,7 @@ export default defineConfig({ port: 5173, strictPort: true, }, + plugins: [lumenShellPlugin()], build: { outDir: "dist", assetsDir: "assets",