Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes · 4 behind
One page shell serves both games: the document, the style and the stage move to src/pages/shell.ts #21
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/17-one-page-shell-serves-both-games-the-doc → main 1 commit fork
5 files
changed +385
−113
README.md +21 −6
| @@ -76,17 +76,32 @@ the rest. | ||
| 76 | 76 | the algorithm, with its backward pass checked against finite differences. |
| 77 | 77 | `scripts/train.ts` runs them and writes `src/client/weights.ts`. |
| 78 | 78 | - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the |
| 79 | schema and its queries, `src/result.ts` validates what a browser may claim, | |
| 80 | `src/pages/game.ts` renders the page. | |
| 79 | schema and its queries, `src/result.ts` validates what a browser may claim. | |
| 80 | - `src/pages/shell.ts` — the document every game is served inside, and nothing | |
| 81 | about any one game: `page({ title, description, heading, env, pull, commit, | |
| 82 | script, ssh, terminalLead, stageHtml, sectionsHtml, footer, extraStyle })` | |
| 83 | gives you the doctype, the head, the one `<style>` block, the header with its | |
| 84 | environment pill and short commit, `<main>`, the terminal line and the footer, | |
| 85 | and `stage({ overlays, touch })` gives you `#stage` — `#scene`, `#hud`, `#fps` | |
| 86 | and the `#touch` pads built from `{ action, label }` buttons. Text parameters | |
| 87 | (`title`, `heading`, `commit`, `ssh`, a label, an id) are escaped by the | |
| 88 | shell; the ones that carry markup you already rendered say so in their name | |
| 89 | (`stageHtml`, `sectionsHtml`, an overlay's `html`) and are written through. | |
| 90 | A rule only one game needs goes in `extraStyle`, never in the shared block, | |
| 91 | and CSS handed in there cannot close the block. So **a second game's page | |
| 92 | writes its overlays, its touch buttons and its sections, and gets the rest**; | |
| 93 | `src/pages/game.ts` is what that looks like for cs. The ids are the contract | |
| 94 | with `src/client/main.ts`, which finds them by `getElementById`: a test in | |
| 95 | `src/server.test.ts` pins every one against the served page. | |
| 81 | 96 | - `scripts/build.ts` bundles a table of entries — `ENTRIES`, one line per game: |
| 82 | 97 | a name and a source file — into `static/<name>.<hash>.js`, committed and |
| 83 | 98 | cached for a year. Each entry sweeps up only its own stale bundles, so one |
| 84 | 99 | game's rebuild never deletes another's, and `bun test` fails if any committed |
| 85 | 100 | bundle is stale. The server resolves the script for an entry by that exact |
| 86 | name, so **adding a second game is one line in `ENTRIES` plus a route in | |
| 87 | `src/server.ts`** that renders its page with `scripts["<name>"]` in the | |
| 88 | `<script type="module" src>` — no inline script, the CSP is `default-src | |
| 89 | 'self'`. | |
| 101 | name, so **adding a second game is one line in `ENTRIES`, a | |
| 102 | `src/pages/<name>.ts` that calls the shell, and a route in `src/server.ts`** | |
| 103 | that renders it with `scripts["<name>"]` as the `script` — no inline script, | |
| 104 | the CSP is `default-src 'self'`. | |
| 90 | 105 | - No dependencies. Nothing is installed to run this. |
| 91 | 106 | |
| 92 | 107 | It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app |
src/pages/game.ts +48 −107
| @@ -1,15 +1,20 @@ | ||
| 1 | 1 | /** |
| 2 | * The page. Server-rendered: the leaderboard and the instructions are in the | |
| 3 | * first paint, and the only script is the game bundle, fetched once and cached | |
| 4 | * by its content hash. | |
| 2 | * The cs page: this game's content and nothing about the document. The shell in | |
| 3 | * `./shell.ts` renders the doctype, the style, the header, the stage and the | |
| 4 | * footer; what is left here is Counter-Strike's — its overlays, its touch | |
| 5 | * buttons, its leaderboard and its prose. | |
| 6 | * | |
| 7 | * Server-rendered: the leaderboard and the instructions are in the first paint, | |
| 8 | * and the only script is the game bundle, fetched once and cached by its | |
| 9 | * content hash. | |
| 5 | 10 | */ |
| 6 | 11 | |
| 7 | 12 | import type { Database } from "bun:sqlite"; |
| 8 | 13 | import { headshotShare, ratio, recentMatches, standings } from "../db.ts"; |
| 9 | 14 | import { CARD } from "../client/weights.ts"; |
| 15 | import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts"; | |
| 10 | 16 | |
| 11 | export const escape = (s: string): string => | |
| 12 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); | |
| 17 | export { escape }; | |
| 13 | 18 | |
| 14 | 19 | /** The leaderboard block. Also what POST /api/match answers with. */ |
| 15 | 20 | export function statsHtml(db: Database): string { |
| @@ -36,94 +41,10 @@ ${table.length === 0 | ||
| 36 | 41 | <ul class="recent">${feed || `<li class="empty">nothing yet</li>`}</ul>`; |
| 37 | 42 | } |
| 38 | 43 | |
| 39 | const STYLE = ` | |
| 40 | :root { color-scheme: dark; --bg:b0d11; --panel:41821; --line:32a36; --text:; --dim:aa4b4; --t:; --ct:fa8e8; } | |
| 41 | * { box-sizing: border-box; } | |
| 42 | body { margin:0; background:var(--bg); color:var(--text); font:16px/1.55 system-ui,-apple-system,Segoe UI,sans-serif; } | |
| 43 | header, main { max-width: 64rem; margin: 0 auto; padding: 0 1rem; } | |
| 44 | header { display:flex; align-items:baseline; gap:.75rem; padding-top:1rem; flex-wrap:wrap; } | |
| 45 | h1 { font-size:1.4rem; margin:0; letter-spacing:.02em; } | |
| 46 | .env { font-size:.72rem; padding:.15rem .5rem; border-radius:999px; background:d4ed8; color:; } | |
| 47 | .env.production { background:5803d; } | |
| 48 | small.commit { color:var(--dim); font-size:.75rem; } | |
| 49 | { position:relative; width:100%; aspect-ratio:16/9; max-height:78vh; margin:1rem auto; background:00; | |
| 50 | border:1px solid var(--line); border-radius:.6rem; overflow:hidden; touch-action:none; } | |
| 51 | , { position:absolute; inset:0; width:100%; height:100%; display:block; } | |
| 52 | { pointer-events:none; } | |
| 53 | .overlay { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; | |
| 54 | gap:.75rem; background:rgba(8,10,14,.82); text-align:center; padding:1rem; overflow:auto; } | |
| 55 | .overlay[hidden] { display:none; } | |
| 56 | .overlay h2 { margin:0; font-size:1.3rem; } | |
| 57 | .overlay p { margin:.2rem 0; color:var(--dim); max-width:34rem; } | |
| 58 | .row { display:flex; gap:.5rem; flex-wrap:wrap; justify-content:center; align-items:center; } | |
| 59 | label { display:flex; flex-direction:column; gap:.25rem; font-size:.8rem; color:var(--dim); text-align:left; } | |
| 60 | input, select { min-height:44px; padding:.5rem .7rem; font:inherit; color:var(--text); | |
| 61 | background:f131a; border:1px solid var(--line); border-radius:.45rem; } | |
| 62 | button { min-height:44px; min-width:44px; padding:.6rem 1.1rem; font:inherit; border:1px solid var(--line); | |
| 63 | border-radius:.45rem; background:b2230; color:var(--text); cursor:pointer; } | |
| 64 | button:hover:not(:disabled) { background:7303f; } | |
| 65 | button:disabled { opacity:.42; cursor:not-allowed; } | |
| 66 | button.primary { background:563eb; border-color:563eb; font-weight:600; } | |
| 67 | .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(11rem,1fr)); gap:.5rem; width:min(40rem,100%); } | |
| 68 | .hint, .overlay .hint { font-size:.78rem; } | |
| 69 | { position:absolute; right:.5rem; bottom:.4rem; font-size:.7rem; color:ea; opacity:.7; pointer-events:none; } | |
| 70 | { position:absolute; inset:auto 0 0 0; display:flex; justify-content:space-between; padding:.6rem; pointer-events:none; } | |
| 71 | [hidden] { display:none; } | |
| 72 | .pad { display:flex; gap:.4rem; flex-wrap:wrap; pointer-events:auto; } | |
| 73 | button { opacity:.72; background:f131acc; } | |
| 74 | main section { margin:1.5rem 0; } | |
| 75 | table { width:100%; border-collapse:collapse; font-size:.9rem; } | |
| 76 | th, td { text-align:left; padding:.35rem .5rem; border-bottom:1px solid var(--line); } | |
| 77 | th { color:var(--dim); font-weight:500; } | |
| 78 | ul.recent { list-style:none; padding:0; margin:0; font-size:.86rem; } | |
| 79 | ul.recent li { padding:.35rem 0; border-bottom:1px solid var(--line); display:flex; gap:.5rem; flex-wrap:wrap; } | |
| 80 | ul.recent span, ul.recent time, .empty { color:var(--dim); } | |
| 81 | kbd { background:b2230; border:1px solid var(--line); border-radius:.25rem; padding:0 .3rem; font-size:.82rem; } | |
| 82 | footer { color:var(--dim); font-size:.8rem; padding:1rem; text-align:center; } | |
| 83 | code { background:41821; padding:.1rem .35rem; border-radius:.25rem; } | |
| 84 | (max-width: 640px) { { aspect-ratio:4/3; } h1 { font-size:1.2rem; } } | |
| 85 | `; | |
| 44 | /** What the shell needs that this page cannot know: the environment it runs in. */ | |
| 45 | export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">; | |
| 86 | 46 | |
| 87 | export type PageOptions = { | |
| 88 | env: string; | |
| 89 | pull: string | undefined; | |
| 90 | commit: string; | |
| 91 | script: string; | |
| 92 | ssh: string | undefined; | |
| 93 | }; | |
| 94 | ||
| 95 | export function gamePage(db: Database, options: PageOptions): string { | |
| 96 | const where = options.env === "preview" ? `preview of pull #${options.pull ?? "?"}` : options.env; | |
| 97 | return `<!doctype html> | |
| 98 | <html lang="en"> | |
| 99 | <meta charset="utf-8"> | |
| 100 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> | |
| 101 | <title>cs — a browser defusal shooter</title> | |
| 102 | <meta name="description" content="A Counter-Strike-shaped defusal shooter that runs in the browser: WebGL2, bots, bomb rounds and an economy."> | |
| 103 | <style>${STYLE}</style> | |
| 104 | <header> | |
| 105 | <h1>cs</h1> | |
| 106 | <span class="env ${options.env === "production" ? "production" : ""}">${escape(where)}</span> | |
| 107 | ${options.commit ? `<small class="commit">@ ${escape(options.commit)}</small>` : ""} | |
| 108 | </header> | |
| 109 | <main> | |
| 110 | <div id="stage"> | |
| 111 | <canvas id="scene"></canvas> | |
| 112 | <canvas id="hud"></canvas> | |
| 113 | <div id="fps"></div> | |
| 114 | <div id="touch" hidden> | |
| 115 | <div class="pad"> | |
| 116 | <button type="button" data-action="duck">crouch</button> | |
| 117 | <button type="button" data-action="jump">jump</button> | |
| 118 | <button type="button" data-action="use">use</button> | |
| 119 | </div> | |
| 120 | <div class="pad"> | |
| 121 | <button type="button" data-action="reload">reload</button> | |
| 122 | <button type="button" data-action="buy">buy</button> | |
| 123 | <button type="button" data-action="fire">fire</button> | |
| 124 | </div> | |
| 125 | </div> | |
| 126 | <div class="overlay" id="menu"> | |
| 47 | const MENU = ` | |
| 127 | 48 | <h2>Defuse or detonate</h2> |
| 128 | 49 | <p>Five against five on <b>de_forge</b>. Bomb rounds, CS movement and economy, bots that push the sites.</p> |
| 129 | 50 | <div class="row"> |
| @@ -136,15 +57,42 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 136 | 57 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
| 137 | 58 | mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy · |
| 138 | 59 | <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>Esc</kbd> pause</p> |
| 139 | </div> | |
| 140 | <div class="overlay" id="pause" hidden> | |
| 60 | `; | |
| 61 | ||
| 62 | const PAUSE = ` | |
| 141 | 63 | <h2>Paused</h2> |
| 142 | 64 | <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div> |
| 143 | </div> | |
| 144 | <div class="overlay" id="buy" hidden></div> | |
| 145 | </div> | |
| 65 | `; | |
| 146 | 66 | |
| 147 | <section id="stats">${statsHtml(db)}</section> | |
| 67 | export function gamePage(db: Database, options: PageOptions): string { | |
| 68 | return page({ | |
| 69 | ...options, | |
| 70 | title: "cs — a browser defusal shooter", | |
| 71 | description: | |
| 72 | "A Counter-Strike-shaped defusal shooter that runs in the browser: WebGL2, bots, bomb rounds and an economy.", | |
| 73 | heading: "cs", | |
| 74 | terminalLead: "The same leaderboard in a terminal:", | |
| 75 | footer: "state lives in this environment's own database", | |
| 76 | stageHtml: stage({ | |
| 77 | touch: [ | |
| 78 | [ | |
| 79 | { action: "duck", label: "crouch" }, | |
| 80 | { action: "jump", label: "jump" }, | |
| 81 | { action: "use", label: "use" }, | |
| 82 | ], | |
| 83 | [ | |
| 84 | { action: "reload", label: "reload" }, | |
| 85 | { action: "buy", label: "buy" }, | |
| 86 | { action: "fire", label: "fire" }, | |
| 87 | ], | |
| 88 | ], | |
| 89 | overlays: [ | |
| 90 | { id: "menu", html: MENU }, | |
| 91 | { id: "pause", html: PAUSE, hidden: true }, | |
| 92 | { id: "buy", html: "", hidden: true }, | |
| 93 | ], | |
| 94 | }), | |
| 95 | sectionsHtml: ` <section id="stats">${statsHtml(db)}</section> | |
| 148 | 96 | |
| 149 | 97 | <section> |
| 150 | 98 | <h2>What this is</h2> |
| @@ -164,13 +112,6 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 164 | 112 | takes to react, so there is still a ladder from fodder to uncomfortable.</p> |
| 165 | 113 | <p>The match runs entirely in your browser; the leaderboard here is what your browser |
| 166 | 114 | reported about it.</p> |
| 167 | <p>${options.ssh === undefined | |
| 168 | ? "The app's terminal is off." | |
| 169 | : `The same leaderboard in a terminal: <code>${escape(options.ssh)}</code>`}</p> | |
| 170 | </section> | |
| 171 | </main> | |
| 172 | <footer>cs · ${escape(where)} · state lives in this environment's own database</footer> | |
| 173 | <script type="module" src="${escape(options.script)}"></script> | |
| 174 | </html> | |
| 175 | `; | |
| 115 | </section>`, | |
| 116 | }); | |
| 176 | 117 | } |
src/pages/shell.test.ts added +133
| @@ -0,0 +1,133 @@ | ||
| 1 | /** | |
| 2 | * The shell is where text becomes markup, so this reads like a hand check: the | |
| 3 | * same hostile string goes in at every text parameter and the assertion is that | |
| 4 | * it comes out inert, while markup handed in as markup is written through | |
| 5 | * untouched. | |
| 6 | */ | |
| 7 | ||
| 8 | import { expect, test } from "bun:test"; | |
| 9 | import { escape, page, stage, terminal, where } from "./shell.ts"; | |
| 10 | ||
| 11 | const XSS = `</script><script>alert(1)</script>`; | |
| 12 | ||
| 13 | const options = { | |
| 14 | title: "t", | |
| 15 | description: "d", | |
| 16 | heading: "h", | |
| 17 | env: "local", | |
| 18 | pull: undefined, | |
| 19 | commit: "abc1234", | |
| 20 | script: "/static/app.0123456789.js", | |
| 21 | ssh: undefined, | |
| 22 | terminalLead: "lead:", | |
| 23 | stageHtml: "", | |
| 24 | sectionsHtml: "", | |
| 25 | footer: "f", | |
| 26 | }; | |
| 27 | ||
| 28 | test("the document is a document, and the only script is the bundle", () => { | |
| 29 | const html = page(options); | |
| 30 | expect(html.startsWith("<!doctype html>\n<html lang=\"en\">\n")).toBe(true); | |
| 31 | expect(html).toContain(`<meta charset="utf-8">`); | |
| 32 | expect(html).toContain(`content="width=device-width, initial-scale=1, viewport-fit=cover"`); | |
| 33 | expect(html).toContain("<title>t</title>"); | |
| 34 | expect(html).toContain(`<meta name="description" content="d">`); | |
| 35 | expect(html).toContain("<h1>h</h1>"); | |
| 36 | expect(html).toContain(`<small class="commit">@ abc1234</small>`); | |
| 37 | expect(html).toContain("<main>"); | |
| 38 | expect(html).toContain(`<script type="module" src="/static/app.0123456789.js"></script>`); | |
| 39 | // One script element, and it has a src: the CSP is default-src 'self'. | |
| 40 | expect(html.match(/<script/g)?.length).toBe(1); | |
| 41 | expect(html.match(/<style/g)?.length).toBe(1); | |
| 42 | }); | |
| 43 | ||
| 44 | test("the environment pill says which deployment this is", () => { | |
| 45 | expect(where("production", undefined)).toBe("production"); | |
| 46 | expect(where("preview", "17")).toBe("preview of pull #17"); | |
| 47 | expect(where("preview", undefined)).toBe("preview of pull #?"); | |
| 48 | expect(page({ ...options, env: "production" })).toContain(`<span class="env production">production</span>`); | |
| 49 | expect(page({ ...options, env: "preview", pull: "17" })).toContain(`preview of pull #17</span>`); | |
| 50 | expect(page({ ...options, commit: "" })).not.toContain("class=\"commit\""); | |
| 51 | }); | |
| 52 | ||
| 53 | test("text that carries markup comes out inert, wherever it enters", () => { | |
| 54 | const html = page({ | |
| 55 | ...options, | |
| 56 | title: XSS, | |
| 57 | description: XSS, | |
| 58 | heading: XSS, | |
| 59 | env: XSS, | |
| 60 | commit: XSS, | |
| 61 | ssh: XSS, | |
| 62 | footer: XSS, | |
| 63 | script: XSS, | |
| 64 | }); | |
| 65 | // The one script element is still the only one: nothing opened a second. | |
| 66 | expect(html.match(/<script/g)?.length).toBe(1); | |
| 67 | expect(html).not.toContain("<script>alert(1)</script>"); | |
| 68 | expect(html).not.toContain("</script><script>"); | |
| 69 | // Ten, not eight: the heading and the environment are each printed twice, | |
| 70 | // once in the header and once in the footer. | |
| 71 | expect(html.split("</script><script>alert(1)</script>").length - 1).toBe(10); | |
| 72 | }); | |
| 73 | ||
| 74 | test("markup handed in as markup is written through, not escaped again", () => { | |
| 75 | const stats = `<table><tr><td>Vesper & co</td></tr></table>`; | |
| 76 | const html = page({ ...options, sectionsHtml: `<section id="stats">${stats}</section>`, stageHtml: "<div id=\"stage\"></div>" }); | |
| 77 | expect(html).toContain(stats); | |
| 78 | expect(html).toContain("<div id=\"stage\"></div>"); | |
| 79 | expect(html).not.toContain("<table>"); | |
| 80 | expect(html).not.toContain("&amp;"); | |
| 81 | }); | |
| 82 | ||
| 83 | test("a game's own style is appended, and cannot close the block", () => { | |
| 84 | const html = page({ ...options, extraStyle: "#board { color: red; }" }); | |
| 85 | expect(html).toContain("#board { color: red; }"); | |
| 86 | expect(html.match(/<style/g)?.length).toBe(1); | |
| 87 | const broken = page({ ...options, extraStyle: "}</style><script>alert(1)</script>" }); | |
| 88 | expect(broken).not.toContain("</style><script>"); | |
| 89 | expect(broken).toContain("\\3c /style>"); | |
| 90 | expect(broken.match(/<script/g)?.length).toBe(1); | |
| 91 | }); | |
| 92 | ||
| 93 | test("the stage keeps the ids the client finds it by", () => { | |
| 94 | const html = stage({ | |
| 95 | touch: [[{ action: "duck", label: "crouch" }], [{ action: "fire", label: "fire" }]], | |
| 96 | overlays: [{ id: "menu", html: "<h2>go</h2>" }, { id: "pause", html: "", hidden: true }], | |
| 97 | }); | |
| 98 | expect(html).toContain(`<div id="stage">`); | |
| 99 | expect(html).toContain(`<canvas id="scene"></canvas>`); | |
| 100 | expect(html).toContain(`<canvas id="hud"></canvas>`); | |
| 101 | expect(html).toContain(`<div id="fps"></div>`); | |
| 102 | expect(html).toContain(`<div id="touch" hidden>`); | |
| 103 | expect(html.match(/<div class="pad">/g)?.length).toBe(2); | |
| 104 | expect(html).toContain(`<button type="button" data-action="duck">crouch</button>`); | |
| 105 | expect(html).toContain(`<div class="overlay" id="menu"><h2>go</h2></div>`); | |
| 106 | expect(html).toContain(`<div class="overlay" id="pause" hidden></div>`); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a touch label and an action are text, not markup", () => { | |
| 110 | const html = stage({ touch: [[{ action: XSS, label: XSS }]], overlays: [{ id: XSS, html: "" }] }); | |
| 111 | expect(html).not.toContain("<script>"); | |
| 112 | const inert = "</script><script>alert(1)</script>"; | |
| 113 | // The label as text, the action inside an attribute, the id inside an | |
| 114 | // attribute: three places, one escaping, no quote or angle bracket survives. | |
| 115 | expect(html).toContain(`<button type="button" data-action="${inert}">${inert}</button>`); | |
| 116 | expect(html).toContain(`<div class="overlay" id="${inert}">`); | |
| 117 | expect(escape(`a" onclick="x`)).toBe("a" onclick="x"); | |
| 118 | }); | |
| 119 | ||
| 120 | test("the terminal line prints FORGE_APP_SSH verbatim, or says the terminal is off", () => { | |
| 121 | expect(terminal("ssh -p 2222 [email protected]", "reach it with:")) | |
| 122 | .toBe("reach it with: <code>ssh -p 2222 [email protected]</code>"); | |
| 123 | expect(terminal(undefined, "reach it with:")).toBe("The app's terminal is off."); | |
| 124 | expect(terminal(undefined, "x")).not.toContain("@"); | |
| 125 | expect(page({ ...options, ssh: undefined })).toContain("The app's terminal is off."); | |
| 126 | expect(page({ ...options, ssh: "ssh -p 2222 cs@localhost" })) | |
| 127 | .toContain("<code>ssh -p 2222 cs@localhost</code>"); | |
| 128 | }); | |
| 129 | ||
| 130 | test("escape covers the five characters that matter", () => { | |
| 131 | expect(escape(`&<>"'`)).toBe("&<>"'"); | |
| 132 | expect(escape("plain")).toBe("plain"); | |
| 133 | }); |
src/pages/shell.ts added +170
| @@ -0,0 +1,170 @@ | ||
| 1 | /** | |
| 2 | * The shell: the document every game on this server is served inside, and | |
| 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. | |
| 7 | * | |
| 8 | * Escaping is the contract at this boundary. A parameter that carries text is | |
| 9 | * 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'`. | |
| 13 | */ | |
| 14 | ||
| 15 | export const escape = (s: string): string => | |
| 16 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); | |
| 17 | ||
| 18 | // A `<style>` block ends at the first `</`, so CSS handed in from outside the | |
| 19 | // shell cannot be trusted to stay inside it. `\3c ` is how CSS spells `<`: | |
| 20 | // valid in a selector or a content string, inert as markup. | |
| 21 | const cssSafe = (css: string): string => css.replace(/</g, "\\3c "); | |
| 22 | ||
| 23 | const STYLE = ` | |
| 24 | :root { color-scheme: dark; --bg:#0b0d11; --panel:#141821; --line:#232a36; --text:#e7eaf0; --dim:#9aa4b4; --t:#e2b04a; --ct:#6fa8e8; } | |
| 25 | * { box-sizing: border-box; } | |
| 26 | body { margin:0; background:var(--bg); color:var(--text); font:16px/1.55 system-ui,-apple-system,Segoe UI,sans-serif; } | |
| 27 | 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; } | |
| 29 | h1 { font-size:1.4rem; margin:0; letter-spacing:.02em; } | |
| 30 | .env { font-size:.72rem; padding:.15rem .5rem; border-radius:999px; background:#1d4ed8; color:#fff; } | |
| 31 | .env.production { background:#15803d; } | |
| 32 | small.commit { color:var(--dim); font-size:.75rem; } | |
| 33 | #stage { position:relative; width:100%; aspect-ratio:16/9; max-height:78vh; margin:1rem auto; background:#000; | |
| 34 | border:1px solid var(--line); border-radius:.6rem; overflow:hidden; touch-action:none; } | |
| 35 | #scene, #hud { position:absolute; inset:0; width:100%; height:100%; display:block; } | |
| 36 | #hud { pointer-events:none; } | |
| 37 | .overlay { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; | |
| 38 | gap:.75rem; background:rgba(8,10,14,.82); text-align:center; padding:1rem; overflow:auto; } | |
| 39 | .overlay[hidden] { display:none; } | |
| 40 | .overlay h2 { margin:0; font-size:1.3rem; } | |
| 41 | .overlay p { margin:.2rem 0; color:var(--dim); max-width:34rem; } | |
| 42 | .row { display:flex; gap:.5rem; flex-wrap:wrap; justify-content:center; align-items:center; } | |
| 43 | label { display:flex; flex-direction:column; gap:.25rem; font-size:.8rem; color:var(--dim); text-align:left; } | |
| 44 | input, select { min-height:44px; padding:.5rem .7rem; font:inherit; color:var(--text); | |
| 45 | background:#0f131a; border:1px solid var(--line); border-radius:.45rem; } | |
| 46 | button { min-height:44px; min-width:44px; padding:.6rem 1.1rem; font:inherit; border:1px solid var(--line); | |
| 47 | border-radius:.45rem; background:#1b2230; color:var(--text); cursor:pointer; } | |
| 48 | button:hover:not(:disabled) { background:#27303f; } | |
| 49 | button:disabled { opacity:.42; cursor:not-allowed; } | |
| 50 | button.primary { background:#2563eb; border-color:#2563eb; font-weight:600; } | |
| 51 | #buy .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(11rem,1fr)); gap:.5rem; width:min(40rem,100%); } | |
| 52 | #buy .hint, .overlay .hint { font-size:.78rem; } | |
| 53 | #fps { position:absolute; right:.5rem; bottom:.4rem; font-size:.7rem; color:#8ea; opacity:.7; pointer-events:none; } | |
| 54 | #touch { position:absolute; inset:auto 0 0 0; display:flex; justify-content:space-between; padding:.6rem; pointer-events:none; } | |
| 55 | #touch[hidden] { display:none; } | |
| 56 | #touch .pad { display:flex; gap:.4rem; flex-wrap:wrap; pointer-events:auto; } | |
| 57 | #touch button { opacity:.72; background:#0f131acc; } | |
| 58 | main section { margin:1.5rem 0; } | |
| 59 | table { width:100%; border-collapse:collapse; font-size:.9rem; } | |
| 60 | th, td { text-align:left; padding:.35rem .5rem; border-bottom:1px solid var(--line); } | |
| 61 | th { color:var(--dim); font-weight:500; } | |
| 62 | ul.recent { list-style:none; padding:0; margin:0; font-size:.86rem; } | |
| 63 | ul.recent li { padding:.35rem 0; border-bottom:1px solid var(--line); display:flex; gap:.5rem; flex-wrap:wrap; } | |
| 64 | ul.recent span, ul.recent time, .empty { color:var(--dim); } | |
| 65 | kbd { background:#1b2230; border:1px solid var(--line); border-radius:.25rem; padding:0 .3rem; font-size:.82rem; } | |
| 66 | footer { color:var(--dim); font-size:.8rem; padding:1rem; text-align:center; } | |
| 67 | code { background:#141821; padding:.1rem .35rem; border-radius:.25rem; } | |
| 68 | @media (max-width: 640px) { #stage { aspect-ratio:4/3; } h1 { font-size:1.2rem; } } | |
| 69 | `; | |
| 70 | ||
| 71 | /** One touch button: the action `src/client/input.ts` looks up, and its label. */ | |
| 72 | export type TouchButton = { action: string; label: string }; | |
| 73 | ||
| 74 | /** One overlay over the stage — a menu, a pause screen — by the id the client finds it with. */ | |
| 75 | export type Overlay = { id: string; html: string; hidden?: boolean }; | |
| 76 | ||
| 77 | export type StageOptions = { | |
| 78 | overlays: Overlay[]; | |
| 79 | /** One inner list per `.pad`, laid out from the left edge of the stage to the right. */ | |
| 80 | touch: TouchButton[][]; | |
| 81 | }; | |
| 82 | ||
| 83 | /** | |
| 84 | * The stage: the two canvases the renderer draws into, the fps readout, the | |
| 85 | * touch pads and the overlays. Every id in here is found by `getElementById` | |
| 86 | * in `src/client/main.ts` — `scene`, `hud`, `fps`, `touch` and each overlay's — | |
| 87 | * so they are the contract, not decoration. | |
| 88 | */ | |
| 89 | export function stage({ overlays, touch }: StageOptions): string { | |
| 90 | const pads = touch | |
| 91 | .map( | |
| 92 | (pad) => ` <div class="pad"> | |
| 93 | ${pad.map((b) => ` <button type="button" data-action="${escape(b.action)}">${escape(b.label)}</button>`).join("\n")} | |
| 94 | </div>`, | |
| 95 | ) | |
| 96 | .join("\n"); | |
| 97 | return ` <div id="stage"> | |
| 98 | <canvas id="scene"></canvas> | |
| 99 | <canvas id="hud"></canvas> | |
| 100 | <div id="fps"></div> | |
| 101 | <div id="touch" hidden> | |
| 102 | ${pads} | |
| 103 | </div> | |
| 104 | ${overlays.map((o) => ` <div class="overlay" id="${escape(o.id)}"${o.hidden === true ? " hidden" : ""}>${o.html}</div>`).join("\n")} | |
| 105 | </div>`; | |
| 106 | } | |
| 107 | ||
| 108 | /** | |
| 109 | * The terminal line, and it stays honest: the exact command is whatever the | |
| 110 | * forge put in `FORGE_APP_SSH`, printed verbatim. When there is none the app's | |
| 111 | * terminal is off and the page says so — no `you@host` is invented here. | |
| 112 | */ | |
| 113 | export function terminal(ssh: string | undefined, lead: string): string { | |
| 114 | return ssh === undefined ? "The app's terminal is off." : `${escape(lead)} <code>${escape(ssh)}</code>`; | |
| 115 | } | |
| 116 | ||
| 117 | /** Which deployment this is, in words a visitor can act on. */ | |
| 118 | export const where = (env: string, pull: string | undefined): string => | |
| 119 | env === "preview" ? `preview of pull #${pull ?? "?"}` : env; | |
| 120 | ||
| 121 | export type PageOptions = { | |
| 122 | /** Text. The `<title>`, and the `<meta name=description>`. */ | |
| 123 | title: string; | |
| 124 | description: string; | |
| 125 | /** Text. The `<h1>`, and the first word of the footer. */ | |
| 126 | heading: string; | |
| 127 | env: string; | |
| 128 | pull: string | undefined; | |
| 129 | commit: string; | |
| 130 | /** The URL of the one module script. Nothing else runs. */ | |
| 131 | script: string; | |
| 132 | ssh: string | undefined; | |
| 133 | /** The sentence in front of the ssh command, when there is one. */ | |
| 134 | terminalLead: string; | |
| 135 | /** Markup, already rendered — usually `stage(...)`. */ | |
| 136 | stageHtml: string; | |
| 137 | /** Markup, already rendered: the `<section>`s under the stage. */ | |
| 138 | sectionsHtml: string; | |
| 139 | /** Text. The footer reads `<heading> · <where> · <footer>`. */ | |
| 140 | footer: string; | |
| 141 | /** One game's own CSS, appended to the shared block so a rule for one game never reaches the other. */ | |
| 142 | extraStyle?: string; | |
| 143 | }; | |
| 144 | ||
| 145 | /** The whole document. */ | |
| 146 | export function page(options: PageOptions): string { | |
| 147 | const here = where(options.env, options.pull); | |
| 148 | return `<!doctype html> | |
| 149 | <html lang="en"> | |
| 150 | <meta charset="utf-8"> | |
| 151 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> | |
| 152 | <title>${escape(options.title)}</title> | |
| 153 | <meta name="description" content="${escape(options.description)}"> | |
| 154 | <style>${STYLE}${options.extraStyle === undefined ? "" : cssSafe(options.extraStyle)}</style> | |
| 155 | <header> | |
| 156 | <h1>${escape(options.heading)}</h1> | |
| 157 | <span class="env ${options.env === "production" ? "production" : ""}">${escape(here)}</span> | |
| 158 | ${options.commit ? `<small class="commit">@ ${escape(options.commit)}</small>` : ""} | |
| 159 | </header> | |
| 160 | <main> | |
| 161 | ${options.stageHtml} | |
| 162 | ||
| 163 | ${options.sectionsHtml} | |
| 164 | <p class="terminal">${terminal(options.ssh, options.terminalLead)}</p> | |
| 165 | </main> | |
| 166 | <footer>${escape(options.heading)} · ${escape(here)} · ${escape(options.footer)}</footer> | |
| 167 | <script type="module" src="${escape(options.script)}"></script> | |
| 168 | </html> | |
| 169 | `; | |
| 170 | } |
src/server.test.ts +13
| @@ -34,6 +34,19 @@ test("serves the game page with the leaderboard in the first paint", async () => | ||
| 34 | 34 | expect(body).toMatch(/<script type="module" src="\/static\/app\.[0-9a-f]+\.js">/); |
| 35 | 35 | }); |
| 36 | 36 | |
| 37 | test("the page carries every id and action the client reaches for", async () => { | |
| 38 | // src/client/main.ts finds these by getElementById and input.ts by | |
| 39 | // [data-action]; nothing type-checks that, so the page is checked instead. | |
| 40 | // Dropping one while moving the markup around is a game that does not start. | |
| 41 | const body = await (await fetch(base + "/")).text(); | |
| 42 | for (const id of ["scene", "hud", "fps", "touch", "menu", "pause", "buy", "stats", | |
| 43 | "name", "length", "side", "sens", "play", "resume", "quit"]) | |
| 44 | expect(body).toContain(`id="${id}"`); | |
| 45 | for (const action of ["duck", "jump", "use", "reload", "buy", "fire"]) | |
| 46 | expect(body).toContain(`data-action="${action}"`); | |
| 47 | expect(body).toContain(`<div id="stage">`); | |
| 48 | }); | |
| 49 | ||
| 37 | 50 | test("a first visit is given a session cookie that javascript cannot read", async () => { |
| 38 | 51 | const response = await fetch(base + "/"); |
| 39 | 52 | const cookie = response.headers.get("set-cookie") ?? ""; |
new above
offline