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

forked from trevato/cs · proposes

A game is one entry: one registry the build, the server, the nav and the 404 all read #83

Merged opened this

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

Merged

torque/cs-engine:torque/72-a-game-is-one-entry-one-registry-the-bui → main 1 commit fork

  • Merged. torque/72-a-game-is-one-entry-one-registry-the-bui was merged into main as 266e608.

Review

Sign in to review this change.
11 files changed +330 −99
README.md +26 −14
@@ -325,9 +325,12 @@ subtends — and the panel says which of the two you are looking at.
325325 heading — each an anchor at least 44px on a side, in the first paint and
326326 before `<main>`, with the current one drawn as text carrying
327327 `aria-current="page"` rather than a link to where you already are. The list
328 itself lives once, in `src/pages/games.ts` (`GAMES` and `navFor(here)`), which
329 both pages and the server's 404 read, so **a third game gets the nav by
330 passing one more entry there** — and no page ever imports another page.
328 itself is `src/pages/games.ts` (`GAMES` and `navFor(here)`), which both pages
329 read, and it is the registry projected, so **a third game gets the nav for
330 free** — and no page ever imports another page. What one page says about
331 another comes from there too (`othersHtml(here)`): the sentence about a game
332 is that game's own registry entry, never prose hand-written next door, which
333 is how it goes stale.
331334 A rule only one game needs goes in `extraStyle`, never in the shared block,
332335 and CSS handed in there cannot close the block. So **a second game's page
333336 writes its overlays, its touch buttons and its sections, and gets the rest**;
@@ -338,18 +341,26 @@ subtends — and the panel says which of the two you are looking at.
338341 `src/client/stage.test-helper.ts`, the stub page a test boots on is built from
339342 that list and `src/server.test.ts` pins the served page against the same one,
340343 so the page the game is tested on and the page it is served on cannot drift.
341 - `scripts/build.ts` bundles a table of entries — `ENTRIES`, one line per game:
342 a name, a source file and the size it may not pass — into
343 `static/<name>.<hash>.js`, committed and cached for a year. Each entry sweeps
344 - `src/games.ts` is **the registry: a game is one entry there.** It holds the
345 name its bundle is filed under, the path it answers on, what the header calls
346 it, the source it is bundled from, the size that bundle may not pass and the
347 one sentence the other pages say about it. It is plain data and imports
348 nothing, which is what lets the build and the server both read it:
349 `scripts/build.ts` projects `ENTRIES` out of it, `src/pages/games.ts` projects
350 the nav and the cross-links, and `src/server.ts` builds its read routes from
351 it against `RENDER`, the one map from a game's name to the function that draws
352 its page. So **adding a game is one entry in `src/games.ts` and one
353 `src/pages/<name>.ts` that calls the shell**, plus the single line in `RENDER`
354 that names that page function — because a page is a function and cannot be
355 data. The build, the header's nav, the cross-links on every other page and the
356 404 all follow on their own. No inline script; the CSP is `default-src 'self'`.
357 `/range` is that recipe followed once.
358 - `scripts/build.ts` bundles every entry into `static/<name>.<hash>.js`,
359 committed and cached for a year. Each entry sweeps
344360 up only its own stale bundles, so one
345361 game's rebuild never deletes another's, and `bun test` fails if any committed
346362 bundle is stale — today that is `app` (the shooter) and `range`. The server
347 resolves the script for an entry by that exact name, so **adding a second game
348 is one line in `ENTRIES`, a `src/pages/<name>.ts` that calls the shell, a
349 route in `src/server.ts`** that renders it with `scripts["<name>"]` as the
350 `script`, and one more entry in `src/pages/games.ts` so every header and the
351 404 link to it — no inline script, the CSP is `default-src 'self'`. `/range`
352 is that recipe followed once.
363 resolves the script for an entry by that exact name.
353364 - **Every bundle has a ceiling.** Today, and `bun run scripts/build.ts` prints
354365 these next to each file name:
355366
@@ -364,8 +375,9 @@ subtends — and the panel says which of the two you are looking at.
364375 a fresh build of the sources against both ceilings — a stale commit cannot
365376 hide a fat build — and the failure names the bundle, what it measured, what it
366377 may not pass and the overshoot in bytes and percent. **Raising a ceiling is
367 allowed**: edit the number in `ENTRIES` and say in the pull what was added and
368 why it is worth the bytes. What is not allowed is growth nobody noticed.
378 allowed**: edit the number on that game's entry in `src/games.ts`, where the
379 log of what moved each one lives, and say in the pull what was added and why
380 it is worth the bytes. What is not allowed is growth nobody noticed.
369381 - No dependencies. Nothing is installed to run this.
370382
371383 It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app
scripts/build.test.ts +7
@@ -14,6 +14,7 @@ import {
1414 write,
1515 type Entry,
1616 } from "./build.ts";
17 import { GAMES } from "../src/games.ts";
1718
1819 /** Room enough that the fixture's two-line bundles are never the thing failing. */
1920 const ROOMY = { raw: 100_000, gzip: 100_000 };
@@ -39,6 +40,12 @@ test("the entry table is what the app ships today, each with the size it may not
3940 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } },
4041 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 29_400, gzip: 12_700 } },
4142 ]);
43 // And it is the registry projected, not a second list beside it: a game's
44 // source and its ceiling are written down once, in `src/games.ts`.
45 expect(ENTRIES).toEqual(GAMES.map(({ name, entry, ceiling }) => ({ name, entry, ceiling })));
46 // Nothing else of a game comes along: this file has no idea what path any of
47 // them answers on.
48 for (const entry of ENTRIES) expect(Object.keys(entry).sort()).toEqual(["ceiling", "entry", "name"]);
4249 });
4350
4451 test("every entry is bundled to its own name and its own content hash", async () => {
scripts/build.ts +11 −38
@@ -1,5 +1,6 @@
11 /**
2 * The build: a table of entries, each bundled to `<name>.<hash>.js` in static/.
2 * The build: every game in the registry, bundled to `<name>.<hash>.js` in
3 * static/.
34 * `bun run build` writes them; the test compares what is committed against what
45 * the sources produce now, so a stale bundle fails the check instead of the
56 * browser. Each entry also carries the size it may not pass, and `build.test.ts`
@@ -8,54 +9,26 @@
89
910 import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
1011 import { join, resolve } from "node:path";
12 import { type Ceiling, GAMES } from "../src/games.ts";
1113
1214 const ROOT = join(import.meta.dir, "..");
1315 export const STATIC_DIR = join(ROOT, "static");
1416
15 /** The bytes a bundle may not pass: raw on disk, and gzipped over the wire. */
16 export type Ceiling = { raw: number; gzip: number };
17 export type { Ceiling };
1718
1819 /**
19 * One game: the name its bundle is filed under, the source it starts from, and
20 * the size it may not pass. The ceiling is not optional — a game that ships
21 * without a number is a game nobody is holding to one.
20 * One bundle: the name it is filed under, the source it starts from, and the
21 * size it may not pass. It is the part of a game this file cares about —
22 * nothing here knows what path a game answers on or what the header calls it.
2223 */
2324 export type Entry = { name: string; entry: string; ceiling: Ceiling };
2425
2526 /**
26 * Adding a game is one line here plus a route in `src/server.ts`.
27 *
28 * The ceilings are today's sizes plus about 10%, rounded up to a round number:
29 * enough headroom that an honest feature lands without a ceiling edit, tight
30 * enough that a stray import of something large trips the test. Measured
31 * 2026-09-17: app 69,711 raw / 32,139 gzipped, range 12,548 raw / 5,726 gzipped.
32 * The range grew 973 bytes for #49, which moved its panel, its lines and its
33 * crosshair onto the engine's overlay — a tool set is bundled whole, so a game
34 * that uses three of its tools still ships all of them. It fits, with about 250
35 * raw and 70 gzipped bytes to spare: the next thing the range gains will want
36 * this ceiling raised, and #18 takes the same bytes back out of the shooter.
37 * The app grew 1,254 bytes for #47, which moved every decision about a frame
38 * into `src/client/scene.ts`; it fits under today's ceiling, so no ceiling moved
39 * with it. It grew another 830 raw / 701 gzipped for #48, which moved the
40 * renderer behind the door: the game now builds all eight of its meshes and
41 * hands them in at construction rather than uploading each on the frame that
42 * first asks for it. Measured after #48: app 70,558 raw / 32,850 gzipped,
43 * range 12,548 raw / 5,729 gzipped — the range is unchanged in size, which is
44 * the tree-shaking working: it imports the door and none of the WebGL2 behind it. The range's ceiling was raised for #41, which gave it a game: the targets,
45 * the clock and the score, the shot traced through the engine's `rayBox`, and
46 * the blips the horizon draws them as.
47 * The range's ceiling was raised again for #52, which gave it sound: the
48 * engine's `sound` module and `src/range/audio.ts`'s five recipes together are
49 * 2,444 raw and 956 gzipped bytes. Measured 2026-09-18: range 26,658 raw /
50 * 11,456 gzipped, which fitted the old 26,700 / 11,600 by 42 bytes — not a
51 * ceiling anybody is holding — so it is today's size plus about 10% again.
52 * Raising a ceiling is allowed, and it is a line in the pull that says what was
53 * added and why.
27 * What is built, projected from the registry in `src/games.ts`: a game is
28 * bundled because it is a game, not because it is also written down here. The
29 * ceilings and the log of what moved them live beside the entries there.
5430 */
55 export const ENTRIES: Entry[] = [
56 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } },
57 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 29_400, gzip: 12_700 } },
58 ];
31 export const ENTRIES: Entry[] = GAMES.map(({ name, entry, ceiling }) => ({ name, entry, ceiling }));
5932
6033 /**
6134 * The one level gzip is ever measured at here. Compression level is the knob
src/engine/GUIDE.md +6 −3
@@ -36,9 +36,12 @@ if (document.readyState === "loading") document.addEventListener("DOMContentLoad
3636 else boot();
3737 ```
3838
39 Three more lines and the game is reachable: an entry with a size ceiling in
40 `build.ENTRIES` in `scripts/build.ts`, a route in `src/server.ts`, and a page
41 that server-renders the markup the entry looks up by id (`src/pages/range.ts`).
39 Two more things and the game is reachable: one entry in the registry in
40 `src/games.ts` — its name, its path, its label, this source file and a size
41 ceiling — and a page that server-renders the markup the entry looks up by id
42 (`src/pages/range.ts`), named in the one map in `src/server.ts` that turns a
43 game's name into its page function. The build, the header's nav and the 404
44 follow from the entry.
4245
4346 ### 2. A host
4447
src/games.test.ts added +37
@@ -0,0 +1,37 @@
1 /**
2 * The registry, held to the rules everything downstream assumes about it: the
3 * build turns a name into a file name and a regular expression, the server
4 * turns an href into a route, and both pages and the 404 render a label and a
5 * sentence as text. Each of those is one invariant here.
6 */
7
8 import { expect, test } from "bun:test";
9 import { existsSync } from "node:fs";
10 import { join } from "node:path";
11 import { GAMES } from "./games.ts";
12
13 const ROOT = join(import.meta.dir, "..");
14
15 test("every game has a plain name of its own, and a source that is there", () => {
16 expect(GAMES.length).toBeGreaterThan(0);
17 for (const { name, entry } of GAMES) {
18 // The same shape `isBundleOf` in scripts/build.ts is willing to match.
19 expect(name).toMatch(/^[a-z][a-z0-9-]*$/);
20 expect(entry).toMatch(/^src\/[a-z0-9/.-]+\.ts$/);
21 expect(existsSync(join(ROOT, entry))).toBe(true);
22 }
23 expect(new Set(GAMES.map((g) => g.name)).size).toBe(GAMES.length);
24 });
25
26 test("every game answers on a path of its own, and says what it is", () => {
27 for (const { href, label, blurb, ceiling } of GAMES) {
28 expect(href.startsWith("/")).toBe(true);
29 expect(label).not.toBe("");
30 // The sentence the other pages read: long enough to be one, and ended.
31 expect(blurb.length).toBeGreaterThan(20);
32 expect(blurb.endsWith(".")).toBe(true);
33 expect(ceiling.raw).toBeGreaterThan(ceiling.gzip);
34 }
35 expect(new Set(GAMES.map((g) => g.href)).size).toBe(GAMES.length);
36 expect(new Set(GAMES.map((g) => g.label)).size).toBe(GAMES.length);
37 });
src/games.ts added +86
@@ -0,0 +1,86 @@
1 /**
2 * The registry: what a game on this server is, written down once. The build
3 * bundles it, the server routes it, the header's nav lists it, the 404 names
4 * it and each page reads what the others say about it from here.
5 *
6 * It is plain data and it imports nothing — not a page, not the build, not the
7 * server. That is the whole reason it can sit under all three: `scripts/build.ts`
8 * reads it to know what to bundle and `src/server.ts` reads it to know what to
9 * serve, and neither drags the other in. A registry that imported a page module
10 * would pull every page into the build script.
11 *
12 * Adding a game is one entry here plus one page: `src/pages/<name>.ts` that
13 * calls the shell, and the one line in `src/server.ts` that maps this entry's
14 * name to that page function — because a page is a function and cannot be data.
15 */
16
17 /** The bytes a bundle may not pass: raw on disk, and gzipped over the wire. */
18 export type Ceiling = { raw: number; gzip: number };
19
20 /** One game, whole: what it is called, where it answers, and what it ships. */
21 export type Game = {
22 /** The name its bundle is filed under: `static/<name>.<hash>.js`. Plain — a letter, then letters, digits and dashes. */
23 name: string;
24 /** The path it answers on. */
25 href: string;
26 /** What the header's nav and the 404 call it. */
27 label: string;
28 /** The source its bundle starts from, relative to the repository root. */
29 entry: string;
30 /** The size that bundle may not pass. Not optional — a game that ships without a number is a game nobody is holding to one. */
31 ceiling: Ceiling;
32 /** The one sentence the *other* pages say about this game. Text, escaped where it is rendered. Prose about a game belongs to that game, or it drifts. */
33 blurb: string;
34 };
35
36 /**
37 * In the order the header lists them, which is also the order they are built.
38 *
39 * The ceilings are today's sizes plus about 10%, rounded up to a round number:
40 * enough headroom that an honest feature lands without a ceiling edit, tight
41 * enough that a stray import of something large trips the test. Measured
42 * 2026-09-17: app 69,711 raw / 32,139 gzipped, range 12,548 raw / 5,726 gzipped.
43 * The range grew 973 bytes for #49, which moved its panel, its lines and its
44 * crosshair onto the engine's overlay — a tool set is bundled whole, so a game
45 * that uses three of its tools still ships all of them. It fits, with about 250
46 * raw and 70 gzipped bytes to spare: the next thing the range gains will want
47 * this ceiling raised, and #18 takes the same bytes back out of the shooter.
48 * The app grew 1,254 bytes for #47, which moved every decision about a frame
49 * into `src/client/scene.ts`; it fits under today's ceiling, so no ceiling moved
50 * with it. It grew another 830 raw / 701 gzipped for #48, which moved the
51 * renderer behind the door: the game now builds all eight of its meshes and
52 * hands them in at construction rather than uploading each on the frame that
53 * first asks for it. Measured after #48: app 70,558 raw / 32,850 gzipped,
54 * range 12,548 raw / 5,729 gzipped — the range is unchanged in size, which is
55 * the tree-shaking working: it imports the door and none of the WebGL2 behind
56 * it. The range's ceiling was raised for #41, which gave it a game: the targets,
57 * the clock and the score, the shot traced through the engine's `rayBox`, and
58 * the blips the horizon draws them as.
59 * The range's ceiling was raised again for #52, which gave it sound: the
60 * engine's `sound` module and `src/range/audio.ts`'s five recipes together are
61 * 2,444 raw and 956 gzipped bytes. Measured 2026-09-18: range 26,658 raw /
62 * 11,456 gzipped, which fitted the old 26,700 / 11,600 by 42 bytes — not a
63 * ceiling anybody is holding — so it is today's size plus about 10% again.
64 * Raising a ceiling is allowed, and it is a line in the pull that says what was
65 * added and why.
66 */
67 export const GAMES: readonly Game[] = [
68 {
69 name: "app",
70 href: "/",
71 label: "cs",
72 entry: "src/client/main.ts",
73 ceiling: { raw: 75_400, gzip: 34_900 },
74 blurb:
75 "a defusal shooter in the browser, with bots that learned to aim, bomb rounds, an economy and the leaderboard this server keeps.",
76 },
77 {
78 name: "range",
79 href: "/range",
80 label: "range",
81 entry: "src/range/main.ts",
82 ceiling: { raw: 29_400, gzip: 12_700 },
83 blurb:
84 "thirty seconds on the clock, pop-up plates down a lane, a shot traced in three dimensions and a score — every line of it out of the engine and none of it out of the shooter.",
85 },
86 ];
src/pages/game.ts +3 −4
@@ -13,7 +13,7 @@ import type { Database } from "bun:sqlite";
1313 import { displayName, headshotShare, ratio, recentMatches, standings } from "../db.ts";
1414 import { CARD } from "../client/weights.ts";
1515 import type { Who } from "../who.ts";
16 import { navFor } from "./games.ts";
16 import { navFor, othersHtml } from "./games.ts";
1717 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
1818
1919 export { escape };
@@ -139,9 +139,8 @@ export function gamePage(db: Database, { who: visitor, ...options }: PageOptions
139139 the browser's word, though: signed in to the forge, your results are recorded under
140140 that name, and everyone else plays as a guest and is listed as one.</p>
141141 <p>The engine underneath — the loop, the controls, the grid, the meshes, the textures and
142 the sounds — is a package, and <a href="/range">the range</a> is a second game being built
143 out of it that shares no line with this one. Today it is that engine's loop and controls
144 drawn as a readout.</p>
142 the sounds — is a package, and what is next door is built out of it and shares no line with
143 this game: ${othersHtml("/")}</p>
145144 </section>`,
146145 });
147146 }
src/pages/games.ts +30 −11
@@ -1,22 +1,24 @@
11 /**
2 * The games this server serves, written down once. The header's nav, and the
3 * 404's two links, are this list rendered — so a third game is one more entry
4 * here plus its page and its route, and every page grows the link on its own.
2 * The games as a page sees them: the header's nav, and the sentence one game
3 * says about another. Both are projections of the registry in `../games.ts`,
4 * which is where a game is written down once — so a third game is one entry
5 * there plus its page, and every header, every cross-link and the 404 grow it
6 * on their own.
57 *
6 * It is plain data and it imports nothing but the shell's type: a page never
7 * imports a page, and this is the list both of them read instead.
8 * A page never imports a page, and this is the list both of them read instead.
9 * The prose about a game comes from that game's own entry, never from the page
10 * doing the linking: a sentence hand-written next door is a sentence that goes
11 * stale the moment the game it describes moves on.
812 */
913
10 import type { NavEntry } from "./shell.ts";
14 import { GAMES as REGISTRY, type Game as Registered } from "../games.ts";
15 import { escape, type NavEntry } from "./shell.ts";
1116
12 /** One game: the path it answers on, and what the header calls it. */
17 /** One game, as a page needs it: the path it answers on, and what to call it. */
1318 export type Game = { href: string; label: string };
1419
1520 /** In the order the header lists them. */
16 export const GAMES: readonly Game[] = [
17 { href: "/", label: "cs" },
18 { href: "/range", label: "range" },
19 ];
21 export const GAMES: readonly Game[] = REGISTRY.map(({ href, label }) => ({ href, label }));
2022
2123 /**
2224 * The nav a page at `here` hands the shell: every game, with its own entry
@@ -24,3 +26,20 @@ export const GAMES: readonly Game[] = [
2426 */
2527 export const navFor = (here: string): NavEntry[] =>
2628 GAMES.map((game) => (game.href === here ? { ...game, current: true } : { ...game }));
29
30 /** A link to one game, and the one sentence its own entry says about it. Text in, markup out: both are escaped here. */
31 const entryHtml = ({ href, label, blurb }: Registered): string =>
32 `<a href="${escape(href)}">${escape(label)}</a> — ${escape(blurb)}`;
33
34 /**
35 * What a page at `here` says about the games next door: every other game, each
36 * as a link followed by its own sentence. A page writes the lead-in and this
37 * writes the rest, so no page describes a game it does not own.
38 */
39 export const othersHtml = (here: string): string =>
40 REGISTRY.filter((game) => game.href !== here)
41 .map(entryHtml)
42 .join(" ");
43
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>`;
src/pages/range.ts +4 −4
@@ -10,8 +10,8 @@
1010 * terminal stay Counter-Strike's.
1111 */
1212
13 import { navFor } from "./games.ts";
14 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
13 import { navFor, othersHtml } from "./games.ts";
14 import { page, type PageOptions as ShellOptions, stage } from "./shell.ts";
1515
1616 /** What the shell needs that this page cannot know: the environment it runs in. */
1717 export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">;
@@ -95,8 +95,8 @@ export function rangePage(options: PageOptions): string {
9595 quarter the volume of one at 200. The device is opened on the gesture that starts a run and
9696 never before it — a page you only look at opens no audio at all.</p>
9797 <p>Nothing on this page is stored. There is no endpoint, no table and no cookie behind it:
98 the leaderboard, the database and the terminal belong to
99 <a href="/">${escape("cs")}</a>, which is the game next door.</p>
98 the leaderboard, the database and the terminal belong to the game next door:
99 ${othersHtml("/range")}</p>
100100 </section>`,
101101 });
102102 }
src/server.test.ts +63
@@ -3,6 +3,8 @@ import { mkdtempSync, readdirSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { bundle, STATIC_DIR } from "../scripts/build.ts";
6 import { GAMES } from "./games.ts";
7 import { escape } from "./pages/shell.ts";
68 import { parseResult } from "./result.ts";
79 import { sign } from "./who.ts";
810 import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts";
@@ -108,6 +110,67 @@ test("every page's header carries the other game and marks its own", async () =>
108110 }
109111 });
110112
113 test("every game in the registry is served: its own page, its own bundle, its own place in the nav", async () => {
114 // The point of the registry: this is a loop, not a case per game, so a third
115 // game is held to everything the first two are the moment it is written down.
116 const floor = await fetch(base + "/");
117 const titles = new Set<string>();
118 for (const game of GAMES) {
119 const response = await fetch(base + game.href);
120 expect(response.status).toBe(200);
121 const body = await response.text();
122
123 // Its own document: a title of its own, no other game's.
124 const title = /<title>([^<]*)<\/title>/.exec(body)?.[1] as string;
125 expect(title).toContain(game.label);
126 expect(titles.has(title)).toBe(false);
127 titles.add(title);
128
129 // Its own bundle, one script, and never another game's.
130 expect(body).toMatch(new RegExp(`<script type="module" src="/static/${game.name}\\.[0-9a-f]+\\.js">`));
131 expect(body.match(/<script/g)?.length).toBe(1);
132 for (const other of GAMES) if (other.name !== game.name) expect(body).not.toContain(`/static/${other.name}.`);
133
134 // The nav marks this one and links every other, in the first paint.
135 expect(body).toContain(`<span class="here" aria-current="page">${escape(game.label)}</span>`);
136 for (const other of GAMES)
137 if (other.href !== game.href)
138 expect(body).toContain(`<a href="${escape(other.href)}">${escape(other.label)}</a>`);
139 expect(body.indexOf("<nav")).toBeLessThan(body.indexOf("<main>"));
140
141 // The same floor under every one of them.
142 for (const header of ["content-security-policy", "x-content-type-options", "referrer-policy",
143 "permissions-policy", "content-type"])
144 expect(response.headers.get(header)).toBe(floor.headers.get(header));
145
146 // HEAD is GET without a body, on every game's path.
147 const head = await server.fetch(new Request(base + game.href, { method: "HEAD" }));
148 expect(head.status).toBe(200);
149 expect(await head.text()).toBe("");
150 expect(head.headers.get("content-security-policy")).toBe(response.headers.get("content-security-policy"));
151 }
152 expect(titles.size).toBe(GAMES.length);
153 });
154
155 test("what one page says about another is that game's own sentence", async () => {
156 // The prose about a game lives in its registry entry, so a page next door
157 // cannot describe it out of date.
158 for (const game of GAMES) {
159 const body = await (await fetch(base + game.href)).text();
160 for (const other of GAMES)
161 if (other.href === game.href) expect(body).not.toContain(escape(other.blurb));
162 else expect(body).toContain(escape(other.blurb));
163 }
164 });
165
166 test("the 404 names every game in the registry and what each one is", async () => {
167 const body = await (await fetch(base + "/nope")).text();
168 for (const game of GAMES) {
169 expect(body).toContain(`<a href="${escape(game.href)}">${escape(game.label)}</a>`);
170 expect(body).toContain(escape(game.blurb));
171 }
172 });
173
111174 test("the 404 names both games instead of only the front page", async () => {
112175 const response = await fetch(base + "/nope");
113176 expect(response.status).toBe(404);
src/server.ts +57 −25
@@ -5,6 +5,12 @@
55 * serves each game's bundled script, and keeps match results in SQLite under
66 * FORGE_APP_DATA. Production and each preview get their own directory.
77 *
8 * What a game is — its bundle's name, its path, its label, its source and its
9 * ceiling — is one entry in `src/games.ts`, which the build reads too. The read
10 * routes below are that registry against `RENDER`, the one map from a game's
11 * name to the function that draws its page. The POST route is not in it:
12 * `/api/match` belongs to the shooter, not to games in general.
13 *
814 * A result is the browser's word for the numbers and nobody's word for the
915 * name, so the name is not taken from it: `src/who.ts` verifies the forge's
1016 * signed identity headers and a result is recorded under that, or as a guest's.
@@ -14,12 +20,13 @@
1420
1521 import { existsSync, readdirSync } from "node:fs";
1622 import { join } from "node:path";
17 import { ENTRIES, isBundleOf, STATIC_DIR } from "../scripts/build.ts";
23 import { isBundleOf, STATIC_DIR } from "../scripts/build.ts";
1824 import { bind } from "./bind.ts";
1925 import { openDatabase, type Player, saveMatch } from "./db.ts";
26 import { GAMES } from "./games.ts";
2027 import { createLimiter } from "./limit.ts";
21 import { GAMES } from "./pages/games.ts";
22 import { escape, gamePage, statsHtml } from "./pages/game.ts";
28 import { listHtml } from "./pages/games.ts";
29 import { gamePage, statsHtml } from "./pages/game.ts";
2330 import { rangePage } from "./pages/range.ts";
2431 import { cleanName, parseResult } from "./result.ts";
2532 import { who, type Who } from "./who.ts";
@@ -170,20 +177,53 @@ const playerOf = (visitor: Who, session: string, claimed: string): Player =>
170177 function findScripts(): Record<string, string> {
171178 const files = existsSync(STATIC_DIR) ? readdirSync(STATIC_DIR) : [];
172179 const found: Record<string, string> = {};
173 for (const { name } of ENTRIES) found[name] = `/static/${files.find((f) => isBundleOf(name, f)) ?? `${name}.js`}`;
180 for (const { name } of GAMES) found[name] = `/static/${files.find((f) => isBundleOf(name, f)) ?? `${name}.js`}`;
174181 return found;
175182 }
176183 const scripts = findScripts();
177 const script = scripts["app"] ?? "/static/app.js";
178 const rangeScript = scripts["range"] ?? "/static/range.js";
184
185 /**
186 * How one game's page is drawn for one request. Everything else about a game —
187 * where it answers, what it is called, which bundle it ships — is data in
188 * `./games.ts`; a page is a function and cannot be, so this is the map, and one
189 * line here is the whole of what a third game adds to this file.
190 */
191 type Render = (request: Request, script: string) => Response;
192
193 const RENDER: Record<string, Render> = {
194 // The shooter: the forge's identity, this environment's database, and a
195 // session cookie for a browser that arrives without one.
196 app: (request, script) => {
197 const session = readSession(request);
198 return html(
199 gamePage(db, { env, pull, commit, script, ssh, who: who(request, identityKey) }),
200 200,
201 session === null ? { "set-cookie": sessionCookie(newSession()) } : {},
202 );
203 },
204 // The range: its own page and its own bundle, and no session — it stores
205 // nothing, so it has nothing to name a browser for.
206 range: (_request, script) => html(rangePage({ env, pull, commit, script, ssh })),
207 };
208
209 // The read routes, resolved once at start-up: the path each game answers on,
210 // against its renderer holding its own bundle. A game in the registry with no
211 // page here is a mistake that fails now, at the first boot, rather than as a
212 // 404 nobody expected.
213 const READ = new Map<string, (request: Request) => Response>(
214 GAMES.map((game) => {
215 const render = RENDER[game.name];
216 if (render === undefined) throw new Error(`the game ${JSON.stringify(game.name)} has no page to render it`);
217 const script = scripts[game.name] ?? `/static/${game.name}.js`;
218 return [game.href, (request: Request) => render(request, script)];
219 }),
220 );
179221
180222 // A wrong path is a dead end unless it says where the games are, so the 404
181 // names every one of them, from the same list the header's nav is built from.
182 // Built once at start-up: it never changes, and text is text — both the href
183 // and the label are escaped, like anywhere else markup is made here.
184 const NOT_FOUND = `<h1>Not found</h1><p>The games on this server: ${GAMES.map(
185 (game) => `<a href="${escape(game.href)}">${escape(game.label)}</a>`,
186 ).join(" · ")}</p>`;
223 // names every one of them and what each one is, from the registry. Built once
224 // at start-up: it never changes, and every href, label and sentence in it is
225 // escaped where it is rendered, like anywhere else markup is made here.
226 const NOT_FOUND = `<h1>Not found</h1><p>The games on this server:</p>${listHtml()}`;
187227
188228 // HEAD is GET without the body: the headers, the status and nothing to read.
189229 const withoutBodyIfHead = (request: Request, response: Response): Response =>
@@ -225,21 +265,13 @@ export const server = Bun.serve({
225265 return html(statsHtml(db), 200, { "set-cookie": sessionCookie(session) });
226266 }
227267
228 // The game, and a health check on HEAD sees the app up.
229 if (url.pathname === "/" && isRead(request)) {
230 const session = readSession(request);
231 const page = gamePage(db, { env, pull, commit, script, ssh, who: who(request, identityKey) });
232 return withoutBodyIfHead(
233 request,
234 html(page, 200, session === null ? { "set-cookie": sessionCookie(newSession()) } : {}),
235 );
268 // The games, one table rather than one branch each. HEAD is GET without a
269 // body, so a health check on `/` sees the app up.
270 if (isRead(request)) {
271 const render = READ.get(url.pathname);
272 if (render !== undefined) return withoutBodyIfHead(request, render(request));
236273 }
237274
238 // The second game: its own page, its own bundle, and no session — it stores
239 // nothing, so it has nothing to name a browser for.
240 if (url.pathname === "/range" && isRead(request))
241 return withoutBodyIfHead(request, html(rangePage({ env, pull, commit, script: rangeScript, ssh })));
242
243275 // Files under ./static are served as they are, cached for a year: they are
244276 // named by content (app.3f2a9c1b04.js), so a change is a new name, never a
245277 // stale cache. The path is confined to the directory, and a name that is

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs