Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes · 4 behind
Every bundle has a stated ceiling, and bun test holds it #39
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/34-every-bundle-has-a-stated-ceiling-and-bu → main 1 commit fork
3 files
changed +173
−23
README.md +20 −3
| @@ -9,7 +9,7 @@ both. | ||
| 9 | 9 | |
| 10 | 10 | sh |
| 11 | 11 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here |
| 12 | bun run scripts/build.ts # rebuild every static/<name>.<hash>.js | |
| 12 | bun run scripts/build.ts # rebuild every static/<name>.<hash>.js, with its size | |
| 13 | 13 | bun run scripts/train.ts --iters 1000 # retrain the bots' policy |
| 14 | 14 | bun run scripts/train.ts --bench # how fast the arena steps |
| 15 | 15 | bun test # the check every pull runs |
| @@ -190,8 +190,9 @@ page shows is the loop and the controls themselves, as numbers that move. | ||
| 190 | 190 | that list and `src/server.test.ts` pins the served page against the same one, |
| 191 | 191 | so the page the game is tested on and the page it is served on cannot drift. |
| 192 | 192 | - `scripts/build.ts` bundles a table of entries — `ENTRIES`, one line per game: |
| 193 | a name and a source file — into `static/<name>.<hash>.js`, committed and | |
| 194 | cached for a year. Each entry sweeps up only its own stale bundles, so one | |
| 193 | a name, a source file and the size it may not pass — into | |
| 194 | `static/<name>.<hash>.js`, committed and cached for a year. Each entry sweeps | |
| 195 | up only its own stale bundles, so one | |
| 195 | 196 | game's rebuild never deletes another's, and `bun test` fails if any committed |
| 196 | 197 | bundle is stale — today that is `app` (the shooter) and `range`. The server |
| 197 | 198 | resolves the script for an entry by that exact name, so **adding a second game |
| @@ -199,6 +200,22 @@ page shows is the loop and the controls themselves, as numbers that move. | ||
| 199 | 200 | route in `src/server.ts`** that renders it with `scripts["<name>"]` as the |
| 200 | 201 | `script` — no inline script, the CSP is `default-src 'self'`. `/range` is that |
| 201 | 202 | three-part recipe followed once. |
| 203 | - **Every bundle has a ceiling.** Today, and `bun run scripts/build.ts` prints | |
| 204 | these next to each file name: | |
| 205 | ||
| 206 | | bundle | raw | ceiling | gzipped | ceiling | | |
| 207 | | ------- | ------ | ------- | ------- | ------- | | |
| 208 | | `app` | 68,457 | 75,400 | 31,661 | 34,900 | | |
| 209 | | `range` | 7,067 | 7,800 | 3,258 | 3,600 | | |
| 210 | ||
| 211 | The ceilings are today's sizes plus about 10%. Gzip is measured at level 9 | |
| 212 | and only at level 9, so the number does not move between a laptop and the | |
| 213 | forge. `scripts/build.test.ts` weighs both the committed file in `static/` and | |
| 214 | a fresh build of the sources against both ceilings — a stale commit cannot | |
| 215 | hide a fat build — and the failure names the bundle, what it measured, what it | |
| 216 | may not pass and the overshoot in bytes and percent. **Raising a ceiling is | |
| 217 | allowed**: edit the number in `ENTRIES` and say in the pull what was added and | |
| 218 | why it is worth the bytes. What is not allowed is growth nobody noticed. | |
| 202 | 219 | - No dependencies. Nothing is installed to run this. |
| 203 | 220 | |
| 204 | 221 | It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app |
scripts/build.test.ts +89 −10
| @@ -1,11 +1,25 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { existsSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"; | |
| 2 | import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { bundle, ENTRIES, isBundleOf, write } from "./build.ts"; | |
| 5 | import { | |
| 6 | bundle, | |
| 7 | ENTRIES, | |
| 8 | GZIP_LEVEL, | |
| 9 | isBundleOf, | |
| 10 | line, | |
| 11 | overshoot, | |
| 12 | sizes, | |
| 13 | STATIC_DIR, | |
| 14 | write, | |
| 15 | type Entry, | |
| 16 | } from "./build.ts"; | |
| 17 | ||
| 18 | /** Room enough that the fixture's two-line bundles are never the thing failing. */ | |
| 19 | const ROOMY = { raw: 100_000, gzip: 100_000 }; | |
| 6 | 20 | |
| 7 | 21 | /** A throwaway pair of entries: two sources and a directory to write them to. */ |
| 8 | function fixture(): { dir: string; entries: { name: string; entry: string }[]; source: (n: string, t: string) => void } { | |
| 22 | function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: string) => void } { | |
| 9 | 23 | const dir = mkdtempSync(join(tmpdir(), "build-")); |
| 10 | 24 | const source = (name: string, text: string): void => writeFileSync(join(dir, `${name}.ts`), text); |
| 11 | 25 | source("app", `export const game = "app";\nconsole.log(game);\n`); |
| @@ -13,17 +27,17 @@ function fixture(): { dir: string; entries: { name: string; entry: string }[]; s | ||
| 13 | 27 | return { |
| 14 | 28 | dir, |
| 15 | 29 | entries: [ |
| 16 | { name: "app", entry: join(dir, "app.ts") }, | |
| 17 | { name: "sandbox", entry: join(dir, "sandbox.ts") }, | |
| 30 | { name: "app", entry: join(dir, "app.ts"), ceiling: ROOMY }, | |
| 31 | { name: "sandbox", entry: join(dir, "sandbox.ts"), ceiling: ROOMY }, | |
| 18 | 32 | ], |
| 19 | 33 | source, |
| 20 | 34 | }; |
| 21 | 35 | } |
| 22 | 36 | |
| 23 | test("the entry table is what the app ships today", () => { | |
| 37 | test("the entry table is what the app ships today, each with the size it may not pass", () => { | |
| 24 | 38 | expect(ENTRIES).toEqual([ |
| 25 | { name: "app", entry: "src/client/main.ts" }, | |
| 26 | { name: "range", entry: "src/range/main.ts" }, | |
| 39 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } }, | |
| 40 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 7_800, gzip: 3_600 } }, | |
| 27 | 41 | ]); |
| 28 | 42 | }); |
| 29 | 43 | |
| @@ -74,6 +88,71 @@ test("a bundle belongs to one entry by exact name", () => { | ||
| 74 | 88 | test("two entries cannot share a name, and a name is plain", async () => { |
| 75 | 89 | const { dir } = fixture(); |
| 76 | 90 | const entry = join(dir, "app.ts"); |
| 77 | expect(bundle([{ name: "app", entry }, { name: "app", entry }])).rejects.toThrow(/share a name/); | |
| 78 | expect(bundle([{ name: "../app", entry }])).rejects.toThrow(/plain name/); | |
| 91 | const of = (name: string): Entry => ({ name, entry, ceiling: ROOMY }); | |
| 92 | expect(bundle([of("app"), of("app")])).rejects.toThrow(/share a name/); | |
| 93 | expect(bundle([of("../app")])).rejects.toThrow(/plain name/); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a size is bytes on disk and bytes gzipped at one fixed level", () => { | |
| 97 | const code = `console.log(${JSON.stringify("é".repeat(1000))});\n`; | |
| 98 | const bytes = new TextEncoder().encode(code); | |
| 99 | // Raw counts bytes, not characters: "é" is two of them. | |
| 100 | expect(sizes(code).raw).toBe(bytes.byteLength); | |
| 101 | expect(sizes(code).raw).toBeGreaterThan(code.length); | |
| 102 | expect(sizes(code)).toEqual(sizes(bytes)); | |
| 103 | // The level is pinned, so the number is the same on every machine that runs | |
| 104 | // this. A different level is a different number, which is why it is pinned. | |
| 105 | expect(GZIP_LEVEL).toBe(9); | |
| 106 | expect(sizes(code).gzip).toBe(Bun.gzipSync(bytes, { level: GZIP_LEVEL }).byteLength); | |
| 107 | expect(sizes(code).gzip).not.toBe(Bun.gzipSync(bytes, { level: 1 }).byteLength); | |
| 108 | expect(sizes(code)).toEqual(sizes(code)); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a bundle that fits says nothing, and one that does not names the overshoot", () => { | |
| 112 | const ceiling = { raw: 1000, gzip: 500 }; | |
| 113 | expect(overshoot("app", { raw: 1000, gzip: 500 }, ceiling)).toBeNull(); | |
| 114 | expect(overshoot("app", { raw: 999, gzip: 499 }, ceiling)).toBeNull(); | |
| 115 | ||
| 116 | const raw = overshoot("app", { raw: 1100, gzip: 400 }, ceiling) as string; | |
| 117 | expect(raw).toContain("app is too big"); | |
| 118 | expect(raw).toContain("raw 1100 bytes over the 1000 byte ceiling by 100 bytes (10.0%)"); | |
| 119 | expect(raw).not.toContain("gzip"); | |
| 120 | ||
| 121 | const gzip = overshoot("range", { raw: 900, gzip: 750 }, ceiling) as string; | |
| 122 | expect(gzip).toContain("range is too big"); | |
| 123 | expect(gzip).toContain("gzip 750 bytes over the 500 byte ceiling by 250 bytes (50.0%)"); | |
| 124 | expect(gzip).not.toContain("raw"); | |
| 125 | ||
| 126 | // Both at once is one sentence, not two failures to chase. | |
| 127 | const both = overshoot("app", { raw: 1001, gzip: 501 }, ceiling) as string; | |
| 128 | expect(both).toContain("raw 1001 bytes over"); | |
| 129 | expect(both).toContain("gzip 501 bytes over"); | |
| 130 | }); | |
| 131 | ||
| 132 | test("a fresh build of every entry is under its ceiling", async () => { | |
| 133 | // The sources as they are now: a fat import fails here on the first build, | |
| 134 | // before anyone has committed the bundle it produced. | |
| 135 | for (const { name, code, ceiling } of await bundle()) { | |
| 136 | expect(overshoot(`${name} (fresh build)`, sizes(code), ceiling)).toBeNull(); | |
| 137 | } | |
| 138 | }, 60_000); | |
| 139 | ||
| 140 | test("the committed bundle of every entry is under its ceiling", () => { | |
| 141 | // The bytes a browser downloads today. Measuring only the fresh build would | |
| 142 | // let a stale, fat commit sit in static/ and pass. | |
| 143 | for (const { name, ceiling } of ENTRIES) { | |
| 144 | const files = readdirSync(STATIC_DIR).filter((file) => isBundleOf(name, file)); | |
| 145 | expect(files.length).toBe(1); | |
| 146 | const bytes = readFileSync(join(STATIC_DIR, files[0] as string)); | |
| 147 | expect(overshoot(`static/${files[0]} (committed)`, sizes(bytes), ceiling)).toBeNull(); | |
| 148 | } | |
| 149 | }); | |
| 150 | ||
| 151 | test("the build prints each bundle's raw and gzipped size next to its name", () => { | |
| 152 | const code = `console.log("hi");\n`; | |
| 153 | const { raw, gzip } = sizes(code); | |
| 154 | const printed = line({ name: "app", file: "app.6864d577a4.js", code, ceiling: { raw: 400, gzip: 300 } }); | |
| 155 | expect(printed).toContain("static/app.6864d577a4.js"); | |
| 156 | expect(printed).toContain(`${raw} B raw / 400`); | |
| 157 | expect(printed).toContain(`${gzip} B gzip-${GZIP_LEVEL} / 300`); | |
| 79 | 158 | }); |
scripts/build.ts +64 −10
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | * The build: a table of entries, each bundled to `<name>.<hash>.js` in static/. |
| 3 | 3 | * `bun run build` writes them; the test compares what is committed against what |
| 4 | 4 | * the sources produce now, so a stale bundle fails the check instead of the |
| 5 | * browser. | |
| 5 | * browser. Each entry also carries the size it may not pass, and `build.test.ts` | |
| 6 | * weighs both the committed file and a fresh build against it. | |
| 6 | 7 | */ |
| 7 | 8 | |
| 8 | 9 | import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; |
| @@ -11,15 +12,60 @@ import { join, resolve } from "node:path"; | ||
| 11 | 12 | const ROOT = join(import.meta.dir, ".."); |
| 12 | 13 | export const STATIC_DIR = join(ROOT, "static"); |
| 13 | 14 | |
| 14 | /** One game: the name its bundle is filed under, and the source it starts from. */ | |
| 15 | export type Entry = { name: string; entry: string }; | |
| 15 | /** The bytes a bundle may not pass: raw on disk, and gzipped over the wire. */ | |
| 16 | export type Ceiling = { raw: number; gzip: number }; | |
| 16 | 17 | |
| 17 | /** Adding a game is one line here plus a route in `src/server.ts`. */ | |
| 18 | /** | |
| 19 | * One game: the name its bundle is filed under, the source it starts from, and | |
| 20 | * the size it may not pass. The ceiling is not optional — a game that ships | |
| 21 | * without a number is a game nobody is holding to one. | |
| 22 | */ | |
| 23 | export type Entry = { name: string; entry: string; ceiling: Ceiling }; | |
| 24 | ||
| 25 | /** | |
| 26 | * Adding a game is one line here plus a route in `src/server.ts`. | |
| 27 | * | |
| 28 | * The ceilings are today's sizes plus about 10%, rounded up to a round number: | |
| 29 | * enough headroom that an honest feature lands without a ceiling edit, tight | |
| 30 | * enough that a stray import of something large trips the test. Measured | |
| 31 | * 2026-09-17: app 68,457 raw / 31,661 gzipped, range 7,067 raw / 3,258 gzipped. | |
| 32 | * Raising a ceiling is allowed, and it is a line in the pull that says what was | |
| 33 | * added and why. | |
| 34 | */ | |
| 18 | 35 | export const ENTRIES: Entry[] = [ |
| 19 | { name: "app", entry: "src/client/main.ts" }, | |
| 20 | { name: "range", entry: "src/range/main.ts" }, | |
| 36 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } }, | |
| 37 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 7_800, gzip: 3_600 } }, | |
| 21 | 38 | ]; |
| 22 | 39 | |
| 40 | /** | |
| 41 | * The one level gzip is ever measured at here. Compression level is the knob | |
| 42 | * that moves the number between machines, so it is pinned: `bun test` on a | |
| 43 | * laptop and on the forge weigh the same bundle the same way. It is not a claim | |
| 44 | * about what a CDN sends — it is a stable ruler. | |
| 45 | */ | |
| 46 | export const GZIP_LEVEL = 9; | |
| 47 | ||
| 48 | /** What a bundle weighs: bytes on disk, and bytes after gzip at `GZIP_LEVEL`. */ | |
| 49 | export function sizes(code: string | Uint8Array): Ceiling { | |
| 50 | const bytes = typeof code === "string" ? new TextEncoder().encode(code) : code; | |
| 51 | return { raw: bytes.byteLength, gzip: Bun.gzipSync(bytes, { level: GZIP_LEVEL }).byteLength }; | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * A sentence naming the bundle, what it measured, what it may not pass and by | |
| 56 | * how much it passed it — in bytes and in percent — or `null` when it fits. | |
| 57 | */ | |
| 58 | export function overshoot(name: string, measured: Ceiling, ceiling: Ceiling): string | null { | |
| 59 | const over = (["raw", "gzip"] as const) | |
| 60 | .filter((kind) => measured[kind] > ceiling[kind]) | |
| 61 | .map((kind) => { | |
| 62 | const by = measured[kind] - ceiling[kind]; | |
| 63 | const percent = ((by / ceiling[kind]) * 100).toFixed(1); | |
| 64 | return `${kind} ${measured[kind]} bytes over the ${ceiling[kind]} byte ceiling by ${by} bytes (${percent}%)`; | |
| 65 | }); | |
| 66 | return over.length === 0 ? null : `${name} is too big: ${over.join("; ")}`; | |
| 67 | } | |
| 68 | ||
| 23 | 69 | // A name becomes part of a file name and part of a regular expression: keep it |
| 24 | 70 | // plain, so neither can be surprised by it. |
| 25 | 71 | const NAME = /^[a-z][a-z0-9-]*$/; |
| @@ -29,14 +75,14 @@ export function isBundleOf(name: string, file: string): boolean { | ||
| 29 | 75 | return NAME.test(name) && new RegExp(`^${name}\\.[0-9a-f]+\\.js$`).test(file); |
| 30 | 76 | } |
| 31 | 77 | |
| 32 | export type Built = { name: string; file: string; code: string }; | |
| 78 | export type Built = { name: string; file: string; code: string; ceiling: Ceiling }; | |
| 33 | 79 | |
| 34 | 80 | /** Bundle every entry and give each the file name its own contents deserve. */ |
| 35 | 81 | export async function bundle(entries: Entry[] = ENTRIES): Promise<Built[]> { |
| 36 | 82 | if (new Set(entries.map((e) => e.name)).size !== entries.length) |
| 37 | 83 | throw new Error("two entries share a name; each bundle needs its own"); |
| 38 | 84 | const built: Built[] = []; |
| 39 | for (const { name, entry } of entries) { | |
| 85 | for (const { name, entry, ceiling } of entries) { | |
| 40 | 86 | if (!NAME.test(name)) throw new Error(`entry name ${JSON.stringify(name)} is not a plain name`); |
| 41 | 87 | const out = await Bun.build({ |
| 42 | 88 | entrypoints: [resolve(ROOT, entry)], |
| @@ -49,7 +95,7 @@ export async function bundle(entries: Entry[] = ENTRIES): Promise<Built[]> { | ||
| 49 | 95 | if (output === undefined) throw new Error(`the bundle for ${name} produced no output`); |
| 50 | 96 | const code = await output.text(); |
| 51 | 97 | const hash = new Bun.CryptoHasher("sha256").update(code).digest("hex").slice(0, 10); |
| 52 | built.push({ name, file: `${name}.${hash}.js`, code }); | |
| 98 | built.push({ name, file: `${name}.${hash}.js`, code, ceiling }); | |
| 53 | 99 | } |
| 54 | 100 | return built; |
| 55 | 101 | } |
| @@ -70,4 +116,12 @@ export async function write(entries: Entry[] = ENTRIES, dir = STATIC_DIR): Promi | ||
| 70 | 116 | return built; |
| 71 | 117 | } |
| 72 | 118 | |
| 73 | if (import.meta.main) for (const { file } of await write()) console.log(`static/${file}`); | |
| 119 | /** `static/app.3f2a9c1b04.js 68457 B raw / 75400 · 31661 B gzip-9 / 34900`. */ | |
| 120 | export function line({ file, code, ceiling }: Built): string { | |
| 121 | const { raw, gzip } = sizes(code); | |
| 122 | return `static/${file} ${raw} B raw / ${ceiling.raw} · ${gzip} B gzip-${GZIP_LEVEL} / ${ceiling.gzip}`; | |
| 123 | } | |
| 124 | ||
| 125 | // The sizes are printed, not enforced, here: the ceiling is a test, so a build | |
| 126 | // that overshoots still writes the file a reviewer can then weigh. | |
| 127 | if (import.meta.main) for (const built of await write()) console.log(line(built)); |
new above
offline