Public: anyone can read this; the crew can change it.
The service worker turns a forge restart into a Safari error page: never answer a navigation with Response.error() #497
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/457-the-service-worker-turns-a-forge-restart → main 4 commits fork
3 files
changed +148
−16
src/web/sw.ts +58 −9
| @@ -10,6 +10,11 @@ | ||
| 10 | 10 | * - Everything else — the API, `/events`, `/live/*`, every POST — goes |
| 11 | 11 | * straight to the network untouched. Writes made offline wait in the |
| 12 | 12 | * page's outbox (`outbox.ts`), not here. |
| 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. | |
| 15 | * - The script's own CSP allows same-origin fetch (see SW_CSP); the floor's | |
| 16 | * `default-src 'none'` made every fetch in the worker fail. | |
| 17 | * - FORGE_SW_KILL=1 serves a worker that unregisters itself. | |
| 13 | 18 | * - Signing out sends `clear`, which empties the cache: the last feed is |
| 14 | 19 | * one person's and must not outlive their session. |
| 15 | 20 | */ |
| @@ -18,9 +23,43 @@ import { sha256 } from "../kernel/hash.ts"; | ||
| 18 | 23 | |
| 19 | 24 | export const SW_CACHE = "forge-shell-v1"; |
| 20 | 25 | |
| 26 | /** Shown when the forge is unreachable and nothing is kept; it retries itself. */ | |
| 27 | export const OFFLINE_HTML = | |
| 28 | '<!doctype html><html lang="en"><head><meta charset="utf-8">' + | |
| 29 | '<meta name="viewport" content="width=device-width,initial-scale=1">' + | |
| 30 | '<meta http-equiv="refresh" content="5"><title>Forge is restarting</title></head>' + | |
| 31 | '<body style="font:16px system-ui;margin:3rem auto;max-width:30rem;padding:0 1rem">' + | |
| 32 | "<h1>The forge is restarting</h1><p>It will be back in a moment. This page retries every few seconds.</p>" + | |
| 33 | "</body></html>"; | |
| 34 | ||
| 35 | /** A worker that removes itself: served when FORGE_SW_KILL is set. */ | |
| 36 | export const killSource = `self.addEventListener('install', function () { self.skipWaiting(); }); | |
| 37 | self.addEventListener('activate', function (event) { | |
| 38 | event.waitUntil(caches.keys().then(function (names) { | |
| 39 | return Promise.all(names.map(function (n) { return caches.delete(n); })); | |
| 40 | }).then(function () { return self.registration.unregister(); }).then(function () { | |
| 41 | return self.clients.matchAll({ type: 'window' }); | |
| 42 | }).then(function (all) { all.forEach(function (c) { c.navigate(c.url); }); })); | |
| 43 | }); | |
| 44 | `; | |
| 45 | ||
| 21 | 46 | const source = /* js */ ` |
| 22 | 47 | var CACHE = ${JSON.stringify(SW_CACHE)}; |
| 23 | self.addEventListener('install', function () { self.skipWaiting(); }); | |
| 48 | var OFFLINE_URL = '/__offline'; | |
| 49 | var OFFLINE_HTML = ${JSON.stringify(OFFLINE_HTML)}; | |
| 50 | function offlineResponse() { | |
| 51 | return new Response(OFFLINE_HTML, { | |
| 52 | status: 503, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } | |
| 53 | }); | |
| 54 | } | |
| 55 | self.addEventListener('install', function (event) { | |
| 56 | event.waitUntil(caches.open(CACHE).then(function (c) { | |
| 57 | return c.put(OFFLINE_URL, offlineResponse()); | |
| 58 | }).then(function () { return self.skipWaiting(); })); | |
| 59 | }); | |
| 60 | function offline() { | |
| 61 | return caches.match(OFFLINE_URL).then(function (hit) { return hit || offlineResponse(); }); | |
| 62 | } | |
| 24 | 63 | self.addEventListener('activate', function (event) { |
| 25 | 64 | event.waitUntil(caches.keys().then(function (names) { |
| 26 | 65 | return Promise.all(names.filter(function (n) { return n !== CACHE; }).map(function (n) { return caches.delete(n); })); |
| @@ -48,14 +87,15 @@ self.addEventListener('fetch', function (event) { | ||
| 48 | 87 | if (request.method !== 'GET') return; |
| 49 | 88 | var url = new URL(request.url); |
| 50 | 89 | if (url.origin !== location.origin) return; |
| 51 | if (url.pathname === '/' && request.mode === 'navigate') { | |
| 90 | if (request.mode === 'navigate') { | |
| 91 | // never Response.error(): a restarting forge must not become a browser error page | |
| 92 | var home = url.pathname === '/'; | |
| 52 | 93 | event.respondWith(fetch(request).then(function (r) { |
| 53 | return keep(new Request('/'), r); | |
| 94 | return home ? keep(new Request('/'), r) : r; | |
| 54 | 95 | }, function () { |
| 55 | return caches.match('/').then(function (hit) { | |
| 56 | return hit ? stale(hit) : Response.error(); | |
| 57 | }); | |
| 58 | })); | |
| 96 | if (!home) return offline(); | |
| 97 | return caches.match('/').then(function (hit) { return hit ? stale(hit) : offline(); }); | |
| 98 | }).catch(offline)); | |
| 59 | 99 | return; |
| 60 | 100 | } |
| 61 | 101 | if (url.pathname.indexOf('/assets/') === 0 || url.pathname.indexOf('/icons/') === 0) { |
| @@ -70,13 +110,22 @@ self.addEventListener('fetch', function (event) { | ||
| 70 | 110 | export const swSource = source; |
| 71 | 111 | const ETAG = `"${sha256(source).slice(0, 16)}"`; |
| 72 | 112 | |
| 113 | /** | |
| 114 | * A worker takes its CSP from its own script response, and `connect-src` | |
| 115 | * falls back to `default-src`: with the floor's `'none'` every fetch() in the | |
| 116 | * worker is refused. The worker fetches same-origin only. | |
| 117 | */ | |
| 118 | export const SW_CSP = "default-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'"; | |
| 119 | ||
| 73 | 120 | export function serviceWorker(): Response { |
| 74 | return new Response(source, { | |
| 121 | const kill = !!process.env.FORGE_SW_KILL && process.env.FORGE_SW_KILL !== "0"; | |
| 122 | return new Response(kill ? killSource : source, { | |
| 75 | 123 | headers: { |
| 76 | 124 | "content-type": "text/javascript; charset=utf-8", |
| 77 | 125 | "cache-control": "no-cache", |
| 78 | etag: ETAG, | |
| 126 | ...(kill ? {} : { etag: ETAG }), | |
| 79 | 127 | "service-worker-allowed": "/", |
| 128 | "content-security-policy": SW_CSP, | |
| 80 | 129 | }, |
| 81 | 130 | }); |
| 82 | 131 | } |
test/fork-flow.test.ts +18 −6
| @@ -6,7 +6,8 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 6 | 6 | import { serve, type Servers } from "../src/http/server.ts"; |
| 7 | 7 | import { resetConnectLimit } from "../src/actions/origins.ts"; |
| 8 | 8 | import { listEvents } from "../src/core/events.ts"; |
| 9 | import { settled } from "../src/origins/jobs.ts"; | |
| 9 | import { firstFetchRetry } from "../src/origins/mirror.ts"; | |
| 10 | import { getJob, settled } from "../src/origins/jobs.ts"; | |
| 10 | 11 | import { |
| 11 | 12 | mirrorAndWait, |
| 12 | 13 | newFakeState, |
| @@ -788,17 +789,28 @@ describe("the fork flow", () => { | ||
| 788 | 789 | push: false, |
| 789 | 790 | admin: false, |
| 790 | 791 | }); |
| 792 | // Making the repository is two requests; the retry must not fire between | |
| 793 | // them and fetch it empty, so it waits longer than the seed can take. | |
| 794 | const found = firstFetchRetry.ms; | |
| 795 | firstFetchRetry.ms = [8_000]; | |
| 791 | 796 | const started = await api(`/api/v1/user/origins/${forgejoOrigin}/mirror`, { |
| 792 | 797 | method: "POST", |
| 793 | 798 | body: JSON.stringify({ fullName: `${UPSTREAM_OWNER}/late` }), |
| 794 | 799 | }); |
| 795 | 800 | expect(started.status).toBe(202); |
| 796 | // Seeded well inside the first retry's own wait (`firstFetchRetry.ms`, | |
| 797 | // `src/origins/mirror.ts`, 2s by default): the immediate first fetch | |
| 798 | // still finds nothing and fails, same as it would on a slower machine, | |
| 799 | // but the retry has margin to spare rather than racing it. | |
| 800 | setTimeout(() => void seedUpstream("late"), 300); | |
| 801 | // Seeded only once the first pass has moved past its fetch (it found | |
| 802 | // nothing and is waiting to try again, `firstFetchRetry` in | |
| 803 | // `src/origins/mirror.ts`): seeding on a timer could land mid-fetch on a | |
| 804 | // slow machine and be fetched half-made. | |
| 805 | const jobId = ((await started.clone().json()) as { job: { id: string } }).job.id; | |
| 806 | for (let i = 0; i < 400; i++) { | |
| 807 | const stage = getJob(jobId)?.stage; | |
| 808 | if (stage !== "creating" && stage !== "fetching") break; | |
| 809 | await new Promise((r) => setTimeout(r, 25)); | |
| 810 | } | |
| 811 | await seedUpstream("late"); | |
| 801 | 812 | const job = await mirrorAndWait(started); |
| 813 | firstFetchRetry.ms = found; | |
| 802 | 814 | expect(job?.stage).toBe("done"); |
| 803 | 815 | expect(job?.repo?.name).toBe("late"); |
| 804 | 816 | const branches = await read<{ name: string }[]>( |
test/offline.test.ts +72 −1
| @@ -14,7 +14,7 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 14 | 14 | import { listIssues } from "../src/core/issues.ts"; |
| 15 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 16 | 16 | import { outboxJs } from "../src/web/outbox.ts"; |
| 17 | import { swSource } from "../src/web/sw.ts"; | |
| 17 | import { serviceWorker, swSource } from "../src/web/sw.ts"; | |
| 18 | 18 | |
| 19 | 19 | type Send = (url: string, init: any) => Promise<{ status: number }>; |
| 20 | 20 | const make = new Function(`${outboxJs}; return forgeOutbox;`)() as ( |
| @@ -147,6 +147,10 @@ describe("the service worker and the server", () => { | ||
| 147 | 147 | expect(res.headers.get("content-type")).toContain("javascript"); |
| 148 | 148 | expect(res.headers.get("service-worker-allowed")).toBe("/"); |
| 149 | 149 | expect(res.headers.get("cache-control")).toBe("no-cache"); |
| 150 | // the worker's CSP is its own: it must let the worker fetch same-origin | |
| 151 | const csp = res.headers.get("content-security-policy") ?? ""; | |
| 152 | expect(csp).toContain("connect-src 'self'"); | |
| 153 | expect(csp).not.toContain("default-src 'none'"); | |
| 150 | 154 | expect(() => new Function(swSource)).not.toThrow(); |
| 151 | 155 | expect(swSource).toContain("stale"); |
| 152 | 156 | }); |
| @@ -229,3 +233,70 @@ describe("the service worker and the server", () => { | ||
| 229 | 233 | .get()!.id; |
| 230 | 234 | } |
| 231 | 235 | }); |
| 236 | ||
| 237 | describe("the worker never turns a restart into a browser error", () => { | |
| 238 | const boot = (kill = false) => { | |
| 239 | const handlers: Record<string, (e: any) => void> = {}; | |
| 240 | const store = new Map<string, Response>(); | |
| 241 | const cache = { | |
| 242 | put: async (k: any, v: Response) => void store.set(typeof k === "string" ? k : new URL(k.url).pathname, v), | |
| 243 | }; | |
| 244 | const self = { | |
| 245 | addEventListener: (n: string, f: any) => (handlers[n] = f), | |
| 246 | skipWaiting() {}, | |
| 247 | clients: { claim: async () => {} }, | |
| 248 | }; | |
| 249 | const caches = { | |
| 250 | open: async () => cache, | |
| 251 | match: async (k: any) => store.get(typeof k === "string" ? k : new URL(k.url).pathname)?.clone(), | |
| 252 | keys: async () => [], | |
| 253 | delete: async () => true, | |
| 254 | }; | |
| 255 | const down = async () => { | |
| 256 | throw new TypeError("connection refused"); | |
| 257 | }; | |
| 258 | new Function("self", "caches", "fetch", "location", swSource)(self, caches, down, new URL("http://x")); | |
| 259 | return { handlers, store }; | |
| 260 | }; | |
| 261 | const navigate = async (h: any, path: string) => { | |
| 262 | let out: Promise<Response> | undefined; | |
| 263 | h.fetch({ | |
| 264 | request: { method: "GET", mode: "navigate", url: `http://x${path}` }, | |
| 265 | respondWith: (p: Promise<Response>) => (out = p), | |
| 266 | }); | |
| 267 | return out!; | |
| 268 | }; | |
| 269 | ||
| 270 | test("server stopped, nothing kept: a navigation gets the offline page", async () => { | |
| 271 | const { handlers } = boot(); | |
| 272 | const res = await navigate(handlers, "/"); | |
| 273 | expect(res.type).not.toBe("error"); | |
| 274 | expect(res.status).toBe(503); | |
| 275 | const html = await res.text(); | |
| 276 | expect(html).toContain("restarting"); | |
| 277 | expect(html).toContain('http-equiv="refresh"'); | |
| 278 | expect((await navigate(handlers, "/some/page")).status).toBe(503); | |
| 279 | }); | |
| 280 | ||
| 281 | test("the offline page is cached at install; a kept home is served stale", async () => { | |
| 282 | const { handlers, store } = boot(); | |
| 283 | let done: Promise<unknown> | undefined; | |
| 284 | handlers.install!({ waitUntil: (p: Promise<unknown>) => (done = p) }); | |
| 285 | await done; | |
| 286 | expect(store.has("/__offline")).toBe(true); | |
| 287 | store.set("/", new Response('<main data-stale="0">hi</main>')); | |
| 288 | const res = await navigate(handlers, "/"); | |
| 289 | expect(await res.text()).toContain('data-stale="1"'); | |
| 290 | }); | |
| 291 | ||
| 292 | test("the kill switch serves a self-unregistering worker", async () => { | |
| 293 | process.env.FORGE_SW_KILL = "1"; | |
| 294 | try { | |
| 295 | const body = await serviceWorker().text(); | |
| 296 | expect(body).toContain("unregister"); | |
| 297 | expect(body).not.toContain("fetch"); | |
| 298 | } finally { | |
| 299 | delete process.env.FORGE_SW_KILL; | |
| 300 | } | |
| 301 | }); | |
| 302 | }); |
new above
offline