Public: anyone can read this; the crew can change it.
forked from trevato/cs
The numbers a page boasts are measured, not typed: the bundle sizes on /example are read from the files they describe #207
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/128-the-numbers-a-page-boasts-are-measured-n → main 1 commit fork
10 files
changed +374
−25
README.md +16
| @@ -797,6 +797,22 @@ never checked itself. | ||
| 797 | 797 | SVG data URI of the crosshair — so **`/favicon.ico` is a request no browser |
| 798 | 798 | makes here**: no file under `static/`, no second round trip, and the CSP |
| 799 | 799 | already allows `img-src 'self' data:`. |
| 800 | - `src/cost.ts` and `src/bytes.ts` — **the numbers `/example` states about | |
| 801 | itself are measured, not typed.** That page's whole claim is what a third game | |
| 802 | costs, so `src/cost.ts` weighs the committed bundles in `static/` with | |
| 803 | `scripts/build.ts`'s own `weigh` (bytes on disk, and gzip at level 9, the | |
| 804 | pinned ruler the ceilings are held to) and counts the lines of | |
| 805 | `src/engine/example.ts` and of `src/example/`. It runs **once, at start-up**, | |
| 806 | in `src/server.ts`, which hands the result to the page: the page renders, and | |
| 807 | a page does not read files on the request path. `src/bytes.ts` is the | |
| 808 | formatter and nothing else — `9,735` when the exact count is the claim, | |
| 809 | `4.5 kB` when the feel of the download is (a kB here is 1,024 bytes) — so the | |
| 810 | rounding and the grouping have a test rather than living inside a template. | |
| 811 | `src/pages/example.test.ts` reads the bundles and the sources itself, formats | |
| 812 | them the same way and looks for them in the rendered page, and scans every | |
| 813 | digit in those paragraphs for one nobody measured, so **a bundle that grows | |
| 814 | moves the sentence or fails the run**. The page says so in its own last | |
| 815 | paragraph, next to the numbers. | |
| 800 | 816 | - `src/games.ts` is **the registry: a game is one entry there.** It holds the |
| 801 | 817 | name its bundle is filed under, the path it answers on, what the header calls |
| 802 | 818 | it, the source it is bundled from, the size that bundle may not pass and the |
scripts/build.test.ts +16
| @@ -11,6 +11,7 @@ import { | ||
| 11 | 11 | overshoot, |
| 12 | 12 | sizes, |
| 13 | 13 | STATIC_DIR, |
| 14 | weigh, | |
| 14 | 15 | write, |
| 15 | 16 | type Entry, |
| 16 | 17 | } from "./build.ts"; |
| @@ -156,6 +157,21 @@ test("the committed bundle of every entry is under its ceiling", () => { | ||
| 156 | 157 | } |
| 157 | 158 | }); |
| 158 | 159 | |
| 160 | test("weighing a committed bundle reads the file and builds nothing", async () => { | |
| 161 | const { dir, entries } = fixture(); | |
| 162 | const [built] = await write(entries, dir); | |
| 163 | const file = readFileSync(join(dir, built?.file as string)); | |
| 164 | // The same ruler `sizes` is: on disk, and gzipped at the pinned level. | |
| 165 | expect(weigh("app", dir)).toEqual(sizes(file)); | |
| 166 | expect(weigh("app", dir).raw).toBe(file.byteLength); | |
| 167 | // And it is the artifact, not a fresh build: a file edited in place weighs | |
| 168 | // what it now is, which is the point of quoting it on a page. | |
| 169 | writeFileSync(join(dir, built?.file as string), `${built?.code as string}// and more\n`); | |
| 170 | expect(weigh("app", dir).raw).toBeGreaterThan(file.byteLength); | |
| 171 | // A game with nothing in static/ is the sentence `bundleOf` writes, not a zero. | |
| 172 | expect(() => weigh("nowhere", dir)).toThrow(/has no bundle/); | |
| 173 | }); | |
| 174 | ||
| 159 | 175 | test("the build prints each bundle's raw and gzipped size next to its name", () => { |
| 160 | 176 | const code = `console.log("hi");\n`; |
| 161 | 177 | const { raw, gzip } = sizes(code); |
scripts/build.ts +15 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | * weighs both the committed file and a fresh build against it. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; | |
| 10 | import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 11 | 11 | import { join, resolve } from "node:path"; |
| 12 | 12 | import { type Ceiling, GAMES } from "../src/games.ts"; |
| 13 | 13 | |
| @@ -96,6 +96,20 @@ export function bundleOf(name: string, files: readonly string[], where = STATIC_ | ||
| 96 | 96 | ); |
| 97 | 97 | } |
| 98 | 98 | |
| 99 | /** | |
| 100 | * What the committed bundle of `name` weighs right now: the one | |
| 101 | * `<name>.<hash>.js` in `dir`, on disk and gzipped at `GZIP_LEVEL`. It builds | |
| 102 | * nothing — this is the file a browser downloads, weighed by the same ruler the | |
| 103 | * ceilings are held to, so a page that quotes a size and a test that checks a | |
| 104 | * ceiling are reading one number. | |
| 105 | * | |
| 106 | * A missing or duplicated bundle throws here, through `bundleOf`, with the | |
| 107 | * sentence that says what to run about it. | |
| 108 | */ | |
| 109 | export function weigh(name: string, dir = STATIC_DIR): Ceiling { | |
| 110 | return sizes(readFileSync(join(dir, bundleOf(name, readdirSync(dir), dir)))); | |
| 111 | } | |
| 112 | ||
| 99 | 113 | export type Built = { name: string; file: string; code: string; ceiling: Ceiling }; |
| 100 | 114 | |
| 101 | 115 | /** Bundle every entry and give each the file name its own contents deserve. */ |
src/bytes.test.ts added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { bytes, kB } from "./bytes.ts"; | |
| 3 | ||
| 4 | test("an exact count is grouped in threes, from either side of a thousand", () => { | |
| 5 | expect(bytes(0)).toBe("0"); | |
| 6 | expect(bytes(7)).toBe("7"); | |
| 7 | expect(bytes(999)).toBe("999"); | |
| 8 | expect(bytes(1000)).toBe("1,000"); | |
| 9 | expect(bytes(9735)).toBe("9,735"); | |
| 10 | expect(bytes(93_714)).toBe("93,714"); | |
| 11 | expect(bytes(1_234_567)).toBe("1,234,567"); | |
| 12 | }); | |
| 13 | ||
| 14 | test("a kilobyte is 1,024 bytes, to a tenth, with the unit on it", () => { | |
| 15 | // The number the issue this came from wrote by hand: 4,229 over the wire is | |
| 16 | // 4.1 kB. It is 1,024 and not 1,000, and that is the whole difference. | |
| 17 | expect(kB(4229)).toBe("4.1 kB"); | |
| 18 | expect(kB(4619)).toBe("4.5 kB"); | |
| 19 | expect(kB(1024)).toBe("1.0 kB"); | |
| 20 | expect(kB(512)).toBe("0.5 kB"); | |
| 21 | expect(kB(0)).toBe("0.0 kB"); | |
| 22 | expect(kB(42_539)).toBe("41.5 kB"); | |
| 23 | }); | |
| 24 | ||
| 25 | test("a number that is not a count of bytes is a throw, not a sentence with NaN in it", () => { | |
| 26 | expect(() => bytes(-1)).toThrow(/whole number of bytes/); | |
| 27 | expect(() => bytes(1.5)).toThrow(/whole number of bytes/); | |
| 28 | expect(() => bytes(Number.NaN)).toThrow(/whole number of bytes/); | |
| 29 | expect(() => kB(-1)).toThrow(/whole number of bytes/); | |
| 30 | expect(() => kB(Number.POSITIVE_INFINITY)).toThrow(/whole number of bytes/); | |
| 31 | }); |
src/bytes.ts added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | /** | |
| 2 | * A byte count, written the way a sentence says one. Two shapes and nothing | |
| 3 | * else: `9735` is `9,735` when the exact figure is the claim, and `4619` is | |
| 4 | * `4.5 kB` when what a reader wants is the feel of the download. | |
| 5 | * | |
| 6 | * It is a module of its own because the alternative is arithmetic inside a | |
| 7 | * template, where no test can reach it: a page interpolates `bytes(n)` and the | |
| 8 | * rounding, the grouping and the unit are decided here, once, with a test | |
| 9 | * beside them. | |
| 10 | * | |
| 11 | * The unit is stated rather than assumed: a kB here is 1,024 bytes, which is | |
| 12 | * what makes 4,619 read as 4.5 kB. That is the ruler `scripts/build.ts` | |
| 13 | * measures with too — a gzip figure at level 9 — and the pages that print these | |
| 14 | * numbers say so next to them. | |
| 15 | */ | |
| 16 | ||
| 17 | /** A count this module will print: a whole number of bytes, and not a negative one. */ | |
| 18 | const whole = (n: number, kind: string): number => { | |
| 19 | if (!Number.isInteger(n) || n < 0) throw new Error(`${kind} wants a whole number of bytes, not ${n}`); | |
| 20 | return n; | |
| 21 | }; | |
| 22 | ||
| 23 | /** `9735` → `9,735`: the exact count, in groups of three. */ | |
| 24 | export const bytes = (n: number): string => String(whole(n, "bytes")).replace(/\B(?=(\d{3})+$)/g, ","); | |
| 25 | ||
| 26 | /** `4619` → `4.5 kB`: the same count rounded to a tenth of a kibibyte, with its unit. */ | |
| 27 | export const kB = (n: number): string => `${(whole(n, "kB") / 1024).toFixed(1)} kB`; |
src/cost.ts added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | /** | |
| 2 | * What the example game cost, measured from the files that cost it. | |
| 3 | * | |
| 4 | * `/example` exists to say what a third game on this engine is worth in bytes | |
| 5 | * and in lines. A number typed into that prose is wrong the first time a bundle | |
| 6 | * grows — the shooter's went from 71,646 to 93,714 while the sentence sat | |
| 7 | * still — so nothing here is typed: the bundle sizes are the committed files in | |
| 8 | * static/ weighed by `scripts/build.ts`'s own ruler, and the line counts are | |
| 9 | * the sources read and counted. | |
| 10 | * | |
| 11 | * It reads files, so it is called once, at start-up, by `src/server.ts`, and | |
| 12 | * the result is handed to the page. A page that measured itself per request | |
| 13 | * would do file I/O on the request path for a number that cannot change while | |
| 14 | * this process lives. | |
| 15 | * | |
| 16 | * It describes what *this* process serves: the bundle in this deployment's | |
| 17 | * static/ and the sources in this deployment's tree, which is the honest thing | |
| 18 | * for a page to claim about its own download. | |
| 19 | */ | |
| 20 | ||
| 21 | import { readFileSync } from "node:fs"; | |
| 22 | import { join } from "node:path"; | |
| 23 | import { weigh } from "../scripts/build.ts"; | |
| 24 | import type { Ceiling } from "./games.ts"; | |
| 25 | ||
| 26 | /** | |
| 27 | * The level every gzip figure in this app is measured at, re-exported so a page | |
| 28 | * can name it in its prose without importing the build script: the number the | |
| 29 | * sentence quotes and the number the bytes were weighed at are one constant. | |
| 30 | */ | |
| 31 | export { GZIP_LEVEL } from "../scripts/build.ts"; | |
| 32 | ||
| 33 | const ROOT = join(import.meta.dir, ".."); | |
| 34 | ||
| 35 | /** The game itself: the lines the engine's guide is written out of. */ | |
| 36 | export const GAME_SOURCE = "src/engine/example.ts"; | |
| 37 | ||
| 38 | /** The wiring a third game needed around it, and the whole of it. */ | |
| 39 | export const WIRING_SOURCES = ["src/example/main.ts", "src/example/plan.ts"]; | |
| 40 | ||
| 41 | /** Every number `/example` states about itself. Bytes are bytes; lines are lines. */ | |
| 42 | export type Cost = { | |
| 43 | /** The example's committed bundle: what the page just downloaded. */ | |
| 44 | script: Ceiling; | |
| 45 | /** The shooter's committed bundle, which the page compares itself against. */ | |
| 46 | shooter: Ceiling; | |
| 47 | /** Lines of `GAME_SOURCE`. */ | |
| 48 | game: number; | |
| 49 | /** Lines of `WIRING_SOURCES`, added up. */ | |
| 50 | wiring: number; | |
| 51 | }; | |
| 52 | ||
| 53 | /** | |
| 54 | * How many lines a source is, counted the way an editor numbers them: a | |
| 55 | * trailing newline ends the last line rather than starting an empty one, so a | |
| 56 | * file `wc -l` calls 79 lines is 79 here. | |
| 57 | */ | |
| 58 | export function lineCount(text: string): number { | |
| 59 | const lines = text.split("\n"); | |
| 60 | if (lines.at(-1) === "") lines.pop(); | |
| 61 | return lines.length; | |
| 62 | } | |
| 63 | ||
| 64 | const linesOf = (root: string, file: string): number => lineCount(readFileSync(join(root, file), "utf8")); | |
| 65 | ||
| 66 | /** | |
| 67 | * Weigh and count, now. `root` and `dir` are arguments so a test can point this | |
| 68 | * at a tree of its own; the server calls it with neither. | |
| 69 | */ | |
| 70 | export function measureExample(root = ROOT, dir?: string): Cost { | |
| 71 | return { | |
| 72 | script: weigh("example", dir), | |
| 73 | shooter: weigh("app", dir), | |
| 74 | game: linesOf(root, GAME_SOURCE), | |
| 75 | wiring: WIRING_SOURCES.reduce((total, file) => total + linesOf(root, file), 0), | |
| 76 | }; | |
| 77 | } |
src/pages/example.test.ts added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | /** | |
| 2 | * The numbers `/example` boasts, against the files they describe. | |
| 3 | * | |
| 4 | * The page's whole claim is that a third game on this engine is cheap, and the | |
| 5 | * evidence is four numbers: the bytes of its bundle, the bytes over the wire, | |
| 6 | * the bytes of the shooter's bundle, and the lines the game and its wiring are. | |
| 7 | * Typed into the prose they drift — the shooter's sat at 71,646 while the file | |
| 8 | * grew past 93,000 — so this file reads the bundles and the sources itself, | |
| 9 | * formats them the way the page does, and looks for them in the rendered | |
| 10 | * document. A bundle that grows now moves the sentence or fails the run. | |
| 11 | * | |
| 12 | * It measures independently of `src/cost.ts` where it can: the sizes here are | |
| 13 | * `readFileSync` plus `Bun.gzipSync` at the pinned level, so a mistake in the | |
| 14 | * measuring module is a mismatch here rather than the same wrong number twice. | |
| 15 | */ | |
| 16 | ||
| 17 | import { expect, test } from "bun:test"; | |
| 18 | import { readdirSync, readFileSync } from "node:fs"; | |
| 19 | import { join } from "node:path"; | |
| 20 | import { bundleOf, GZIP_LEVEL, STATIC_DIR } from "../../scripts/build.ts"; | |
| 21 | import { bytes, kB } from "../bytes.ts"; | |
| 22 | import { GAME_SOURCE, lineCount, measureExample, WIRING_SOURCES } from "../cost.ts"; | |
| 23 | import { costHtml, examplePage } from "./example.ts"; | |
| 24 | ||
| 25 | const ROOT = join(import.meta.dir, "..", ".."); | |
| 26 | ||
| 27 | /** The committed bundle of a game, weighed here rather than asked for. */ | |
| 28 | const weighed = (name: string): { raw: number; gzip: number } => { | |
| 29 | const file = readFileSync(join(STATIC_DIR, bundleOf(name, readdirSync(STATIC_DIR)))); | |
| 30 | return { raw: file.byteLength, gzip: Bun.gzipSync(file, { level: GZIP_LEVEL }).byteLength }; | |
| 31 | }; | |
| 32 | ||
| 33 | /** The same count `lineCount` makes, arrived at the other way round. */ | |
| 34 | const linesOf = (file: string): number => { | |
| 35 | const text = readFileSync(join(ROOT, file), "utf8"); | |
| 36 | return text.split("\n").length - (text.endsWith("\n") ? 1 : 0); | |
| 37 | }; | |
| 38 | ||
| 39 | /** | |
| 40 | * The page as one line: prose is wrapped in the template, so a sentence is read | |
| 41 | * here with its newlines and indentation collapsed to single spaces. What is | |
| 42 | * being checked is the words and the numbers, never where the source wrapped. | |
| 43 | */ | |
| 44 | const rendered = (): string => | |
| 45 | examplePage({ | |
| 46 | env: "local", | |
| 47 | pull: undefined, | |
| 48 | commit: "abc1234", | |
| 49 | script: "/static/example.x.js", | |
| 50 | ssh: undefined, | |
| 51 | cost: measureExample(), | |
| 52 | }).replace(/\s+/g, " "); | |
| 53 | ||
| 54 | test("a line count is what an editor would number, trailing newline and all", () => { | |
| 55 | expect(lineCount("a\nb\nc\n")).toBe(3); | |
| 56 | expect(lineCount("a\nb\nc")).toBe(3); | |
| 57 | expect(lineCount("only one")).toBe(1); | |
| 58 | expect(lineCount("")).toBe(0); | |
| 59 | expect(lineCount("\n")).toBe(1); | |
| 60 | // The real file, counted the way `wc -l` counts it. | |
| 61 | const source = readFileSync(join(ROOT, GAME_SOURCE), "utf8"); | |
| 62 | expect(lineCount(source)).toBe(source.split("\n").length - 1); | |
| 63 | }); | |
| 64 | ||
| 65 | test("what the page is handed is what the files weigh and what the sources count", () => { | |
| 66 | const cost = measureExample(); | |
| 67 | expect(cost.script).toEqual(weighed("example")); | |
| 68 | expect(cost.shooter).toEqual(weighed("app")); | |
| 69 | expect(cost.game).toBe(linesOf(GAME_SOURCE)); | |
| 70 | expect(cost.wiring).toBe(WIRING_SOURCES.reduce((total, file) => total + linesOf(file), 0)); | |
| 71 | // Sanity, so a measurement of zero can never quietly pass the checks above. | |
| 72 | expect(cost.script.raw).toBeGreaterThan(1000); | |
| 73 | expect(cost.shooter.raw).toBeGreaterThan(cost.script.raw); | |
| 74 | expect(cost.game).toBeGreaterThan(20); | |
| 75 | expect(cost.wiring).toBeGreaterThan(cost.game); | |
| 76 | }); | |
| 77 | ||
| 78 | test("the bytes the page states are the bytes of the committed bundles", () => { | |
| 79 | const body = rendered(); | |
| 80 | const script = weighed("example"); | |
| 81 | const shooter = weighed("app"); | |
| 82 | expect(body).toContain(`is ${bytes(script.raw)} bytes`); | |
| 83 | expect(body).toContain(`${kB(script.gzip)} of them over the wire`); | |
| 84 | expect(body).toContain(`against the shooter's ${bytes(shooter.raw)}`); | |
| 85 | expect(body).toContain(`and ${kB(shooter.gzip)}.`); | |
| 86 | expect(body).toContain(`gzipped at level ${GZIP_LEVEL}`); | |
| 87 | }); | |
| 88 | ||
| 89 | test("the lines the page states are the lines of the sources it names", () => { | |
| 90 | const body = rendered(); | |
| 91 | expect(body).toContain(`the ${linesOf(GAME_SOURCE)} lines the engine's guide is written out of`); | |
| 92 | const wiring = WIRING_SOURCES.reduce((total, file) => total + linesOf(file), 0); | |
| 93 | expect(body).toContain(`two files and ${wiring} lines`); | |
| 94 | }); | |
| 95 | ||
| 96 | test("no number the page states about itself is one somebody typed", () => { | |
| 97 | const body = rendered(); | |
| 98 | // The four that were typed, and were wrong by the time this was written. | |
| 99 | for (const stale of ["8,754", "4,229", "71,646", "seventy-nine", "about two hundred"]) | |
| 100 | expect(body).not.toContain(stale); | |
| 101 | // Every group of digits in the paragraphs about the cost is one of the | |
| 102 | // measured numbers, the gzip level, or the 1,024 the unit is defined by. A | |
| 103 | // number typed into this prose fails here. | |
| 104 | const cost = measureExample(); | |
| 105 | const section = costHtml(cost); | |
| 106 | const allowed = new Set([ | |
| 107 | bytes(cost.script.raw), bytes(cost.shooter.raw), | |
| 108 | kB(cost.script.gzip).replace(" kB", ""), kB(cost.shooter.gzip).replace(" kB", ""), | |
| 109 | String(cost.game), String(cost.wiring), String(GZIP_LEVEL), "1,024", | |
| 110 | "2", // "2D on one canvas", and the two files of wiring | |
| 111 | "404", // the page nobody had to edit | |
| 112 | ]); | |
| 113 | for (const found of section.match(/\d[\d,]*(?:\.\d+)?/g) ?? []) expect([...allowed]).toContain(found); | |
| 114 | }); |
src/pages/example.ts +49 −22
| @@ -10,11 +10,17 @@ | ||
| 10 | 10 | * thing to demonstrate is the price tag, not a third shooter. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { bytes, kB } from "../bytes.ts"; | |
| 14 | import { type Cost, GZIP_LEVEL } from "../cost.ts"; | |
| 13 | 15 | import { navFor, othersHtml } from "./games.ts"; |
| 14 | 16 | import { page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| 15 | 17 | |
| 16 | /** What the shell needs that this page cannot know: the environment it runs in. */ | |
| 17 | export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">; | |
| 18 | /** | |
| 19 | * What the shell needs that this page cannot know: the environment it runs in, | |
| 20 | * and what this game cost. The cost is measured by `src/cost.ts` at start-up | |
| 21 | * and handed in, because a page renders and does not read files. | |
| 22 | */ | |
| 23 | export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh"> & { cost: Cost }; | |
| 18 | 24 | |
| 19 | 25 | const NOTE = ` |
| 20 | 26 | <h2>The smallest game this engine can run</h2> |
| @@ -25,6 +31,46 @@ const NOTE = ` | ||
| 25 | 31 | <div class="row"><button type="button" class="primary" data-action="start">Start</button></div> |
| 26 | 32 | `; |
| 27 | 33 | |
| 34 | /** | |
| 35 | * The two paragraphs about what a third game cost, with every number in them | |
| 36 | * measured: the bundles weighed on disk, the sources counted. Nothing here | |
| 37 | * formats a number itself — `../bytes.ts` does — and nothing here reads a file; | |
| 38 | * it is handed a `Cost` and writes the sentence around it. | |
| 39 | * | |
| 40 | * Exported so `./example.test.ts` can read every digit in these paragraphs and | |
| 41 | * hold each one against a measurement, without the games' own blurbs below | |
| 42 | * being part of the question. | |
| 43 | */ | |
| 44 | export function costHtml(cost: Cost): string { | |
| 45 | const { script, shooter, game, wiring } = cost; | |
| 46 | return ` <p>The game on this page is not on this page. It is <code>src/engine/example.ts</code>, | |
| 47 | the ${game} lines the engine's guide is written out of — a room, a plate, a seeded spawn, a | |
| 48 | fixed step and one shot per press — and it was already there, already tested, before this | |
| 49 | route existed. What a third game cost was the wiring around it: | |
| 50 | <code>src/example/main.ts</code> finds the canvas, hands the engine's controls the game's own | |
| 51 | bindings table, runs the engine's fixed loop and calls the game's <code>step</code>; | |
| 52 | <code>src/example/plan.ts</code> draws the result. That is two files and ${wiring} lines, one | |
| 53 | registry entry in <code>src/games.ts</code>, this page, and one line in the map from a game's | |
| 54 | name to its page. No branch was added to the server, and nothing in the header, | |
| 55 | the 404 or the other two pages was edited: they read the registry, so this game appeared in | |
| 56 | all of them the moment it was written down.</p> | |
| 57 | <p>What it did not have to write is the longer list: no loop, no fixed step, no bindings table | |
| 58 | reader, no touch buttons, no pointer handling, no ray against a box, no seeded random, no | |
| 59 | vector maths, no panel, no bar, no clock, no text scaling — all of that is the package, and | |
| 60 | this game reaches for it through one door, <code>src/engine/index.ts</code>. It is 2D on one | |
| 61 | canvas: no WebGL, no atlas, no mesh, no sound and no database, and the door costs what you | |
| 62 | use — the script this page just downloaded is ${bytes(script.raw)} bytes, ${kB(script.gzip)} | |
| 63 | of them over the wire, against the shooter's ${bytes(shooter.raw)} bytes and ${kB(shooter.gzip)}. | |
| 64 | Every rectangle, the crosshair, the countdown bar and the <code>m:ss</code> clock come out of | |
| 65 | the engine's <code>overlay</code>, and nothing else draws here.</p> | |
| 66 | <p>Every number in those two paragraphs is measured, not typed. The byte counts are this | |
| 67 | deployment's own committed bundles under <code>static/</code>, read when this server started | |
| 68 | and gzipped at level ${GZIP_LEVEL} — the ruler <code>scripts/build.ts</code> weighs the | |
| 69 | ceilings with, and a kB here is 1,024 bytes. The line counts are those source files read the | |
| 70 | same way. A bundle that grows moves this sentence on the next deploy, and | |
| 71 | <code>src/pages/example.test.ts</code> fails if it does not.</p>`; | |
| 72 | } | |
| 73 | ||
| 28 | 74 | export function examplePage(options: PageOptions): string { |
| 29 | 75 | return page({ |
| 30 | 76 | ...options, |
| @@ -50,26 +96,7 @@ export function examplePage(options: PageOptions): string { | ||
| 50 | 96 | }), |
| 51 | 97 | sectionsHtml: ` <section> |
| 52 | 98 | <h2>What this cost</h2> |
| 53 | <p>The game on this page is not on this page. It is <code>src/engine/example.ts</code>, the | |
| 54 | seventy-nine lines the engine's guide is written out of — a room, a plate, a seeded spawn, a | |
| 55 | fixed step and one shot per press — and it was already there, already tested, before this | |
| 56 | route existed. What a third game cost was the wiring around it: | |
| 57 | <code>src/example/main.ts</code> finds the canvas, hands the engine's controls the game's own | |
| 58 | bindings table, runs the engine's fixed loop and calls the game's <code>step</code>; | |
| 59 | <code>src/example/plan.ts</code> draws the result. That is two files and about two hundred | |
| 60 | lines, one registry entry in <code>src/games.ts</code>, this page, and one line in the map | |
| 61 | from a game's name to its page. No branch was added to the server, and nothing in the header, | |
| 62 | the 404 or the other two pages was edited: they read the registry, so this game appeared in | |
| 63 | all of them the moment it was written down.</p> | |
| 64 | <p>What it did not have to write is the longer list: no loop, no fixed step, no bindings table | |
| 65 | reader, no touch buttons, no pointer handling, no ray against a box, no seeded random, no | |
| 66 | vector maths, no panel, no bar, no clock, no text scaling — all of that is the package, and | |
| 67 | this game reaches for it through one door, <code>src/engine/index.ts</code>. It is 2D on one | |
| 68 | canvas: no WebGL, no atlas, no mesh, no sound and no database, and the door costs what you | |
| 69 | use — the script this page just downloaded is 8,754 bytes, 4,229 of them over the wire, | |
| 70 | against the shooter's 71,646. Every rectangle, the crosshair, the countdown bar and the | |
| 71 | <code>m:ss</code> clock come out of the engine's <code>overlay</code>, and nothing else draws | |
| 72 | here.</p> | |
| 99 | ${costHtml(options.cost)} | |
| 73 | 100 | <p>Nothing on this page is stored: no endpoint, no table and no cookie. The leaderboard, the |
| 74 | 101 | database and the terminal belong to the games next door: ${othersHtml("/example")}</p> |
| 75 | 102 | </section>`, |
src/server.test.ts +15
| @@ -3,6 +3,8 @@ import { mkdtempSync, readdirSync, rmSync, writeFileSync } 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 { bytes, kB } from "./bytes.ts"; | |
| 7 | import { measureExample } from "./cost.ts"; | |
| 6 | 8 | import { GAMES } from "./games.ts"; |
| 7 | 9 | import { matchOf, playKey } from "./matchid.ts"; |
| 8 | 10 | import { NOTHING_POSTED } from "./client/pending.ts"; |
| @@ -208,6 +210,19 @@ test("serves the example on its own route, with its own page and its own bundle" | ||
| 208 | 210 | expect(response.headers.get("set-cookie")).toBeNull(); |
| 209 | 211 | }); |
| 210 | 212 | |
| 213 | test("the example page's byte counts are the bundles this server is serving", async () => { | |
| 214 | // The page's claim is about what a browser just downloaded, so it is checked | |
| 215 | // over the wire: the numbers in the served document are the files in static/, | |
| 216 | // weighed here. `src/pages/example.test.ts` holds the rest of that sentence — | |
| 217 | // this is the wiring, that the server measures at start-up and hands it over. | |
| 218 | const body = (await (await fetch(base + "/example")).text()).replace(/\s+/g, " "); | |
| 219 | const cost = measureExample(); | |
| 220 | expect(body).toContain(`is ${bytes(cost.script.raw)} bytes`); | |
| 221 | expect(body).toContain(`${kB(cost.script.gzip)} of them over the wire`); | |
| 222 | expect(body).toContain(`the shooter's ${bytes(cost.shooter.raw)} bytes`); | |
| 223 | expect(body).toContain(`the ${cost.game} lines`); | |
| 224 | }); | |
| 225 | ||
| 211 | 226 | test("the example page carries every id and action its entry reaches for", async () => { |
| 212 | 227 | // src/example/main.ts finds these by getElementById and hands [data-action] |
| 213 | 228 | // to the engine's controls; nothing type-checks that, so the page is checked |
src/server.ts +14 −2
| @@ -33,6 +33,7 @@ import { existsSync, readdirSync } from "node:fs"; | ||
| 33 | 33 | import { join } from "node:path"; |
| 34 | 34 | import { bundleOf, STATIC_DIR } from "../scripts/build.ts"; |
| 35 | 35 | import { bind } from "./bind.ts"; |
| 36 | import { measureExample } from "./cost.ts"; | |
| 36 | 37 | import { openDatabase, type Player, saveMatch } from "./db.ts"; |
| 37 | 38 | import { GAMES } from "./games.ts"; |
| 38 | 39 | import { createLimiter } from "./limit.ts"; |
| @@ -229,6 +230,14 @@ export function findScripts(dir = STATIC_DIR, games = GAMES): Map<string, string | ||
| 229 | 230 | } |
| 230 | 231 | const scripts = findScripts(); |
| 231 | 232 | |
| 233 | /** | |
| 234 | * What `/example` says a third game cost, weighed and counted once, here, | |
| 235 | * against the files this process serves: the committed bundles in static/ and | |
| 236 | * the sources in this tree. `src/cost.ts` does the reading; the page formats | |
| 237 | * what it is handed. Per request this is a lookup, not a file. | |
| 238 | */ | |
| 239 | const exampleCost = measureExample(); | |
| 240 | ||
| 232 | 241 | /** |
| 233 | 242 | * How one game's page is drawn for one request: its document, and any header |
| 234 | 243 | * of its own — the shooter's session cookie is the only one. The status, the |
| @@ -291,8 +300,11 @@ const RENDER: Record<string, Render> = { | ||
| 291 | 300 | // nothing, so it has nothing to name a browser for. No CSS of its own either: |
| 292 | 301 | // its style block is the shell's, exactly. |
| 293 | 302 | range: { draw: (_request, script) => ({ body: rangePage({ env, pull, commit, script, ssh }) }) }, |
| 294 | // The example: the guide's game with a page around it, and no session either. | |
| 295 | example: { draw: (_request, script) => ({ body: examplePage({ env, pull, commit, script, ssh }) }) }, | |
| 303 | // The example: the guide's game with a page around it, no session either, and | |
| 304 | // the measured cost of being a third game, weighed once above. | |
| 305 | example: { | |
| 306 | draw: (_request, script) => ({ body: examplePage({ env, pull, commit, script, ssh, cost: exampleCost }) }), | |
| 307 | }, | |
| 296 | 308 | }; |
| 297 | 309 | |
| 298 | 310 | // The read routes, resolved once at start-up: the path each game answers on, |
new above
offline