Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

Not found is a page, not a fragment: the 404 and the 500 are served through the shell, and a favicon is not a miss #184

Merged opened this

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

Merged

torque/cs-engine:torque/115-not-found-is-a-page-not-a-fragment-the-4 → main 1 commit fork

  • Merged. torque/115-not-found-is-a-page-not-a-fragment-the-4 was merged into main as 950823c.

Review

Sign in to review this change.
8 files changed +366 −26
README.md +17 −2
@@ -612,8 +612,8 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
612612 about any one game: `page({ title, description, heading, nav, env, pull,
613613 commit, script, ssh, terminalLead, stageHtml, sectionsHtml, footer,
614614 extraStyle })`
615 gives you the doctype, the head, the one `<style>` block, the header with its
616 nav of the games, its environment pill and short commit, `<main>`, the
615 gives you the doctype, the head, the one `<style>` block, the icon, the header
616 with its nav of the games, its environment pill and short commit, `<main>`, the
617617 terminal line and the footer,
618618 and `stage({ overlays, touch })` gives you `#stage` — `#scene`, `#hud`, `#fps`
619619 and the `#touch` pads built from `{ action, label }` buttons. Text parameters
@@ -641,6 +641,21 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
641641 `src/client/stage.test-helper.ts`, the stub page a test boots on is built from
642642 that list and `src/server.test.ts` pins the served page against the same one,
643643 so the page the game is tested on and the page it is served on cannot drift.
644 - `src/pages/miss.ts` — **the two pages no game owns**, through that same
645 shell: the 404 a wrong path lands on and the 500 a bug does. Both are whole
646 documents, dark and phone-sized like every game page, with the header's nav,
647 the environment pill, the commit and the footer; the 404 lists every game
648 from the registry as a way out, each link 44px on a side, and the 500 says
649 something went wrong and nothing else — no path, no stack, no runtime, no
650 version, because the page is never handed the error. The error goes to the
651 log, which is the only place it is. `script` is optional on the shell for
652 exactly these two: a page that runs nothing gets no `<script>` element at
653 all, and the per-game tests still pin exactly one on every game's page.
654 `src/server.ts` renders both once at start-up, so a miss costs a lookup and
655 a write. The icon is in the document too — one `<link rel="icon">` holding an
656 SVG data URI of the crosshair — so **`/favicon.ico` is a request no browser
657 makes here**: no file under `static/`, no second round trip, and the CSP
658 already allows `img-src 'self' data:`.
644659 - `src/games.ts` is **the registry: a game is one entry there.** It holds the
645660 name its bundle is filed under, the path it answers on, what the header calls
646661 it, the source it is bundled from, the size that bundle may not pass and the
src/pages/games.ts +7 −2
@@ -41,5 +41,10 @@ export const othersHtml = (here: string): string =>
4141 .map(entryHtml)
4242 .join(" ");
4343
44 /** Every game, one to a line: what a wrong path says instead of being a dead end. */
45 export const listHtml = (): string => `<ul>${REGISTRY.map((game) => `<li>${entryHtml(game)}</li>`).join("")}</ul>`;
44 /**
45 * Every game, one to a line: what a wrong path says instead of being a dead
46 * end. The class is the handle `./miss.ts` sizes these links by — a link out of
47 * a dead end is a target a thumb has to hit, so it is 44px there like the nav's.
48 */
49 export const listHtml = (): string =>
50 `<ul class="games">${REGISTRY.map((game) => `<li>${entryHtml(game)}</li>`).join("")}</ul>`;
src/pages/miss.test.ts added +86
@@ -0,0 +1,86 @@
1 /**
2 * The two pages no game owns, read as a visitor gets them: a whole document
3 * both times, the games in the header and in the list, and — on the 500 — not
4 * one word about what broke.
5 */
6
7 import { expect, test } from "bun:test";
8 import { GAMES } from "../games.ts";
9 import { errorPage, notFoundPage } from "./miss.ts";
10 import { escape } from "./shell.ts";
11
12 const options = { env: "local", pull: undefined, commit: "abc1234", ssh: undefined };
13
14 /** Every check that is true of both of them, because both go through the shell. */
15 const isTheDocument = (html: string): void => {
16 expect(html.startsWith("<!doctype html>\n<html lang=\"en\">\n")).toBe(true);
17 expect(html).toContain(`<meta charset="utf-8">`);
18 expect(html).toContain(`content="width=device-width, initial-scale=1, viewport-fit=cover"`);
19 expect(html).toContain(`<link rel="icon" href="data:image/svg+xml,`);
20 expect(html).toContain("<main>");
21 expect(html).toContain("<footer>");
22 // One h1, and it is the shell's heading: the sections under it say nothing
23 // that would be a second.
24 expect(html.match(/<h1/g)?.length).toBe(1);
25 // Nothing runs on either page, so neither ships a script.
26 expect(html).not.toContain("<script");
27 expect(html.match(/<style/g)?.length).toBe(1);
28 // The header: every game linked, none of them current, and the pill.
29 for (const game of GAMES) expect(html).toContain(`<a href="${escape(game.href)}">${escape(game.label)}</a>`);
30 expect(html).not.toContain(`aria-current="page"`);
31 expect(html).toContain(`<span class="env ">local</span>`);
32 expect(html).toContain(`<small class="commit">@ abc1234</small>`);
33 expect(html.indexOf("<nav")).toBeLessThan(html.indexOf("<main>"));
34 };
35
36 test("the 404 is the same document every game is served inside", () => {
37 const html = notFoundPage(options);
38 isTheDocument(html);
39 expect(html).toContain("<h1>not found</h1>");
40 expect(html).toContain("<title>not found — cs</title>");
41 expect(html).toContain("No page answers on that path.");
42 });
43
44 test("the 404 names every game in the registry, and what each one is", () => {
45 const html = notFoundPage(options);
46 expect(html).toContain(`<ul class="games">`);
47 for (const game of GAMES) {
48 expect(html).toContain(`<li><a href="${escape(game.href)}">${escape(game.label)}</a> — ${escape(game.blurb)}</li>`);
49 }
50 expect(html.match(/<li>/g)?.length).toBe(GAMES.length);
51 // Every link out of a dead end is a target a thumb can hit, like the nav's.
52 expect(html).toContain("ul.games a");
53 expect(html).toContain("min-height:44px; min-width:44px");
54 });
55
56 test("the 500 is a document too, and says nothing about what broke", () => {
57 const html = errorPage(options);
58 isTheDocument(html);
59 expect(html).toContain("<h1>something went wrong</h1>");
60 expect(html).toContain("This request hit a bug on the server");
61 // No path, no stack, no runtime, no version: the page is not handed the
62 // error, so the only way one of these could appear is if someone wrote it in.
63 for (const leak of ["/src/", "/home/", "/data/", ".ts:", "Error:", "node_modules", "stack", "v1."])
64 expect(html).not.toContain(leak);
65 });
66
67 test("neither page can be made to say something the environment did not", () => {
68 // env, pull, commit and ssh are the only text these pages take, and all four
69 // come from the process rather than the request — but they are escaped like
70 // anything else, so a deployment named with markup is still inert.
71 const XSS = `</style><script>alert(1)</script>`;
72 for (const html of [
73 notFoundPage({ env: XSS, pull: XSS, commit: XSS, ssh: XSS }),
74 errorPage({ env: XSS, pull: XSS, commit: XSS, ssh: XSS }),
75 ]) {
76 expect(html).not.toContain("<script");
77 expect(html).not.toContain("</style><script>");
78 expect(html).toContain("&lt;/style&gt;&lt;script&gt;alert(1)&lt;/script&gt;");
79 }
80 });
81
82 test("the terminal line is the forge's own command, or says the terminal is off", () => {
83 expect(notFoundPage(options)).toContain("The app's terminal is off.");
84 expect(errorPage({ ...options, ssh: "ssh -p 2222 cs@localhost" }))
85 .toContain("<code>ssh -p 2222 cs@localhost</code>");
86 });
src/pages/miss.ts added +91
@@ -0,0 +1,91 @@
1 /**
2 * The pages that are not a game: the 404 a wrong path lands on, and the 500 a
3 * bug does. They are the only two documents this server sends that no game
4 * owns, and they are here together because they are one concept — what a
5 * visitor sees when there is nothing to play on this path.
6 *
7 * Both go through `./shell.ts` like every other page, so they carry the
8 * doctype, the charset, the viewport, the style, the header with its nav of
9 * every game, the environment pill, the commit and the footer. Neither hands
10 * the shell a script, because neither runs anything: the shell emits no
11 * `<script>` element for a page that leaves it out.
12 *
13 * The 404 is not a dead end — it lists every game from the registry through
14 * `listHtml()`, and those links are a thumb's target, so they are the nav's
15 * 44px. The 500 lists nothing about itself: what went wrong is in the log, and
16 * a path, a stack, a runtime or a version on the page is a gift to whoever is
17 * probing. Neither page is handed the error, so neither can leak it.
18 *
19 * Both are pure: environment in, markup out. `src/server.ts` renders each once
20 * at start-up, because neither ever changes.
21 */
22
23 import { listHtml, navFor } from "./games.ts";
24 import { page, type PageOptions as ShellOptions } from "./shell.ts";
25
26 /** What the shell needs that these pages cannot know: the environment they run in. */
27 export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "ssh">;
28
29 /**
30 * No path here is a game's, so the nav marks nothing current: every entry is a
31 * link out, which is the whole use of a nav on a page you did not mean to be on.
32 */
33 const NAV = navFor("");
34
35 /**
36 * These pages' own CSS. The list of games is the way out of a miss, so each
37 * link is the same target the header's nav is — 44px on a side, with the
38 * padding pulled back so the text still lines up with the paragraph above it.
39 */
40 const STYLE = `
41 section.miss p { max-width: 42rem; }
42 ul.games { list-style:none; padding:0; margin:.5rem 0 0; }
43 ul.games li { padding:.15rem 0; border-bottom:1px solid var(--line); color:var(--dim); font-size:.92rem; }
44 ul.games a { display:inline-flex; align-items:center; min-height:44px; min-width:44px; padding:0 .55rem;
45 margin-left:-.55rem; border-radius:.45rem; color:var(--ct); text-decoration:none; font-size:1rem; }
46 ul.games a:hover, ul.games a:focus-visible { background:#1b2230; text-decoration:underline; }
47 `;
48
49 /** What both pages hand the shell; only the words differ. */
50 const missPage = (options: PageOptions, title: string, heading: string, description: string, bodyHtml: string): string =>
51 page({
52 ...options,
53 title,
54 description,
55 heading,
56 nav: NAV,
57 terminalLead: "The shooter's leaderboard in a terminal:",
58 footer: "nothing was stored for this request",
59 extraStyle: STYLE,
60 stageHtml: "",
61 sectionsHtml: ` <section class="miss">
62 ${bodyHtml}
63 </section>`,
64 });
65
66 /** The 404: the sentence, and every game on this server as a way out of it. */
67 export const notFoundPage = (options: PageOptions): string =>
68 missPage(
69 options,
70 "not found — cs",
71 "not found",
72 "No page answers on that path. The games on this server are listed here.",
73 ` <p>No page answers on that path. The games on this server:</p>
74 ${listHtml()}`,
75 );
76
77 /**
78 * The 500: it says what happened and nothing about how. No path, no stack, no
79 * runtime, no version — the detail is in this deployment's log, where a person
80 * who is allowed to read it can.
81 */
82 export const errorPage = (options: PageOptions): string =>
83 missPage(
84 options,
85 "something went wrong — cs",
86 "something went wrong",
87 "This request hit a bug on the server. The detail is in the log, not on this page.",
88 ` <p>This request hit a bug on the server, and nothing about it is on this page: what went
89 wrong is in this deployment's log, where it belongs. Nothing was stored. Try it again, or
90 take one of the games in the header.</p>`,
91 );
src/pages/shell.test.ts +40
@@ -43,6 +43,46 @@ test("the document is a document, and the only script is the bundle", () => {
4343 expect(html.match(/<style/g)?.length).toBe(1);
4444 });
4545
46 test("a page with no script has no script element anywhere", () => {
47 // The 404 and the 500 run nothing, so they hand in no bundle — and what comes
48 // back is still a whole document, with the element left out rather than
49 // emitted empty. The test above is the other half: a game that forgets its
50 // bundle has no script either, and its page's own test catches that.
51 const { script, ...noScript } = options;
52 const html = page(noScript);
53 expect(html.startsWith("<!doctype html>\n<html lang=\"en\">\n")).toBe(true);
54 expect(html).toContain(`<meta charset="utf-8">`);
55 expect(html).toContain("<main>");
56 expect(html).toContain("<footer>");
57 expect(html.endsWith("</footer>\n</html>\n")).toBe(true);
58 expect(html).not.toContain("<script");
59 expect(html).not.toContain(script);
60 expect(html.match(/<style/g)?.length).toBe(1);
61 });
62
63 test("the icon is in the document, so no browser goes looking for one", () => {
64 const html = page(options);
65 expect(html).toContain(`<link rel="icon" href="data:image/svg+xml,%3Csvg `);
66 expect(html).toContain("%3C/svg%3E");
67 // In the head, before the title, and it is the only link element.
68 expect(html.indexOf("<link rel=\"icon\"")).toBeLessThan(html.indexOf("<title>"));
69 expect(html.match(/<link/g)?.length).toBe(1);
70 // A data URI with a bare # would be a fragment, and the colours are hex.
71 expect(html).not.toContain("favicon.ico");
72 const href = /<link rel="icon" href="([^"]*)"/.exec(html)?.[1] as string;
73 expect(href).not.toContain("#");
74 // It is a URL, and what it carries is one whole SVG: decoded, not guessed at.
75 const url = new URL(href);
76 expect(url.protocol).toBe("data:");
77 const svg = decodeURIComponent(href.slice("data:image/svg+xml,".length));
78 expect(svg.startsWith("<svg xmlns=")).toBe(true);
79 expect(svg.endsWith("</svg>")).toBe(true);
80 expect(svg).toContain("viewBox='0 0 16 16'");
81 expect(svg).not.toContain('"');
82 // Small enough to be cheaper than the request it replaces.
83 expect(href.length).toBeLessThan(600);
84 });
85
4686 test("the environment pill says which deployment this is", () => {
4787 expect(where("production", undefined)).toBe("production");
4888 expect(where("preview", "17")).toBe("preview of pull #17");
src/pages/shell.ts +32 −5
@@ -10,6 +10,11 @@
1010 * and where it sits. The list itself lives in `./games.ts`, which both pages
1111 * read, so no page imports another page.
1212 *
13 * The script is the one thing a page may not have: a game hands in its bundle
14 * and gets a `<script>`, and a page that runs nothing — the 404, the 500 — hands
15 * in none and gets no script element at all. Everything else in the document is
16 * the same for both, which is the point of the shell.
17 *
1318 * Escaping is the contract at this boundary. A parameter that carries text is
1419 * escaped here (`title`, `heading`, `env`, `commit`, `ssh`, a touch label, an
1520 * id, a nav entry's href and label); a parameter that carries markup the
@@ -89,6 +94,23 @@ code { background:#141821; padding:.1rem .35rem; border-radius:.25rem; }
8994 @media (max-width: 640px) { #stage { aspect-ratio:4/3; } h1 { font-size:1.2rem; } }
9095 `;
9196
97 /**
98 * The icon, in the document rather than beside it: a crosshair on the page's
99 * own background, written as an SVG data URI. A browser with no `<link
100 * rel=icon>` asks for `/favicon.ico` on its own and is answered with a 404 by
101 * design, which is a wasted request and a wasted miss; this is the same picture
102 * in about three hundred bytes of the first paint, with no second request, no
103 * file under `static/` and no route. The CSP already allows `img-src 'self'
104 * data:`. `#` is `%23` because a bare one would start the URL's fragment;
105 * single quotes are safe inside the double-quoted attribute, and there is
106 * nothing here a caller can reach.
107 */
108 const FAVICON =
109 "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E" +
110 "%3Crect width='16' height='16' rx='3.5' fill='%230b0d11'/%3E" +
111 "%3Cpath d='M8 2.2v3.4M8 10.4v3.4M2.2 8h3.4M10.4 8h3.4' stroke='%23e2b04a' stroke-width='1.4' stroke-linecap='round'/%3E" +
112 "%3Ccircle cx='8' cy='8' r='1.3' fill='%236fa8e8'/%3E%3C/svg%3E";
113
92114 /** One touch button: the action `src/engine/controls.ts` holds, and its label. */
93115 export type TouchButton = { action: string; label: string };
94116
@@ -177,8 +199,11 @@ export type PageOptions = {
177199 env: string;
178200 pull: string | undefined;
179201 commit: string;
180 /** The URL of the one module script. Nothing else runs. */
181 script: string;
202 /**
203 * The URL of the one module script. Nothing else runs — and a page that runs
204 * nothing leaves this out, which emits no `<script>` element at all.
205 */
206 script?: string;
182207 ssh: string | undefined;
183208 /** The sentence in front of the ssh command, when there is one. */
184209 terminalLead: string;
@@ -188,17 +213,20 @@ export type PageOptions = {
188213 sectionsHtml: string;
189214 /** Text. The footer reads `<heading> · <where> · <footer>`. */
190215 footer: string;
191 /** One game's own CSS, appended to the shared block so a rule for one game never reaches the other. */
216 /** One page's own CSS, appended to the shared block so a rule for one page never reaches another. */
192217 extraStyle?: string;
193218 };
194219
195220 /** The whole document. */
196221 export function page(options: PageOptions): string {
197222 const here = where(options.env, options.pull);
223 const script =
224 options.script === undefined ? "" : `<script type="module" src="${escape(options.script)}"></script>\n`;
198225 return `<!doctype html>
199226 <html lang="en">
200227 <meta charset="utf-8">
201228 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
229 <link rel="icon" href="${FAVICON}">
202230 <title>${escape(options.title)}</title>
203231 <meta name="description" content="${escape(options.description)}">
204232 <style>${STYLE}${options.extraStyle === undefined ? "" : cssSafe(options.extraStyle)}</style>
@@ -215,7 +243,6 @@ ${options.sectionsHtml}
215243 <p class="terminal">${terminal(options.ssh, options.terminalLead)}</p>
216244 </main>
217245 <footer>${escape(options.heading)} · ${escape(here)} · ${escape(options.footer)}</footer>
218 <script type="module" src="${escape(options.script)}"></script>
219 </html>
246 ${script}</html>
220247 `;
221248 }
src/server.test.ts +66 −3
@@ -14,7 +14,7 @@ process.env.PORT = "0";
1414 // The test is the forge: it holds the same key and signs identities with it.
1515 const KEY = "the-key-the-forge-signs-with";
1616 process.env.FORGE_APP_IDENTITY_KEY = KEY;
17 const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT } = await import("./server.ts");
17 const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT, onError } = await import("./server.ts");
1818 const base = `http://${server.hostname}:${server.port}`;
1919
2020 /** The two headers the forge puts on a request it proxies for a signed-in person. */
@@ -213,13 +213,76 @@ test("the 404 names both games instead of only the front page", async () => {
213213 const response = await fetch(base + "/nope");
214214 expect(response.status).toBe(404);
215215 const body = await response.text();
216 expect(body).toContain("Not found");
216 expect(body).toContain("not found");
217217 expect(body).toContain(`<a href="/">cs</a>`);
218218 expect(body).toContain(`<a href="/range">range</a>`);
219219 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
220220 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
221221 });
222222
223 test("a wrong path lands on the same document a game does, not on a fragment", async () => {
224 // It used to be four hundred bytes of unstyled markup. It is the shell now:
225 // the same head, the same header and the same footer every game is served in.
226 const floor = await fetch(base + "/");
227 const response = await fetch(base + "/nope");
228 expect(response.status).toBe(404);
229 const body = await response.text();
230 expect(body.startsWith("<!doctype html>")).toBe(true);
231 expect(body).toContain(`<meta charset="utf-8">`);
232 expect(body).toContain(`content="width=device-width, initial-scale=1, viewport-fit=cover"`);
233 expect(body).toContain(`<link rel="icon" href="data:image/svg+xml,`);
234 expect(body).toContain(`<nav aria-label="Games">`);
235 expect(body).toContain("<h1>not found</h1>");
236 expect(body).toContain("<footer>");
237 // Nothing runs on it, so nothing is downloaded for it.
238 expect(body).not.toContain("<script");
239 // The whole floor, header for header, is the same as the front page's.
240 for (const header of ["content-security-policy", "x-content-type-options", "referrer-policy",
241 "permissions-policy", "content-type"])
242 expect(response.headers.get(header)).toBe(floor.headers.get(header));
243 expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
244 });
245
246 test("HEAD on a missing path is the 404 with nothing to read", async () => {
247 const head = await server.fetch(new Request(base + "/nope", { method: "HEAD" }));
248 expect(head.status).toBe(404);
249 expect(await head.text()).toBe("");
250 expect(head.headers.get("content-type")).toBe("text/html; charset=utf-8");
251 expect(head.headers.get("content-security-policy")).toContain("default-src 'self'");
252 });
253
254 test("a bug is a document too, and the error stays in the log", async () => {
255 // The handler itself, called with an error that carries a path and a stack:
256 // reaching it through a broken route would mean leaving one behind.
257 const logged: unknown[] = [];
258 const console_error = console.error;
259 console.error = (...args: unknown[]) => void logged.push(args[0]);
260 const boom = new Error("ENOENT: no such file, open '/data/app/secret/config.json'");
261 const response = onError(boom);
262 console.error = console_error;
263
264 expect(response.status).toBe(500);
265 expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
266 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
267 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
268 expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
269
270 const body = await response.text();
271 expect(body.startsWith("<!doctype html>")).toBe(true);
272 expect(body).toContain("<h1>something went wrong</h1>");
273 expect(body).toContain(`<nav aria-label="Games">`);
274 expect(body).toContain("<footer>");
275 expect(body).not.toContain("<script");
276 // Nothing the error was carrying is on the page: not the message, not the
277 // path in it, not the stack, not the file the stack names.
278 expect(body).not.toContain("ENOENT");
279 expect(body).not.toContain("/data/app/secret/config.json");
280 expect(body).not.toContain("secret");
281 for (const line of (boom.stack ?? "").split("\n").slice(1)) expect(body).not.toContain(line.trim());
282 // And it did reach the log, which is the only place it is.
283 expect(logged).toEqual([boom]);
284 });
285
223286 test("the range stands on the same security floor as the game", async () => {
224287 const [game, range] = await Promise.all([fetch(base + "/"), fetch(base + "/range")]);
225288 for (const header of ["content-security-policy", "x-content-type-options", "referrer-policy",
@@ -240,7 +303,7 @@ test("HEAD /range answers like GET, and an unknown path is still the same 404",
240303
241304 const missing = await fetch(base + "/range/nope");
242305 expect(missing.status).toBe(404);
243 expect(await missing.text()).toContain("Not found");
306 expect(await missing.text()).toContain("<h1>not found</h1>");
244307 expect(missing.headers.get("content-security-policy")).toContain("default-src 'self'");
245308 });
246309
src/server.ts +27 −14
@@ -25,9 +25,9 @@ import { bind } from "./bind.ts";
2525 import { openDatabase, type Player, saveMatch } from "./db.ts";
2626 import { GAMES } from "./games.ts";
2727 import { createLimiter } from "./limit.ts";
28 import { listHtml } from "./pages/games.ts";
2928 import { examplePage } from "./pages/example.ts";
3029 import { gamePage, statsHtml } from "./pages/game.ts";
30 import { errorPage, notFoundPage } from "./pages/miss.ts";
3131 import { rangePage } from "./pages/range.ts";
3232 import { cleanName, parseResult } from "./result.ts";
3333 import { who, type Who } from "./who.ts";
@@ -222,11 +222,28 @@ const READ = new Map<string, (request: Request) => Response>(
222222 }),
223223 );
224224
225 // A wrong path is a dead end unless it says where the games are, so the 404
226 // names every one of them and what each one is, from the registry. Built once
227 // at start-up: it never changes, and every href, label and sentence in it is
228 // escaped where it is rendered, like anywhere else markup is made here.
229 const NOT_FOUND = `<h1>Not found</h1><p>The games on this server:</p>${listHtml()}`;
225 // The two pages no game owns, rendered through the same shell every game is
226 // served inside: `src/pages/miss.ts` writes both. A wrong path is a dead end
227 // unless it says where the games are, so the 404 names every one of them and
228 // what each one is, from the registry; the 500 names nothing at all. Both are
229 // built once at start-up, because neither ever changes — a miss stays as cheap
230 // as it was when it was a fragment — and every href, label and sentence in them
231 // is escaped where it is rendered, like anywhere else markup is made here.
232 const NOT_FOUND = notFoundPage({ env, pull, commit, ssh });
233 const SERVER_ERROR = errorPage({ env, pull, commit, ssh });
234
235 /**
236 * What a bug answers with: the floor, the shell's document and nothing else —
237 * never the runtime's own error page, which carries source and paths, and never
238 * without the headers. The error itself goes to the log, where it belongs, and
239 * is not handed to the page, so there is nothing on it to leak. Exported
240 * because it is the whole of what a 500 is here, and a test can call it without
241 * leaving a broken route behind.
242 */
243 export function onError(error: unknown): Response {
244 console.error(error);
245 return html(SERVER_ERROR, 500);
246 }
230247
231248 // HEAD is GET without the body: the headers, the status and nothing to read.
232249 const withoutBodyIfHead = (request: Request, response: Response): Response =>
@@ -287,15 +304,11 @@ export const server = Bun.serve({
287304 });
288305 }
289306
290 return html(NOT_FOUND, 404);
291 },
292 // A bug answers with the floor and nothing else: never the runtime's own
293 // error page, which carries source and paths, and never without the
294 // headers. The detail goes to the log, where it belongs.
295 error(error) {
296 console.error(error);
297 return html("<h1>Something went wrong</h1>", 500);
307 // Nothing answers here. HEAD is GET without a body on a miss too: the
308 // status and the headers, and nothing to read.
309 return withoutBodyIfHead(request, html(NOT_FOUND, 404));
298310 },
311 error: onError,
299312 });
300313
301314 if (listen.unusable !== undefined)

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs