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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/72-a-game-is-one-entry-one-registry-the-bui → main 1 commit fork
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. | ||
| 325 | 325 | heading — each an anchor at least 44px on a side, in the first paint and |
| 326 | 326 | before `<main>`, with the current one drawn as text carrying |
| 327 | 327 | `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. | |
| 331 | 334 | A rule only one game needs goes in `extraStyle`, never in the shared block, |
| 332 | 335 | and CSS handed in there cannot close the block. So **a second game's page |
| 333 | 336 | 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. | ||
| 338 | 341 | `src/client/stage.test-helper.ts`, the stub page a test boots on is built from |
| 339 | 342 | that list and `src/server.test.ts` pins the served page against the same one, |
| 340 | 343 | 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 | |
| 344 | 360 | up only its own stale bundles, so one |
| 345 | 361 | game's rebuild never deletes another's, and `bun test` fails if any committed |
| 346 | 362 | 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. | |
| 353 | 364 | - **Every bundle has a ceiling.** Today, and `bun run scripts/build.ts` prints |
| 354 | 365 | these next to each file name: |
| 355 | 366 | |
| @@ -364,8 +375,9 @@ subtends — and the panel says which of the two you are looking at. | ||
| 364 | 375 | a fresh build of the sources against both ceilings — a stale commit cannot |
| 365 | 376 | hide a fat build — and the failure names the bundle, what it measured, what it |
| 366 | 377 | 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. | |
| 369 | 381 | - No dependencies. Nothing is installed to run this. |
| 370 | 382 | |
| 371 | 383 | 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 { | ||
| 14 | 14 | write, |
| 15 | 15 | type Entry, |
| 16 | 16 | } from "./build.ts"; |
| 17 | import { GAMES } from "../src/games.ts"; | |
| 17 | 18 | |
| 18 | 19 | /** Room enough that the fixture's two-line bundles are never the thing failing. */ |
| 19 | 20 | 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 | ||
| 39 | 40 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } }, |
| 40 | 41 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 29_400, gzip: 12_700 } }, |
| 41 | 42 | ]); |
| 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"]); | |
| 42 | 49 | }); |
| 43 | 50 | |
| 44 | 51 | test("every entry is bundled to its own name and its own content hash", async () => { |
scripts/build.ts +11 −38
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | /** |
| 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/. | |
| 3 | 4 | * `bun run build` writes them; the test compares what is committed against what |
| 4 | 5 | * the sources produce now, so a stale bundle fails the check instead of the |
| 5 | 6 | * browser. Each entry also carries the size it may not pass, and `build.test.ts` |
| @@ -8,54 +9,26 @@ | ||
| 8 | 9 | |
| 9 | 10 | import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; |
| 10 | 11 | import { join, resolve } from "node:path"; |
| 12 | import { type Ceiling, GAMES } from "../src/games.ts"; | |
| 11 | 13 | |
| 12 | 14 | const ROOT = join(import.meta.dir, ".."); |
| 13 | 15 | export const STATIC_DIR = join(ROOT, "static"); |
| 14 | 16 | |
| 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 }; | |
| 17 | 18 | |
| 18 | 19 | /** |
| 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. | |
| 22 | 23 | */ |
| 23 | 24 | export type Entry = { name: string; entry: string; ceiling: Ceiling }; |
| 24 | 25 | |
| 25 | 26 | /** |
| 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. | |
| 54 | 30 | */ |
| 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 })); | |
| 59 | 32 | |
| 60 | 33 | /** |
| 61 | 34 | * 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 | ||
| 36 | 36 | else boot(); |
| 37 | 37 | |
| 38 | 38 | |
| 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. | |
| 42 | 45 | |
| 43 | 46 | ### 2. A host |
| 44 | 47 |
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"; | ||
| 13 | 13 | import { displayName, headshotShare, ratio, recentMatches, standings } from "../db.ts"; |
| 14 | 14 | import { CARD } from "../client/weights.ts"; |
| 15 | 15 | import type { Who } from "../who.ts"; |
| 16 | import { navFor } from "./games.ts"; | |
| 16 | import { navFor, othersHtml } from "./games.ts"; | |
| 17 | 17 | import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| 18 | 18 | |
| 19 | 19 | export { escape }; |
| @@ -139,9 +139,8 @@ export function gamePage(db: Database, { who: visitor, ...options }: PageOptions | ||
| 139 | 139 | the browser's word, though: signed in to the forge, your results are recorded under |
| 140 | 140 | that name, and everyone else plays as a guest and is listed as one.</p> |
| 141 | 141 | <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> | |
| 145 | 144 | </section>`, |
| 146 | 145 | }); |
| 147 | 146 | } |
src/pages/games.ts +30 −11
| @@ -1,22 +1,24 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 5 | 7 | * |
| 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. | |
| 8 | 12 | */ |
| 9 | 13 | |
| 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"; | |
| 11 | 16 | |
| 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. */ | |
| 13 | 18 | export type Game = { href: string; label: string }; |
| 14 | 19 | |
| 15 | 20 | /** 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 })); | |
| 20 | 22 | |
| 21 | 23 | /** |
| 22 | 24 | * The nav a page at `here` hands the shell: every game, with its own entry |
| @@ -24,3 +26,20 @@ export const GAMES: readonly Game[] = [ | ||
| 24 | 26 | */ |
| 25 | 27 | export const navFor = (here: string): NavEntry[] => |
| 26 | 28 | 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 @@ | ||
| 10 | 10 | * terminal stay Counter-Strike's. |
| 11 | 11 | */ |
| 12 | 12 | |
| 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"; | |
| 15 | 15 | |
| 16 | 16 | /** What the shell needs that this page cannot know: the environment it runs in. */ |
| 17 | 17 | export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">; |
| @@ -95,8 +95,8 @@ export function rangePage(options: PageOptions): string { | ||
| 95 | 95 | quarter the volume of one at 200. The device is opened on the gesture that starts a run and |
| 96 | 96 | never before it — a page you only look at opens no audio at all.</p> |
| 97 | 97 | <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> | |
| 100 | 100 | </section>`, |
| 101 | 101 | }); |
| 102 | 102 | } |
src/server.test.ts +63
| @@ -3,6 +3,8 @@ import { mkdtempSync, readdirSync } from "node:fs"; | ||
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { bundle, STATIC_DIR } from "../scripts/build.ts"; |
| 6 | import { GAMES } from "./games.ts"; | |
| 7 | import { escape } from "./pages/shell.ts"; | |
| 6 | 8 | import { parseResult } from "./result.ts"; |
| 7 | 9 | import { sign } from "./who.ts"; |
| 8 | 10 | 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 () => | ||
| 108 | 110 | } |
| 109 | 111 | }); |
| 110 | 112 | |
| 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 | ||
| 111 | 174 | test("the 404 names both games instead of only the front page", async () => { |
| 112 | 175 | const response = await fetch(base + "/nope"); |
| 113 | 176 | expect(response.status).toBe(404); |
src/server.ts +57 −25
| @@ -5,6 +5,12 @@ | ||
| 5 | 5 | * serves each game's bundled script, and keeps match results in SQLite under |
| 6 | 6 | * FORGE_APP_DATA. Production and each preview get their own directory. |
| 7 | 7 | * |
| 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 | * | |
| 8 | 14 | * A result is the browser's word for the numbers and nobody's word for the |
| 9 | 15 | * name, so the name is not taken from it: `src/who.ts` verifies the forge's |
| 10 | 16 | * signed identity headers and a result is recorded under that, or as a guest's. |
| @@ -14,12 +20,13 @@ | ||
| 14 | 20 | |
| 15 | 21 | import { existsSync, readdirSync } from "node:fs"; |
| 16 | 22 | import { join } from "node:path"; |
| 17 | import { ENTRIES, isBundleOf, STATIC_DIR } from "../scripts/build.ts"; | |
| 23 | import { isBundleOf, STATIC_DIR } from "../scripts/build.ts"; | |
| 18 | 24 | import { bind } from "./bind.ts"; |
| 19 | 25 | import { openDatabase, type Player, saveMatch } from "./db.ts"; |
| 26 | import { GAMES } from "./games.ts"; | |
| 20 | 27 | 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"; | |
| 23 | 30 | import { rangePage } from "./pages/range.ts"; |
| 24 | 31 | import { cleanName, parseResult } from "./result.ts"; |
| 25 | 32 | import { who, type Who } from "./who.ts"; |
| @@ -170,20 +177,53 @@ const playerOf = (visitor: Who, session: string, claimed: string): Player => | ||
| 170 | 177 | function findScripts(): Record<string, string> { |
| 171 | 178 | const files = existsSync(STATIC_DIR) ? readdirSync(STATIC_DIR) : []; |
| 172 | 179 | 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`}`; | |
| 174 | 181 | return found; |
| 175 | 182 | } |
| 176 | 183 | 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 | ); | |
| 179 | 221 | |
| 180 | 222 | // 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()}`; | |
| 187 | 227 | |
| 188 | 228 | // HEAD is GET without the body: the headers, the status and nothing to read. |
| 189 | 229 | const withoutBodyIfHead = (request: Request, response: Response): Response => |
| @@ -225,21 +265,13 @@ export const server = Bun.serve({ | ||
| 225 | 265 | return html(statsHtml(db), 200, { "set-cookie": sessionCookie(session) }); |
| 226 | 266 | } |
| 227 | 267 | |
| 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)); | |
| 236 | 273 | } |
| 237 | 274 | |
| 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 | ||
| 243 | 275 | // Files under ./static are served as they are, cached for a year: they are |
| 244 | 276 | // named by content (app.3f2a9c1b04.js), so a change is a new name, never a |
| 245 | 277 | // stale cache. The path is confined to the directory, and a name that is |
new above
offline