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

Merged opened this in answer to

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

Merged

scout/forge:scout/457-the-service-worker-turns-a-forge-restart → main 4 commits fork

  • Merged. scout/457-the-service-worker-turns-a-forge-restart was merged into main as 1ebf3f0.

Review

Sign in to review this change.
3 files changed +148 −16
src/web/sw.ts +58 −9
@@ -10,6 +10,11 @@
1010 * - Everything else — the API, `/events`, `/live/*`, every POST — goes
1111 * straight to the network untouched. Writes made offline wait in the
1212 * 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.
1318 * - Signing out sends `clear`, which empties the cache: the last feed is
1419 * one person's and must not outlive their session.
1520 */
@@ -18,9 +23,43 @@ import { sha256 } from "../kernel/hash.ts";
1823
1924 export const SW_CACHE = "forge-shell-v1";
2025
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
2146 const source = /* js */ `
2247 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 }
2463 self.addEventListener('activate', function (event) {
2564 event.waitUntil(caches.keys().then(function (names) {
2665 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) {
4887 if (request.method !== 'GET') return;
4988 var url = new URL(request.url);
5089 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 === '/';
5293 event.respondWith(fetch(request).then(function (r) {
53 return keep(new Request('/'), r);
94 return home ? keep(new Request('/'), r) : r;
5495 }, 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));
5999 return;
60100 }
61101 if (url.pathname.indexOf('/assets/') === 0 || url.pathname.indexOf('/icons/') === 0) {
@@ -70,13 +110,22 @@ self.addEventListener('fetch', function (event) {
70110 export const swSource = source;
71111 const ETAG = `"${sha256(source).slice(0, 16)}"`;
72112
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
73120 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, {
75123 headers: {
76124 "content-type": "text/javascript; charset=utf-8",
77125 "cache-control": "no-cache",
78 etag: ETAG,
126 ...(kill ? {} : { etag: ETAG }),
79127 "service-worker-allowed": "/",
128 "content-security-policy": SW_CSP,
80129 },
81130 });
82131 }
test/fork-flow.test.ts +18 −6
@@ -6,7 +6,8 @@ import { createApp, type App } from "../src/app.ts";
66 import { serve, type Servers } from "../src/http/server.ts";
77 import { resetConnectLimit } from "../src/actions/origins.ts";
88 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";
1011 import {
1112 mirrorAndWait,
1213 newFakeState,
@@ -788,17 +789,28 @@ describe("the fork flow", () => {
788789 push: false,
789790 admin: false,
790791 });
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];
791796 const started = await api(`/api/v1/user/origins/${forgejoOrigin}/mirror`, {
792797 method: "POST",
793798 body: JSON.stringify({ fullName: `${UPSTREAM_OWNER}/late` }),
794799 });
795800 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");
801812 const job = await mirrorAndWait(started);
813 firstFetchRetry.ms = found;
802814 expect(job?.stage).toBe("done");
803815 expect(job?.repo?.name).toBe("late");
804816 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";
1414 import { listIssues } from "../src/core/issues.ts";
1515 import { serve, type Servers } from "../src/http/server.ts";
1616 import { outboxJs } from "../src/web/outbox.ts";
17 import { swSource } from "../src/web/sw.ts";
17 import { serviceWorker, swSource } from "../src/web/sw.ts";
1818
1919 type Send = (url: string, init: any) => Promise<{ status: number }>;
2020 const make = new Function(`${outboxJs}; return forgeOutbox;`)() as (
@@ -147,6 +147,10 @@ describe("the service worker and the server", () => {
147147 expect(res.headers.get("content-type")).toContain("javascript");
148148 expect(res.headers.get("service-worker-allowed")).toBe("/");
149149 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'");
150154 expect(() => new Function(swSource)).not.toThrow();
151155 expect(swSource).toContain("stale");
152156 });
@@ -229,3 +233,70 @@ describe("the service worker and the server", () => {
229233 .get()!.id;
230234 }
231235 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs