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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/12324-the-service-worker-serves-a-small-offlin → main 9 commits fork 3 threads · 3 unresolved

  • Merged. scout/12324-the-service-worker-serves-a-small-offlin was merged into main as c91bb75.

Review

Sign in to review this change.
13 files changed +196 −8
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[] = [
123123 "changelog.atom",
124124 "explore.atom",
125125 "sw.js",
126 "offline",
126127 "tell",
127128 "talk",
128129 "notes",
src/http/route_marks.ts +1
@@ -160,6 +160,7 @@ export const HEADS: Readonly<Record<string, Mark | "mixed">> = {
160160 "/releases.json": "open",
161161 "/repos": "open",
162162 "/resilience": "admin",
163 "/offline": "open",
163164 "/review": "open",
164165 "/robots.txt": "open",
165166 "/runs": "open",
src/http/server.ts +2
@@ -323,6 +323,7 @@ import { liveFeedFragment } from "../web/studio/feed.ts";
323323 import { likeSubmit } from "../web/studio/like.ts";
324324 import { HOME_REGIONS } from "../web/studio/home_last.ts";
325325 import { serviceWorker } from "../web/sw.ts";
326 import { offlinePage } from "../web/pages/offline.ts";
326327 import { explorePage } from "../web/studio/explore.ts";
327328 import { isPacksMirror, MIRROR_PUSH_REFUSED } from "../packs/owner.ts";
328329 import { packEdit, packNew, packPage, packRepos, packsMirror, packsPage } from "../web/pages/packs.ts";
@@ -867,6 +868,7 @@ export function buildRouter(): Router<Handler> {
867868 // The service worker: root scope needs a root path, and it must never be
868869 // cached by the browser's HTTP cache or an update would wait a day.
869870 router.add("GET", "/sw.js", serviceWorker);
871 router.add("GET", "/offline", offlinePage);
870872 // The page-view beacon's report (http/measure.ts): numbers only, no IP kept.
871873 router.add("POST", "/_measure", (ctx) => measureSubmit(ctx, routeOf));
872874 // 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 {
207207 * plus everything that is a file, an API or an edge hand-off.
208208 */
209209 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",
211211 "/*/*/raw/*", "/*/*/media/*", "/*/*/archive/*", "/*/*/app/*/open",
212212 "/logout*", "/signout*", "/sign-out*", "/signin*", "/sign-in*", "/login*",
213213 "/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;
outdated

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).

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 @@
1111 * straight to the network untouched. Writes made offline wait in the
1212 * page's outbox (`outbox.ts`), not here.
1313 * - 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 `/`.
1516 * - The script's own CSP allows same-origin fetch (see SW_CSP); the floor's
1617 * `default-src 'none'` made every fetch in the worker fail.
1718 * - FORGE_SW_KILL=1 serves a worker that unregisters itself.
@@ -21,9 +22,9 @@
2122
2223 import { sha256 } from "../kernel/hash.ts";
2324
24 export const SW_CACHE = "forge-shell-v1";
25 export const SW_CACHE = "forge-shell-v2";
2526
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. */
2728 export const OFFLINE_HTML =
2829 '<!doctype html><html lang="en"><head><meta charset="utf-8">' +
2930 '<meta name="viewport" content="width=device-width,initial-scale=1">' +
@@ -45,7 +46,7 @@ self.addEventListener('activate', function (event) {
4546
4647 const source = /* js */ `
4748 var CACHE = ${JSON.stringify(SW_CACHE)};
48 var OFFLINE_URL = '/__offline';
49 var OFFLINE_URL = '/offline';
4950 var OFFLINE_HTML = ${JSON.stringify(OFFLINE_HTML)};
5051 function offlineResponse() {
5152 return new Response(OFFLINE_HTML, {
@@ -54,7 +55,10 @@ function offlineResponse() {
5455 }
5556 self.addEventListener('install', function (event) {
5657 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()); });
5862 }).then(function () { return self.skipWaiting(); }));
5963 });
6064 function offline() {
test/design-audit.test.ts +1 −1
@@ -17,7 +17,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8");
1717
1818 /** Routes that answer a file, a fragment, a redirect hop or a callback. */
1919 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(\/|$))/;
2121
2222 const getRoutes = [
2323 ...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> = {
4747 "/manifest.webmanifest": "the install manifest, JSON",
4848 "/opensearch.xml": "the browser search description, XML",
4949 "/sw.js": "the service worker script",
50 "/offline": "the static fallback the service worker keeps for an unreachable forge",
5051 "/audit.csv": "a CSV download of /audit",
5152 "/maintenance": "no picture: the cap of 24 looks is spent; an admin's page, covered by test/maintenance.test.ts",
5253 "/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
9595 /** file -> the patterns it may carry, and why */
9696 const ALLOWED: Record<string, { allow: string[]; why: string }> = {
9797 "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" },
9899 "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" },
99100 "src/web/client.ts": { allow: ["setInterval"], why: "clocks that only redraw text" },
100101 "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", () => {
367368 const res = await fetch(`${servers.url}${path}`, { headers: { "accept-encoding": "identity", ...headers }, redirect: "manual", signal: AbortSignal.timeout(8_000) });
368369 if (res.headers.get("refresh")) offenders.push(`${path}: Refresh header`);
369370 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; }
370374 htmlPages.push(path);
371375 const html = await res.text();
372376 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", () => {
286286 let done: Promise<unknown> | undefined;
287287 handlers.install!({ waitUntil: (p: Promise<unknown>) => (done = p) });
288288 await done;
289 expect(store.has("/__offline")).toBe(true);
289 expect(store.has("/offline")).toBe(true);
290290 store.set("/", new Response('<main data-stale="0">hi</main>'));
291291 const res = await navigate(handlers, "/");
292292 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]> = [
2626
2727 /** Long-lived streams and live fragments: no page, no phases to name. */
2828 const NOT_PAGES = new Set([
29 "/offline", // the worker's static fallback, kept at install: no session to time
2930 "/events",
3031 "/system/stream",
3132 "/settings/teams/stream",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs