Public: anyone can read this; the crew can change it.
The service worker serves a small offline page for a navigation that cannot reach the forge, instead of the browser's error #12341
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12324-the-service-worker-serves-a-small-offlin → main 9 commits fork 3 threads · 3 unresolved
docs/features/offline.md added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | # Offline page | |
| 2 | ||
| 3 | `/offline` is a static page (under 2 KB, no script) saying the forge is unreachable and when it was last seen. It wears the forge's own tokens (`--bg`, `--fg`, `--muted-fg`, light and dark by the OS setting) inlined under a hash-admitted CSP, and retries itself every 5 s with a `<meta http-equiv="refresh">`, which is not script. | |
| 4 | ||
| 5 | The service worker (`src/web/sw.ts`) fetches it at install and serves it only for a navigation that fails with nothing kept; the API, `/events` and assets are never answered by it. "Last seen" is the time of the worker's last install, since that is when the copy was made: after a long idle it can be days old. Only this page and `/` are cached, so a signed-in page is never served stale from here. |
src/core/reserved.ts +1
| @@ -123,6 +123,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 123 | 123 | "changelog.atom", |
| 124 | 124 | "explore.atom", |
| 125 | 125 | "sw.js", |
| 126 | "offline", | |
| 126 | 127 | "tell", |
| 127 | 128 | "talk", |
| 128 | 129 | "notes", |
src/http/route_marks.ts +1
| @@ -160,6 +160,7 @@ export const HEADS: Readonly<Record<string, Mark | "mixed">> = { | ||
| 160 | 160 | "/releases.json": "open", |
| 161 | 161 | "/repos": "open", |
| 162 | 162 | "/resilience": "admin", |
| 163 | "/offline": "open", | |
| 163 | 164 | "/review": "open", |
| 164 | 165 | "/robots.txt": "open", |
| 165 | 166 | "/runs": "open", |
src/http/server.ts +2
| @@ -323,6 +323,7 @@ import { liveFeedFragment } from "../web/studio/feed.ts"; | ||
| 323 | 323 | import { likeSubmit } from "../web/studio/like.ts"; |
| 324 | 324 | import { HOME_REGIONS } from "../web/studio/home_last.ts"; |
| 325 | 325 | import { serviceWorker } from "../web/sw.ts"; |
| 326 | import { offlinePage } from "../web/pages/offline.ts"; | |
| 326 | 327 | import { explorePage } from "../web/studio/explore.ts"; |
| 327 | 328 | import { isPacksMirror, MIRROR_PUSH_REFUSED } from "../packs/owner.ts"; |
| 328 | 329 | import { packEdit, packNew, packPage, packRepos, packsMirror, packsPage } from "../web/pages/packs.ts"; |
| @@ -867,6 +868,7 @@ export function buildRouter(): Router<Handler> { | ||
| 867 | 868 | // The service worker: root scope needs a root path, and it must never be |
| 868 | 869 | // cached by the browser's HTTP cache or an update would wait a day. |
| 869 | 870 | router.add("GET", "/sw.js", serviceWorker); |
| 871 | router.add("GET", "/offline", offlinePage); | |
| 870 | 872 | // The page-view beacon's report (http/measure.ts): numbers only, no IP kept. |
| 871 | 873 | router.add("POST", "/_measure", (ctx) => measureSubmit(ctx, routeOf)); |
| 872 | 874 | // An uncaught script error the beacon saw (http/script_errors.ts): counts only. |
src/web/asset.ts +1 −1
| @@ -207,7 +207,7 @@ export function spriteAsset(): FileAsset { | ||
| 207 | 207 | * plus everything that is a file, an API or an edge hand-off. |
| 208 | 208 | */ |
| 209 | 209 | export const NO_PREFETCH_PATHS = [ |
| 210 | "/assets/*", "/api/*", "/_plat/*", "/_measure*", "/_script-error", "/mcp*", "/oauth/*", "/sw.js", | |
| 210 | "/assets/*", "/api/*", "/_plat/*", "/_measure*", "/_script-error", "/mcp*", "/oauth/*", "/sw.js", "/offline", | |
| 211 | 211 | "/*/*/raw/*", "/*/*/media/*", "/*/*/archive/*", "/*/*/app/*/open", |
| 212 | 212 | "/logout*", "/signout*", "/sign-out*", "/signin*", "/sign-in*", "/login*", |
| 213 | 213 | "/claim*", "/welcome*", "/first-admin*", "/verify*", "/go/*", "/-/*", "/apps/open*", |
src/web/pages/offline.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** | |
| 2 | * /offline: the page the service worker keeps (at install) and serves for a | |
| 3 | * navigation that cannot reach the forge. Static, server-rendered, no script, | |
| 4 | * no signed-in content: the time it shows is when the worker last reached the | |
| 5 | * forge, because that is when this copy was made. | |
| 6 | */ | |
| 7 | import { createHash } from "node:crypto"; | |
| 8 | import { THEMES } from "../../design/themes.ts"; | |
| 9 | import { securityFloor } from "../../http/respond.ts"; | |
| 10 | ||
| 11 | const pad = (n: number) => String(n).padStart(2, "0"); | |
| 12 | ||
| 13 | /** `2026-11-14 09:30 UTC`: plain data in, plain text out. */ | |
| 14 | export function seenAt(at: Date): string { | |
| 15 | return `${at.getUTCFullYear()}-${pad(at.getUTCMonth() + 1)}-${pad(at.getUTCDate())} ${pad(at.getUTCHours())}:${pad(at.getUTCMinutes())} UTC`; | |
| 16 | } | |
| 17 | ||
| 18 | const light = THEMES.light.tokens; | |
| 19 | const dark = THEMES.dark.tokens; | |
| 20 | /** | |
| 21 | * The forge's own tokens (design/themes.ts) and font stack, inlined: the page | |
| 22 | * cannot load the stylesheet it is standing in for. | |
| 23 | */ | |
| 24 | const STYLE = | |
| 25 | `:root{color-scheme:light dark;--bg:${light.bg};--fg:${light.fg};--muted-fg:${light["muted-fg"]}}` + | |
| 26 | `@media(prefers-color-scheme:dark){:root{--bg:${dark.bg};--fg:${dark.fg};--muted-fg:${dark["muted-fg"]}}}` + | |
| 27 | `body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif}` + | |
| 28 | `main{max-width:36rem;margin:0 auto;padding:4rem 16px}` + | |
| 29 | `h1{font-size:20px;font-weight:620;letter-spacing:-.02em;line-height:1.25;margin:0 0 .75rem}` + | |
| 30 | `p{margin:0}.muted{color:var(--muted-fg);margin-top:.5rem}`; | |
| 31 | /** The page is static and scriptless: its one stylesheet is admitted by hash, nothing else is. */ | |
| 32 | const CSP = `default-src 'none'; style-src 'sha256-${createHash("sha256").update(STYLE).digest("base64")}'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`; | |
| 33 | ||
| 34 | export function offlineHtml(at: Date): string { | |
| 35 | return ( | |
| 36 | '<!doctype html><html lang="en"><head><meta charset="utf-8">' + | |
| 37 | '<meta name="viewport" content="width=device-width,initial-scale=1">' + | |
| 38 | '<meta http-equiv="refresh" content="5">' + | |
| 39 | "<title>The forge is unreachable</title>" + | |
| 40 | `<style>${STYLE}</style></head><body><main>` + | |
| 41 | "<h1>The forge is unreachable</h1>" + | |
| 42 | "<p>This page tries again every few seconds.</p>" + | |
| 43 | `<p class="muted">Last seen <time datetime="${at.toISOString()}">${seenAt(at)}</time>.</p>` + | |
| 44 | "</main></body></html>" | |
| 45 | ); | |
| 46 | } | |
| 47 | ||
| 48 | export function offlinePage(): Response { | |
| 49 | return securityFloor( | |
| 50 | new Response(offlineHtml(new Date()), { | |
| 51 | headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache", "content-security-policy": CSP }, | |
| 52 | }), | |
| 53 | ); | |
| 54 | } |
src/web/sw.ts +9 −5
| @@ -11,7 +11,8 @@ | ||
| 11 | 11 | * straight to the network untouched. Writes made offline wait in the |
| 12 | 12 | * page's outbox (`outbox.ts`), not here. |
| 13 | 13 | * - A navigation is never answered with `Response.error()`: no network and |
| 14 | * nothing kept gives an inline offline page (kept at install) that retries. | |
| 14 | * nothing kept gives the `/offline` page (kept at install; an inline one if | |
| 15 | * that fetch failed). Only that one page is kept besides `/`. | |
| 15 | 16 | * - The script's own CSP allows same-origin fetch (see SW_CSP); the floor's |
| 16 | 17 | * `default-src 'none'` made every fetch in the worker fail. |
| 17 | 18 | * - FORGE_SW_KILL=1 serves a worker that unregisters itself. |
| @@ -21,9 +22,9 @@ | ||
| 21 | 22 | |
| 22 | 23 | import { sha256 } from "../kernel/hash.ts"; |
| 23 | 24 | |
| 24 | export const SW_CACHE = "forge-shell-v1"; | |
| 25 | export const SW_CACHE = "forge-shell-v2"; | |
| 25 | 26 | |
| 26 | /** Shown when the forge is unreachable and nothing is kept; it retries itself. */ | |
| 27 | /** The inline fallback, for a forge unreachable at install and nothing kept; it retries itself. */ | |
| 27 | 28 | export const OFFLINE_HTML = |
| 28 | 29 | '<!doctype html><html lang="en"><head><meta charset="utf-8">' + |
| 29 | 30 | '<meta name="viewport" content="width=device-width,initial-scale=1">' + |
| @@ -45,7 +46,7 @@ self.addEventListener('activate', function (event) { | ||
| 45 | 46 | |
| 46 | 47 | const source = /* js */ ` |
| 47 | 48 | var CACHE = ${JSON.stringify(SW_CACHE)}; |
| 48 | var OFFLINE_URL = '/__offline'; | |
| 49 | var OFFLINE_URL = '/offline'; | |
| 49 | 50 | var OFFLINE_HTML = ${JSON.stringify(OFFLINE_HTML)}; |
| 50 | 51 | function offlineResponse() { |
| 51 | 52 | return new Response(OFFLINE_HTML, { |
| @@ -54,7 +55,10 @@ function offlineResponse() { | ||
| 54 | 55 | } |
| 55 | 56 | self.addEventListener('install', function (event) { |
| 56 | 57 | event.waitUntil(caches.open(CACHE).then(function (c) { |
| 57 | return c.put(OFFLINE_URL, offlineResponse()); | |
| 58 | // the server's own page carries the time it was last reached; the inline one is the fallback | |
The kept page replaces the inline fallback that refreshed itself every 5 s (<meta http-equiv=refresh>), so a person on the offline page now has to reload by hand when the forge comes back, and that is a regression from the base. A meta refresh is not script and is allowed by this CSP; add it to the page, or say in docs/features/offline.md why not. Also: a cached copy shows the time it was rendered at install, so 'Last seen' can be days old after a worker has been idle. The wording is right, but the docs should say it is the worker's last install. |
||
| 59 | return fetch(OFFLINE_URL).then(function (r) { | |
| 60 | return r.ok ? c.put(OFFLINE_URL, r) : c.put(OFFLINE_URL, offlineResponse()); | |
| 61 | }, function () { return c.put(OFFLINE_URL, offlineResponse()); }); | |
| 58 | 62 | }).then(function () { return self.skipWaiting(); })); |
| 59 | 63 | }); |
| 60 | 64 | function offline() { |
test/design-audit.test.ts +1 −1
| @@ -17,7 +17,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8"); | ||
| 17 | 17 | |
| 18 | 18 | /** Routes that answer a file, a fragment, a redirect hop or a callback. */ |
| 19 | 19 | const NOT_PAGES = |
| 20 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|new\/issue$|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|openapi\.json$|releases\.json$|changelog\.atom$|explore\.atom$|docs(\/[^/]*)?$|audit\.csv$|settings\/traffic\/scrapes\/export$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|broker\/origins\/start$|home\/archived\/|settings\/backups\/live$|api\/v1(\/|$))/; | |
| 20 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|new\/issue$|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|offline$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|openapi\.json$|releases\.json$|changelog\.atom$|explore\.atom$|docs(\/[^/]*)?$|audit\.csv$|settings\/traffic\/scrapes\/export$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|broker\/origins\/start$|home\/archived\/|settings\/backups\/live$|api\/v1(\/|$))/; | |
| 21 | 21 | |
| 22 | 22 | const getRoutes = [ |
| 23 | 23 | ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g), |
test/forge-looks.test.ts +1
| @@ -47,6 +47,7 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 47 | 47 | "/manifest.webmanifest": "the install manifest, JSON", |
| 48 | 48 | "/opensearch.xml": "the browser search description, XML", |
| 49 | 49 | "/sw.js": "the service worker script", |
| 50 | "/offline": "the static fallback the service worker keeps for an unreachable forge", | |
| 50 | 51 | "/audit.csv": "a CSV download of /audit", |
| 51 | 52 | "/maintenance": "no picture: the cap of 24 looks is spent; an admin's page, covered by test/maintenance.test.ts", |
| 52 | 53 | "/claim": "answers 404 to an admin: it exists only for a link-holder", |
test/no-polling.test.ts +4
| @@ -95,6 +95,7 @@ async function render(base: string, path: string, cookie: string): Promise<{ sta | ||
| 95 | 95 | /** file -> the patterns it may carry, and why */ |
| 96 | 96 | const ALLOWED: Record<string, { allow: string[]; why: string }> = { |
| 97 | 97 | "src/web/sw.ts": { allow: ['http-equiv="refresh"'], why: "the restart page: the forge is down, so there is no /events to hear" }, |
| 98 | "src/web/pages/offline.ts": { allow: ['http-equiv="refresh"'], why: "the offline page: the forge is unreachable, so there is no /events to hear" }, | |
| 98 | 99 | "src/web/pages/talk.ts": { allow: ["setInterval"], why: "server side: the stream of one turn watches the run's row, which has no event to hear" }, |
| 99 | 100 | "src/web/client.ts": { allow: ["setInterval"], why: "clocks that only redraw text" }, |
| 100 | 101 | "src/web/space_client.ts": { allow: ["setInterval"], why: "the map clock label only redraws text; it fetches and reloads nothing" }, |
| @@ -367,6 +368,9 @@ describe("every page the router lists", () => { | ||
| 367 | 368 | const res = await fetch(`${servers.url}${path}`, { headers: { "accept-encoding": "identity", ...headers }, redirect: "manual", signal: AbortSignal.timeout(8_000) }); |
| 368 | 369 | if (res.headers.get("refresh")) offenders.push(`${path}: Refresh header`); |
| 369 | 370 | if (res.status !== 200 || !(res.headers.get("content-type") ?? "").includes("text/html")) { await res.body?.cancel(); return; } |
| 371 | // The offline page is the one routed page that refreshes: it shows only when the forge | |
| 372 | // is down, so there is no /events to hear. | |
| 373 | if (path === "/offline") { await res.body?.cancel(); return; } | |
| 370 | 374 | htmlPages.push(path); |
| 371 | 375 | const html = await res.text(); |
| 372 | 376 | for (const found of findPolling(html.replace(/<script\b[^>]*>[^]*?<\/script\s*>/gi, ""))) offenders.push(`${path}: ${found}`); |
test/offline-page.test.ts added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createApp, type App } from "../src/app.ts"; | |
| 6 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 7 | import { createHash } from "node:crypto"; | |
| 8 | import { THEMES } from "../src/design/themes.ts"; | |
| 9 | import { swSource } from "../src/web/sw.ts"; | |
| 10 | import { offlineHtml, seenAt } from "../src/web/pages/offline.ts"; | |
| 11 | ||
| 12 | let dir: string; | |
| 13 | let app: App; | |
| 14 | let servers: Servers; | |
| 15 | beforeAll(async () => { | |
| 16 | dir = await mkdtemp(join(tmpdir(), "forge-offline-page-")); | |
| 17 | app = createApp({ dataDir: dir, port: 0 }); | |
| 18 | servers = await serve(app); | |
| 19 | }); | |
| 20 | afterAll(async () => { | |
| 21 | servers.stop(); | |
| 22 | app.close(); | |
| 23 | await rm(dir, { recursive: true, force: true }); | |
| 24 | }); | |
| 25 | ||
| 26 | test("/offline is a small static page with the last-seen time, the floor and no script", async () => { | |
| 27 | const res = await fetch(`${servers.url}/offline`); | |
| 28 | expect(res.status).toBe(200); | |
| 29 | const body = await res.text(); | |
| 30 | expect(new TextEncoder().encode(body).length).toBeLessThan(2048); | |
| 31 | expect(body).toContain("The forge is unreachable"); | |
| 32 | expect(body).toMatch(/<time datetime="\d{4}-\d\d-\d\dT[^"]+Z">\d{4}-\d\d-\d\d \d\d:\d\d UTC<\/time>/); | |
| 33 | expect(body.toLowerCase()).not.toContain("<script"); | |
| 34 | expect(body).not.toMatch(/\son\w+=/i); | |
| 35 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 36 | expect(res.headers.get("referrer-policy")).toBeTruthy(); | |
| 37 | }); | |
| 38 | ||
| 39 | test("the time reads in UTC", () => { | |
| 40 | expect(seenAt(new Date("2026-11-14T09:05:00Z"))).toBe("2026-11-14 09:05 UTC"); | |
| 41 | }); | |
| 42 | ||
| 43 | test("the page wears the forge's tokens, retries itself and keeps the hash-admitted policy", async () => { | |
outdated
This test does not prove the behaviour. Asserting that swSource contains a string, and that nothing after '/assets/' contains 'offline()', passes even if install never fetches /offline or the navigation branch never serves it. Drive the worker the way test/offline.test.ts does: run install with a stubbed fetch returning the page and assert the cached body is the server's; run a failed navigate with nothing kept and assert the body is the /offline page; assert a failed non-navigation request (an /api/ GET, an /assets/ GET) does not get it. |
||
| 44 | const res = await fetch(`${servers.url}/offline`); | |
| 45 | const body = await res.text(); | |
| 46 | expect(body).toContain(`--bg:${THEMES.light.tokens.bg}`); | |
| 47 | expect(body).toContain(`--bg:${THEMES.dark.tokens.bg}`); | |
| 48 | expect(body).toContain("var(--muted-fg)"); | |
| 49 | expect(body).toContain('<meta http-equiv="refresh" content="5">'); | |
| 50 | const style = body.match(/<style>([\s\S]*?)<\/style>/)![1]!; | |
| 51 | const hash = createHash("sha256").update(style).digest("base64"); | |
| 52 | expect(res.headers.get("content-security-policy")).toContain(`style-src 'sha256-${hash}'`); | |
| 53 | }); | |
| 54 | ||
| 55 | /** Run the real worker source against stubs, as test/offline.test.ts does. */ | |
| 56 | function boot(serverPage: string | null) { | |
| 57 | const handlers: Record<string, (e: any) => void> = {}; | |
| 58 | const store = new Map<string, Response>(); | |
| 59 | const path = (k: any) => (typeof k === "string" ? k : new URL(k.url).pathname); | |
| 60 | const cache = { put: async (k: any, v: Response) => void store.set(path(k), v) }; | |
| 61 | const self = { addEventListener: (n: string, f: any) => (handlers[n] = f), skipWaiting() {}, clients: { claim: async () => {} } }; | |
| 62 | const caches = { | |
| 63 | open: async () => cache, | |
| 64 | match: async (k: any) => store.get(path(k))?.clone(), | |
| 65 | keys: async () => [], | |
| 66 | delete: async () => true, | |
| 67 | }; | |
| 68 | const fetched: string[] = []; | |
| 69 | const net = async (r: any) => { | |
| 70 | fetched.push(typeof r === "string" ? r : new URL(r.url).pathname); | |
| 71 | if (serverPage === null) throw new TypeError("down"); | |
| 72 | return new Response(serverPage, { headers: { "content-type": "text/html" } }); | |
| 73 | }; | |
| 74 | new Function("self", "caches", "fetch", "location", swSource)(self, caches, net, new URL("http://x")); | |
| 75 | return { handlers, store, fetched }; | |
| 76 | } | |
| 77 | const ask = (h: any, path: string, mode = "navigate") => { | |
| 78 | let out: Promise<Response> | undefined; | |
| 79 | h.fetch({ request: { method: "GET", mode, url: `http://x${path}` }, respondWith: (p: Promise<Response>) => (out = p) }); | |
| 80 | return out; | |
| 81 | }; | |
| 82 | ||
| 83 | test("install fetches /offline and keeps the server's own body", async () => { | |
| 84 | const page = offlineHtml(new Date("2026-11-14T09:05:00Z")); | |
| 85 | const { handlers, store, fetched } = boot(page); | |
| 86 | let done: Promise<unknown> | undefined; | |
| 87 | handlers.install!({ waitUntil: (p: Promise<unknown>) => (done = p) }); | |
| 88 | await done; | |
| 89 | expect(fetched).toContain("/offline"); | |
| 90 | expect(await store.get("/offline")!.text()).toBe(page); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a failed navigation with nothing kept gets the kept /offline page, not the inline one", async () => { | |
| 94 | const page = offlineHtml(new Date("2026-11-14T09:05:00Z")); | |
| 95 | const up = boot(page); | |
| 96 | let done: Promise<unknown> | undefined; | |
| 97 | up.handlers.install!({ waitUntil: (p: Promise<unknown>) => (done = p) }); | |
| 98 | await done; | |
| 99 | // the forge goes away; the worker keeps what it kept | |
| 100 | const down = boot(null); | |
| 101 | down.store.set("/offline", up.store.get("/offline")!); | |
| 102 | const res = await ask(down.handlers, "/alice/forge/pulls")!; | |
| 103 | expect(await res.text()).toBe(page); | |
| 104 | }); | |
| 105 | ||
| 106 | test("a failed non-navigation request never gets the page", async () => { | |
| 107 | const { handlers, store } = boot(null); | |
| 108 | store.set("/offline", new Response("OFFLINE")); | |
| 109 | for (const path of ["/api/v1/feed", "/assets/app.js"]) { | |
| 110 | const out = ask(handlers, path, "cors"); | |
| 111 | if (out === undefined) continue; // left to the network untouched | |
| 112 | const res = await out.catch(() => null); | |
| 113 | expect(res === null || (await res.text()) !== "OFFLINE").toBe(true); | |
| 114 | } | |
| 115 | }); |
test/offline.test.ts +1 −1
| @@ -286,7 +286,7 @@ describe("the worker never turns a restart into a browser error", () => { | ||
| 286 | 286 | let done: Promise<unknown> | undefined; |
| 287 | 287 | handlers.install!({ waitUntil: (p: Promise<unknown>) => (done = p) }); |
| 288 | 288 | await done; |
| 289 | expect(store.has("/__offline")).toBe(true); | |
| 289 | expect(store.has("/offline")).toBe(true); | |
| 290 | 290 | store.set("/", new Response('<main data-stale="0">hi</main>')); |
| 291 | 291 | const res = await navigate(handlers, "/"); |
| 292 | 292 | expect(await res.text()).toContain('data-stale="1"'); |
test/server-timing-sweep.test.ts +1
| @@ -26,6 +26,7 @@ const UNNAMED: Array<[path: string, issue: string]> = [ | ||
| 26 | 26 | |
| 27 | 27 | /** Long-lived streams and live fragments: no page, no phases to name. */ |
| 28 | 28 | const NOT_PAGES = new Set([ |
| 29 | "/offline", // the worker's static fallback, kept at install: no session to time | |
| 29 | 30 | "/events", |
| 30 | 31 | "/system/stream", |
| 31 | 32 | "/settings/teams/stream", |
Principle 4 (one background, one text colour) and the 'tokens, not hand-rolled markup' rule: this is a private stylesheet with a fixed font and the browser's default colours, so it ignores --bg/--fg and the forge's type scale and will look unlike every other page (and ignores dark mode). Inline the token values the design system defines (same --bg, --fg, muted for the second line, same font stack and spacing) and keep the hash-admitted CSP. Also principle 8: the copy 'This device cannot reach the forge right now. Check the connection and reload.' explains twice; one plain sentence plus the last-seen line is enough (principle 1).