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:
parent
902a1ffc22
commit
615ae2565c
7 changed files with 418 additions and 10 deletions
|
|
@ -6,7 +6,7 @@ import globals from "globals";
|
||||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: ["dist/**", "node_modules/**", "coverage/**", "experiments/**"],
|
ignores: ["dist/**", "node_modules/**", "coverage/**", "experiments/**", "public/sw.js"],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.strictTypeChecked,
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
|
@ -15,7 +15,12 @@ export default tseslint.config(
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
projectService: {
|
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,
|
tsconfigRootDir: import.meta.dirname,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
136
public/sw.js
Normal file
136
public/sw.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
/**
|
/**
|
||||||
* Lumen — Stage 2 application shell entry point.
|
* Lumen — Stage 2/3 application shell entry point.
|
||||||
* Minimal boot: layout + router + four placeholder views.
|
* Minimal boot: layout + router + four placeholder views + SW registration (Stage 3).
|
||||||
* No IDB / Cache / SW / sync / data package per IMPLEMENTATION-CONTRACT.md Stage 2 SCOPE.
|
* No IDB / dataset / sync per Stage 3 SCOPE — SW is shell-only.
|
||||||
*/
|
*/
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
import { registerSW } from "../platform/sw/register.js";
|
||||||
import { createLayout, setActiveNav } from "./layout.js";
|
import { createLayout, setActiveNav } from "./layout.js";
|
||||||
import { Router, routeForPath, normalizePath } from "../ui/router/router.js";
|
import { Router, routeForPath, normalizePath } from "../ui/router/router.js";
|
||||||
import { createEmergencyView } from "../ui/views/emergency/emergency.js";
|
import { createEmergencyView } from "../ui/views/emergency/emergency.js";
|
||||||
|
|
@ -36,10 +37,15 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
appHost.replaceChildren(root);
|
appHost.replaceChildren(root);
|
||||||
appHost.removeAttribute("aria-busy");
|
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.textContent = "Shell";
|
||||||
statusChip.setAttribute("aria-label", "Offline status: shell only");
|
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
|
// Ensure the deterministic deep-link set exists: all four destinations must be reachable
|
||||||
const router = new Router(normalizePath(location.pathname));
|
const router = new Router(normalizePath(location.pathname));
|
||||||
|
|
||||||
|
|
|
||||||
33
src/platform/sw/register.ts
Normal file
33
src/platform/sw/register.ts
Normal file
|
|
@ -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<SWRegistrationResult> {
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
@ -23,15 +23,16 @@ describe("architectural boundaries — Stage 1 gate (B-1…B-7)", () => {
|
||||||
expect(tsFiles).toEqual(
|
expect(tsFiles).toEqual(
|
||||||
expect.arrayContaining(["src/app/layout.ts", "src/app/main.ts", "src/ui/router/router.ts"]),
|
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(
|
const forbidden = tsFiles.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
f.startsWith("src/platform/") ||
|
f.startsWith("src/platform/idb/") ||
|
||||||
|
f.startsWith("src/platform/cache/") ||
|
||||||
f.startsWith("src/storage/") ||
|
f.startsWith("src/storage/") ||
|
||||||
f.startsWith("src/data/") ||
|
f.startsWith("src/data/") ||
|
||||||
f.startsWith("src/sync/"),
|
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(
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
171
tests/unit/sw.test.ts
Normal file
171
tests/unit/sw.test.ts
Normal file
|
|
@ -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\(\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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({
|
export default defineConfig({
|
||||||
appType: "spa",
|
appType: "spa",
|
||||||
|
|
@ -6,6 +61,7 @@ export default defineConfig({
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
|
plugins: [lumenShellPlugin()],
|
||||||
build: {
|
build: {
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
assetsDir: "assets",
|
assetsDir: "assets",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue