Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
Both games are one click apart: the shell carries the nav, and the 404 offers both #46
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/33-both-games-are-one-click-apart-the-shell → main 2 commits fork
8 files
changed +174
−15
README.md +17 −6
| @@ -210,15 +210,25 @@ page shows is the loop and the controls themselves, as numbers that move. | ||
| 210 | 210 | an IPv4 or IPv6 literal, or `localhost` — is bound and anything else is |
| 211 | 211 | ignored with a line in the log rather than failing to resolve at start-up. |
| 212 | 212 | - `src/pages/shell.ts` — the document every game is served inside, and nothing |
| 213 | about any one game: `page({ title, description, heading, env, pull, commit, | |
| 214 | script, ssh, terminalLead, stageHtml, sectionsHtml, footer, extraStyle })` | |
| 213 | about any one game: `page({ title, description, heading, nav, env, pull, | |
| 214 | commit, script, ssh, terminalLead, stageHtml, sectionsHtml, footer, | |
| 215 | extraStyle })` | |
| 215 | 216 | gives you the doctype, the head, the one `<style>` block, the header with its |
| 216 | environment pill and short commit, `<main>`, the terminal line and the footer, | |
| 217 | nav of the games, its environment pill and short commit, `<main>`, the | |
| 218 | terminal line and the footer, | |
| 217 | 219 | and `stage({ overlays, touch })` gives you `#stage` — `#scene`, `#hud`, `#fps` |
| 218 | 220 | and the `#touch` pads built from `{ action, label }` buttons. Text parameters |
| 219 | 221 | (`title`, `heading`, `commit`, `ssh`, a label, an id) are escaped by the |
| 220 | 222 | shell; the ones that carry markup you already rendered say so in their name |
| 221 | 223 | (`stageHtml`, `sectionsHtml`, an overlay's `html`) and are written through. |
| 224 | **The nav is the shell's too**, because the header is: a page hands in | |
| 225 | `nav: { href, label, current? }[]` and the shell renders the games beside the | |
| 226 | heading — each an anchor at least 44px on a side, in the first paint and | |
| 227 | before `<main>`, with the current one drawn as text carrying | |
| 228 | `aria-current="page"` rather than a link to where you already are. The list | |
| 229 | itself lives once, in `src/pages/games.ts` (`GAMES` and `navFor(here)`), which | |
| 230 | both pages and the server's 404 read, so **a third game gets the nav by | |
| 231 | passing one more entry there** — and no page ever imports another page. | |
| 222 | 232 | A rule only one game needs goes in `extraStyle`, never in the shared block, |
| 223 | 233 | and CSS handed in there cannot close the block. So **a second game's page |
| 224 | 234 | writes its overlays, its touch buttons and its sections, and gets the rest**; |
| @@ -236,10 +246,11 @@ page shows is the loop and the controls themselves, as numbers that move. | ||
| 236 | 246 | game's rebuild never deletes another's, and `bun test` fails if any committed |
| 237 | 247 | bundle is stale — today that is `app` (the shooter) and `range`. The server |
| 238 | 248 | resolves the script for an entry by that exact name, so **adding a second game |
| 239 | is one line in `ENTRIES`, a `src/pages/<name>.ts` that calls the shell, and a | |
| 249 | is one line in `ENTRIES`, a `src/pages/<name>.ts` that calls the shell, a | |
| 240 | 250 | route in `src/server.ts`** that renders it with `scripts["<name>"]` as the |
| 241 | `script` — no inline script, the CSP is `default-src 'self'`. `/range` is that | |
| 242 | three-part recipe followed once. | |
| 251 | `script`, and one more entry in `src/pages/games.ts` so every header and the | |
| 252 | 404 link to it — no inline script, the CSP is `default-src 'self'`. `/range` | |
| 253 | is that recipe followed once. | |
| 243 | 254 | - **Every bundle has a ceiling.** Today, and `bun run scripts/build.ts` prints |
| 244 | 255 | these next to each file name: |
| 245 | 256 |
src/pages/game.ts +2
| @@ -13,6 +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 | 17 | import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| 17 | 18 | |
| 18 | 19 | export { escape }; |
| @@ -93,6 +94,7 @@ export function gamePage(db: Database, { who: visitor, ...options }: PageOptions | ||
| 93 | 94 | description: |
| 94 | 95 | "A Counter-Strike-shaped defusal shooter that runs in the browser: WebGL2, bots, bomb rounds and an economy.", |
| 95 | 96 | heading: "cs", |
| 97 | nav: navFor("/"), | |
| 96 | 98 | terminalLead: "The same leaderboard in a terminal:", |
| 97 | 99 | footer: "state lives in this environment's own database", |
| 98 | 100 | stageHtml: stage({ |
src/pages/games.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 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. | |
| 5 | * | |
| 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 | */ | |
| 9 | ||
| 10 | import type { NavEntry } from "./shell.ts"; | |
| 11 | ||
| 12 | /** One game: the path it answers on, and what the header calls it. */ | |
| 13 | export type Game = { href: string; label: string }; | |
| 14 | ||
| 15 | /** In the order the header lists them. */ | |
| 16 | export const GAMES: readonly Game[] = [ | |
| 17 | { href: "/", label: "cs" }, | |
| 18 | { href: "/range", label: "range" }, | |
| 19 | ]; | |
| 20 | ||
| 21 | /** | |
| 22 | * The nav a page at `here` hands the shell: every game, with its own entry | |
| 23 | * marked current — the shell renders that one as text rather than a link. | |
| 24 | */ | |
| 25 | export const navFor = (here: string): NavEntry[] => | |
| 26 | GAMES.map((game) => (game.href === here ? { ...game, current: true } : { ...game })); |
src/pages/range.ts +2
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | * the terminal stay Counter-Strike's. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { navFor } from "./games.ts"; | |
| 13 | 14 | import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| 14 | 15 | |
| 15 | 16 | /** What the shell needs that this page cannot know: the environment it runs in. */ |
| @@ -34,6 +35,7 @@ export function rangePage(options: PageOptions): string { | ||
| 34 | 35 | description: |
| 35 | 36 | "A shooting range built only from this app's engine. Today it is the loop, the controls and a readout of both.", |
| 36 | 37 | heading: "range", |
| 38 | nav: navFor("/range"), | |
| 37 | 39 | terminalLead: "The shooter's leaderboard in a terminal:", |
| 38 | 40 | footer: "no database, no cookie: this page keeps nothing", |
| 39 | 41 | stageHtml: stage({ |
src/pages/shell.test.ts +45 −1
| @@ -6,7 +6,8 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { expect, test } from "bun:test"; |
| 9 | import { escape, page, stage, terminal, where } from "./shell.ts"; | |
| 9 | import { escape, nav, page, stage, terminal, where } from "./shell.ts"; | |
| 10 | import { GAMES, navFor } from "./games.ts"; | |
| 10 | 11 | |
| 11 | 12 | const XSS = `</script><script>alert(1)</script>`; |
| 12 | 13 | |
| @@ -14,6 +15,7 @@ const options = { | ||
| 14 | 15 | title: "t", |
| 15 | 16 | description: "d", |
| 16 | 17 | heading: "h", |
| 18 | nav: [{ href: "/", label: "cs", current: true }, { href: "/range", label: "range" }], | |
| 17 | 19 | env: "local", |
| 18 | 20 | pull: undefined, |
| 19 | 21 | commit: "abc1234", |
| @@ -90,6 +92,48 @@ test("a game's own style is appended, and cannot close the block", () => { | ||
| 90 | 92 | expect(broken.match(/<script/g)?.length).toBe(1); |
| 91 | 93 | }); |
| 92 | 94 | |
| 95 | test("the header carries the games, and the one you are on is not a link", () => { | |
| 96 | const html = page(options); | |
| 97 | expect(html).toContain( | |
| 98 | `<nav aria-label="Games"><span class="here" aria-current="page">cs</span><a href="/range">range</a></nav>`, | |
| 99 | ); | |
| 100 | // One link out, not two: a link to the page you are already on is noise. | |
| 101 | expect(html).not.toContain(`<a href="/">cs</a>`); | |
| 102 | // In the header, before <main>: the keyboard reaches it first, and it is in | |
| 103 | // the first paint rather than added later, so nothing shifts. | |
| 104 | expect(html.indexOf("<nav")).toBeGreaterThan(html.indexOf("<h1>")); | |
| 105 | expect(html.indexOf("<nav")).toBeLessThan(html.indexOf("<main>")); | |
| 106 | expect(html.indexOf("<nav")).toBeLessThan(html.indexOf(`class="env`)); | |
| 107 | // Every target is at least 44px on a side, in the one style block. | |
| 108 | expect(html).toContain("header nav a, header nav .here"); | |
| 109 | expect(html).toContain("min-height:44px; min-width:44px"); | |
| 110 | expect(nav([])).toBe(""); | |
| 111 | }); | |
| 112 | ||
| 113 | test("a nav entry is text, wherever it enters", () => { | |
| 114 | const html = nav([{ href: `/a"><script>alert(1)</script>`, label: "cs & <b>co</b>" }, { | |
| 115 | href: "/b", | |
| 116 | label: XSS, | |
| 117 | current: true, | |
| 118 | }]); | |
| 119 | expect(html).not.toContain("<script>"); | |
| 120 | expect(html).not.toContain("<b>"); | |
| 121 | expect(html).toContain(`href="/a"><script>alert(1)</script>"`); | |
| 122 | expect(html).toContain(">cs & <b>co</b></a>"); | |
| 123 | expect(html).toContain( | |
| 124 | `<span class="here" aria-current="page"></script><script>alert(1)</script></span>`, | |
| 125 | ); | |
| 126 | }); | |
| 127 | ||
| 128 | test("the list of games lives once, and a page marks its own", () => { | |
| 129 | expect(GAMES.map((g) => g.href)).toEqual(["/", "/range"]); | |
| 130 | expect(navFor("/")).toEqual([{ href: "/", label: "cs", current: true }, { href: "/range", label: "range" }]); | |
| 131 | expect(navFor("/range")).toEqual([{ href: "/", label: "cs" }, { href: "/range", label: "range", current: true }]); | |
| 132 | // A path that is no game marks nothing, and still lists them all. | |
| 133 | expect(navFor("/nope").filter((e) => e.current === true)).toEqual([]); | |
| 134 | expect(navFor("/nope")).toHaveLength(GAMES.length); | |
| 135 | }); | |
| 136 | ||
| 93 | 137 | test("the stage keeps the ids the client finds it by", () => { |
| 94 | 138 | const html = stage({ |
| 95 | 139 | touch: [[{ action: "duck", label: "crouch" }], [{ action: "fire", label: "fire" }]], |
src/pages/shell.ts +41 −7
| @@ -1,15 +1,20 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The shell: the document every game on this server is served inside, and |
| 3 | 3 | * nothing about any one game. It owns the doctype, the head, the one style |
| 4 | * block, the header with its environment pill, `<main>`, the footer, the | |
| 5 | * terminal line and the single module script — a second game writes its stage | |
| 6 | * and its sections and gets the rest. | |
| 4 | * block, the header with its nav of the games and its environment pill, | |
| 5 | * `<main>`, the footer, the terminal line and the single module script — a | |
| 6 | * second game writes its stage and its sections and gets the rest. | |
| 7 | * | |
| 8 | * The nav is the shell's because the header is: a page hands in the games as | |
| 9 | * data (`nav`) and marks its own, and the shell decides what a link looks like | |
| 10 | * and where it sits. The list itself lives in `./games.ts`, which both pages | |
| 11 | * read, so no page imports another page. | |
| 7 | 12 | * |
| 8 | 13 | * Escaping is the contract at this boundary. A parameter that carries text is |
| 9 | 14 | * escaped here (`title`, `heading`, `env`, `commit`, `ssh`, a touch label, an |
| 10 | * id); a parameter that carries markup the caller already rendered says so in | |
| 11 | * its name (`stageHtml`, `sectionsHtml`, an overlay's `html`) and is written | |
| 12 | * through untouched. There is no inline script: the CSP is `default-src 'self'`. | |
| 15 | * id, a nav entry's href and label); a parameter that carries markup the | |
| 16 | * caller already rendered says so in its name (`stageHtml`, `sectionsHtml`, an | |
| 17 | * overlay's `html`) and is written through untouched. There is no inline script: the CSP is `default-src 'self'`. | |
| 13 | 18 | */ |
| 14 | 19 | |
| 15 | 20 | export const escape = (s: string): string => |
| @@ -25,8 +30,14 @@ const STYLE = ` | ||
| 25 | 30 | * { box-sizing: border-box; } |
| 26 | 31 | body { margin:0; background:var(--bg); color:var(--text); font:16px/1.55 system-ui,-apple-system,Segoe UI,sans-serif; } |
| 27 | 32 | header, main { max-width: 64rem; margin: 0 auto; padding: 0 1rem; } |
| 28 | header { display:flex; align-items:baseline; gap:.75rem; padding-top:1rem; flex-wrap:wrap; } | |
| 33 | header { display:flex; align-items:center; gap:.5rem .75rem; padding-top:1rem; flex-wrap:wrap; } | |
| 29 | 34 | h1 { font-size:1.4rem; margin:0; letter-spacing:.02em; } |
| 35 | header nav { display:flex; gap:.25rem; flex-wrap:wrap; margin-left:-.55rem; } | |
| 36 | header nav a, header nav .here { display:inline-flex; align-items:center; justify-content:center; | |
| 37 | min-height:44px; min-width:44px; padding:0 .55rem; border-radius:.45rem; font-size:.92rem; } | |
| 38 | header nav a { color:var(--ct); text-decoration:none; } | |
| 39 | header nav a:hover, header nav a:focus-visible { background:#1b2230; text-decoration:underline; } | |
| 40 | header nav .here { color:var(--text); font-weight:600; background:#1b2230; border:1px solid var(--line); } | |
| 30 | 41 | .env { font-size:.72rem; padding:.15rem .5rem; border-radius:999px; background:#1d4ed8; color:#fff; } |
| 31 | 42 | .env.production { background:#15803d; } |
| 32 | 43 | small.commit { color:var(--dim); font-size:.75rem; } |
| @@ -118,12 +129,34 @@ export function terminal(ssh: string | undefined, lead: string): string { | ||
| 118 | 129 | export const where = (env: string, pull: string | undefined): string => |
| 119 | 130 | env === "preview" ? `preview of pull #${pull ?? "?"}` : env; |
| 120 | 131 | |
| 132 | /** | |
| 133 | * One game in the header's nav. `href` and `label` are text, escaped here; the | |
| 134 | * entry a page marks `current` is rendered as text with `aria-current="page"` | |
| 135 | * rather than as a link, because a link to the page you are on is noise. | |
| 136 | */ | |
| 137 | export type NavEntry = { href: string; label: string; current?: boolean }; | |
| 138 | ||
| 139 | /** The header's nav: anchors, in the first paint, before `<main>`. */ | |
| 140 | export function nav(entries: NavEntry[]): string { | |
| 141 | if (entries.length === 0) return ""; | |
| 142 | const items = entries | |
| 143 | .map((e) => | |
| 144 | e.current === true | |
| 145 | ? `<span class="here" aria-current="page">${escape(e.label)}</span>` | |
| 146 | : `<a href="${escape(e.href)}">${escape(e.label)}</a>`, | |
| 147 | ) | |
| 148 | .join(""); | |
| 149 | return `<nav aria-label="Games">${items}</nav>`; | |
| 150 | } | |
| 151 | ||
| 121 | 152 | export type PageOptions = { |
| 122 | 153 | /** Text. The `<title>`, and the `<meta name=description>`. */ |
| 123 | 154 | title: string; |
| 124 | 155 | description: string; |
| 125 | 156 | /** Text. The `<h1>`, and the first word of the footer. */ |
| 126 | 157 | heading: string; |
| 158 | /** Every game on this server, this page's own marked `current`. */ | |
| 159 | nav: NavEntry[]; | |
| 127 | 160 | env: string; |
| 128 | 161 | pull: string | undefined; |
| 129 | 162 | commit: string; |
| @@ -154,6 +187,7 @@ export function page(options: PageOptions): string { | ||
| 154 | 187 | <style>${STYLE}${options.extraStyle === undefined ? "" : cssSafe(options.extraStyle)}</style> |
| 155 | 188 | <header> |
| 156 | 189 | <h1>${escape(options.heading)}</h1> |
| 190 | ${nav(options.nav)} | |
| 157 | 191 | <span class="env ${options.env === "production" ? "production" : ""}">${escape(here)}</span> |
| 158 | 192 | ${options.commit ? `<small class="commit">@ ${escape(options.commit)}</small>` : ""} |
| 159 | 193 | </header> |
src/server.test.ts +31
| @@ -88,6 +88,37 @@ test("the two pages link to each other", async () => { | ||
| 88 | 88 | expect(await (await fetch(base + "/range")).text()).toContain(`href="/"`); |
| 89 | 89 | }); |
| 90 | 90 | |
| 91 | test("every page's header carries the other game and marks its own", async () => { | |
| 92 | // The nav is the shell's, so this is the pair checked from the outside: on | |
| 93 | // each page the other game is one click away and this one says it is here. | |
| 94 | const game = await (await fetch(base + "/")).text(); | |
| 95 | expect(game).toContain(`<nav aria-label="Games">`); | |
| 96 | expect(game).toContain(`<span class="here" aria-current="page">cs</span>`); | |
| 97 | expect(game).toContain(`<a href="/range">range</a>`); | |
| 98 | ||
| 99 | const range = await (await fetch(base + "/range")).text(); | |
| 100 | expect(range).toContain(`<nav aria-label="Games">`); | |
| 101 | expect(range).toContain(`<span class="here" aria-current="page">range</span>`); | |
| 102 | expect(range).toContain(`<a href="/">cs</a>`); | |
| 103 | ||
| 104 | // Header first, and no second script to add it later: it is the first paint. | |
| 105 | for (const body of [game, range]) { | |
| 106 | expect(body.indexOf("<nav")).toBeLessThan(body.indexOf("<main>")); | |
| 107 | expect(body.match(/<script/g)?.length).toBe(1); | |
| 108 | } | |
| 109 | }); | |
| 110 | ||
| 111 | test("the 404 names both games instead of only the front page", async () => { | |
| 112 | const response = await fetch(base + "/nope"); | |
| 113 | expect(response.status).toBe(404); | |
| 114 | const body = await response.text(); | |
| 115 | expect(body).toContain("Not found"); | |
| 116 | expect(body).toContain(`<a href="/">cs</a>`); | |
| 117 | expect(body).toContain(`<a href="/range">range</a>`); | |
| 118 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 119 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 120 | }); | |
| 121 | ||
| 91 | 122 | test("the range stands on the same security floor as the game", async () => { |
| 92 | 123 | const [game, range] = await Promise.all([fetch(base + "/"), fetch(base + "/range")]); |
| 93 | 124 | for (const header of ["content-security-policy", "x-content-type-options", "referrer-policy", |
src/server.ts +10 −1
| @@ -18,6 +18,7 @@ import { ENTRIES, isBundleOf, STATIC_DIR } from "../scripts/build.ts"; | ||
| 18 | 18 | import { bind } from "./bind.ts"; |
| 19 | 19 | import { openDatabase, type Player, saveMatch } from "./db.ts"; |
| 20 | 20 | import { createLimiter } from "./limit.ts"; |
| 21 | import { GAMES } from "./pages/games.ts"; | |
| 21 | 22 | import { escape, gamePage, statsHtml } from "./pages/game.ts"; |
| 22 | 23 | import { rangePage } from "./pages/range.ts"; |
| 23 | 24 | import { cleanName, parseResult } from "./result.ts"; |
| @@ -176,6 +177,14 @@ const scripts = findScripts(); | ||
| 176 | 177 | const script = scripts["app"] ?? "/static/app.js"; |
| 177 | 178 | const rangeScript = scripts["range"] ?? "/static/range.js"; |
| 178 | 179 | |
| 180 | // 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>`; | |
| 187 | ||
| 179 | 188 | // HEAD is GET without the body: the headers, the status and nothing to read. |
| 180 | 189 | const withoutBodyIfHead = (request: Request, response: Response): Response => |
| 181 | 190 | request.method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response; |
| @@ -243,7 +252,7 @@ export const server = Bun.serve({ | ||
| 243 | 252 | }); |
| 244 | 253 | } |
| 245 | 254 | |
| 246 | return html(`<h1>Not found</h1><p><a href="/">Back to ${escape("cs")}</a></p>`, 404); | |
| 255 | return html(NOT_FOUND, 404); | |
| 247 | 256 | }, |
| 248 | 257 | // A bug answers with the floor and nothing else: never the runtime's own |
| 249 | 258 | // error page, which carries source and paths, and never without the |
new above
offline