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

Merged opened this

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

Merged

vesper/cs-engine:vesper/33-both-games-are-one-click-apart-the-shell → main 2 commits fork

  • Merged. vesper/33-both-games-are-one-click-apart-the-shell was merged into main as 9a67334.

Review

Sign in to review this change.
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.
210210 an IPv4 or IPv6 literal, or `localhost` — is bound and anything else is
211211 ignored with a line in the log rather than failing to resolve at start-up.
212212 - `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 })`
215216 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,
217219 and `stage({ overlays, touch })` gives you `#stage` — `#scene`, `#hud`, `#fps`
218220 and the `#touch` pads built from `{ action, label }` buttons. Text parameters
219221 (`title`, `heading`, `commit`, `ssh`, a label, an id) are escaped by the
220222 shell; the ones that carry markup you already rendered say so in their name
221223 (`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.
222232 A rule only one game needs goes in `extraStyle`, never in the shared block,
223233 and CSS handed in there cannot close the block. So **a second game's page
224234 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.
236246 game's rebuild never deletes another's, and `bun test` fails if any committed
237247 bundle is stale — today that is `app` (the shooter) and `range`. The server
238248 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
240250 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.
243254 - **Every bundle has a ceiling.** Today, and `bun run scripts/build.ts` prints
244255 these next to each file name:
245256
src/pages/game.ts +2
@@ -13,6 +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";
1617 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
1718
1819 export { escape };
@@ -93,6 +94,7 @@ export function gamePage(db: Database, { who: visitor, ...options }: PageOptions
9394 description:
9495 "A Counter-Strike-shaped defusal shooter that runs in the browser: WebGL2, bots, bomb rounds and an economy.",
9596 heading: "cs",
97 nav: navFor("/"),
9698 terminalLead: "The same leaderboard in a terminal:",
9799 footer: "state lives in this environment's own database",
98100 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 @@
1010 * the terminal stay Counter-Strike's.
1111 */
1212
13 import { navFor } from "./games.ts";
1314 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
1415
1516 /** What the shell needs that this page cannot know: the environment it runs in. */
@@ -34,6 +35,7 @@ export function rangePage(options: PageOptions): string {
3435 description:
3536 "A shooting range built only from this app's engine. Today it is the loop, the controls and a readout of both.",
3637 heading: "range",
38 nav: navFor("/range"),
3739 terminalLead: "The shooter's leaderboard in a terminal:",
3840 footer: "no database, no cookie: this page keeps nothing",
3941 stageHtml: stage({
src/pages/shell.test.ts +45 −1
@@ -6,7 +6,8 @@
66 */
77
88 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";
1011
1112 const XSS = `</script><script>alert(1)</script>`;
1213
@@ -14,6 +15,7 @@ const options = {
1415 title: "t",
1516 description: "d",
1617 heading: "h",
18 nav: [{ href: "/", label: "cs", current: true }, { href: "/range", label: "range" }],
1719 env: "local",
1820 pull: undefined,
1921 commit: "abc1234",
@@ -90,6 +92,48 @@ test("a game's own style is appended, and cannot close the block", () => {
9092 expect(broken.match(/<script/g)?.length).toBe(1);
9193 });
9294
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&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;"`);
122 expect(html).toContain(">cs &amp; &lt;b&gt;co&lt;/b&gt;</a>");
123 expect(html).toContain(
124 `<span class="here" aria-current="page">&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;</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
93137 test("the stage keeps the ids the client finds it by", () => {
94138 const html = stage({
95139 touch: [[{ action: "duck", label: "crouch" }], [{ action: "fire", label: "fire" }]],
src/pages/shell.ts +41 −7
@@ -1,15 +1,20 @@
11 /**
22 * The shell: the document every game on this server is served inside, and
33 * 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.
712 *
813 * Escaping is the contract at this boundary. A parameter that carries text is
914 * 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'`.
1318 */
1419
1520 export const escape = (s: string): string =>
@@ -25,8 +30,14 @@ const STYLE = `
2530 * { box-sizing: border-box; }
2631 body { margin:0; background:var(--bg); color:var(--text); font:16px/1.55 system-ui,-apple-system,Segoe UI,sans-serif; }
2732 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; }
2934 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); }
3041 .env { font-size:.72rem; padding:.15rem .5rem; border-radius:999px; background:#1d4ed8; color:#fff; }
3142 .env.production { background:#15803d; }
3243 small.commit { color:var(--dim); font-size:.75rem; }
@@ -118,12 +129,34 @@ export function terminal(ssh: string | undefined, lead: string): string {
118129 export const where = (env: string, pull: string | undefined): string =>
119130 env === "preview" ? `preview of pull #${pull ?? "?"}` : env;
120131
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
121152 export type PageOptions = {
122153 /** Text. The `<title>`, and the `<meta name=description>`. */
123154 title: string;
124155 description: string;
125156 /** Text. The `<h1>`, and the first word of the footer. */
126157 heading: string;
158 /** Every game on this server, this page's own marked `current`. */
159 nav: NavEntry[];
127160 env: string;
128161 pull: string | undefined;
129162 commit: string;
@@ -154,6 +187,7 @@ export function page(options: PageOptions): string {
154187 <style>${STYLE}${options.extraStyle === undefined ? "" : cssSafe(options.extraStyle)}</style>
155188 <header>
156189 <h1>${escape(options.heading)}</h1>
190 ${nav(options.nav)}
157191 <span class="env ${options.env === "production" ? "production" : ""}">${escape(here)}</span>
158192 ${options.commit ? `<small class="commit">@ ${escape(options.commit)}</small>` : ""}
159193 </header>
src/server.test.ts +31
@@ -88,6 +88,37 @@ test("the two pages link to each other", async () => {
8888 expect(await (await fetch(base + "/range")).text()).toContain(`href="/"`);
8989 });
9090
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
91122 test("the range stands on the same security floor as the game", async () => {
92123 const [game, range] = await Promise.all([fetch(base + "/"), fetch(base + "/range")]);
93124 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";
1818 import { bind } from "./bind.ts";
1919 import { openDatabase, type Player, saveMatch } from "./db.ts";
2020 import { createLimiter } from "./limit.ts";
21 import { GAMES } from "./pages/games.ts";
2122 import { escape, gamePage, statsHtml } from "./pages/game.ts";
2223 import { rangePage } from "./pages/range.ts";
2324 import { cleanName, parseResult } from "./result.ts";
@@ -176,6 +177,14 @@ const scripts = findScripts();
176177 const script = scripts["app"] ?? "/static/app.js";
177178 const rangeScript = scripts["range"] ?? "/static/range.js";
178179
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
179188 // HEAD is GET without the body: the headers, the status and nothing to read.
180189 const withoutBodyIfHead = (request: Request, response: Response): Response =>
181190 request.method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
@@ -243,7 +252,7 @@ export const server = Bun.serve({
243252 });
244253 }
245254
246 return html(`<h1>Not found</h1><p><a href="/">Back to ${escape("cs")}</a></p>`, 404);
255 return html(NOT_FOUND, 404);
247256 },
248257 // A bug answers with the floor and nothing else: never the runtime's own
249258 // error page, which carries source and paths, and never without the

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs