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

Merged opened this

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

Merged

torque/cs-engine:torque/34-every-bundle-has-a-stated-ceiling-and-bu → main 1 commit fork

  • Merged. torque/34-every-bundle-has-a-stated-ceiling-and-bu was merged into main as b5c49e0.

Review

Sign in to review this change.
3 files changed +173 −23
README.md +20 −3
@@ -9,7 +9,7 @@ both.
99
1010 ```sh
1111 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
1313 bun run scripts/train.ts --iters 1000 # retrain the bots' policy
1414 bun run scripts/train.ts --bench # how fast the arena steps
1515 bun test # the check every pull runs
@@ -190,8 +190,9 @@ page shows is the loop and the controls themselves, as numbers that move.
190190 that list and `src/server.test.ts` pins the served page against the same one,
191191 so the page the game is tested on and the page it is served on cannot drift.
192192 - `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
195196 game's rebuild never deletes another's, and `bun test` fails if any committed
196197 bundle is stale — today that is `app` (the shooter) and `range`. The server
197198 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.
199200 route in `src/server.ts`** that renders it with `scripts["<name>"]` as the
200201 `script` — no inline script, the CSP is `default-src 'self'`. `/range` is that
201202 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.
202219 - No dependencies. Nothing is installed to run this.
203220
204221 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 @@
11 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";
33 import { tmpdir } from "node:os";
44 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 };
620
721 /** 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 } {
923 const dir = mkdtempSync(join(tmpdir(), "build-"));
1024 const source = (name: string, text: string): void => writeFileSync(join(dir, `${name}.ts`), text);
1125 source("app", `export const game = "app";\nconsole.log(game);\n`);
@@ -13,17 +27,17 @@ function fixture(): { dir: string; entries: { name: string; entry: string }[]; s
1327 return {
1428 dir,
1529 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 },
1832 ],
1933 source,
2034 };
2135 }
2236
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", () => {
2438 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 } },
2741 ]);
2842 });
2943
@@ -74,6 +88,71 @@ test("a bundle belongs to one entry by exact name", () => {
7488 test("two entries cannot share a name, and a name is plain", async () => {
7589 const { dir } = fixture();
7690 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`);
79158 });
scripts/build.ts +64 −10
@@ -2,7 +2,8 @@
22 * The build: a table of entries, each bundled to `<name>.<hash>.js` in static/.
33 * `bun run build` writes them; the test compares what is committed against what
44 * 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.
67 */
78
89 import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
@@ -11,15 +12,60 @@ import { join, resolve } from "node:path";
1112 const ROOT = join(import.meta.dir, "..");
1213 export const STATIC_DIR = join(ROOT, "static");
1314
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 };
1617
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 */
1835 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 } },
2138 ];
2239
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
2369 // A name becomes part of a file name and part of a regular expression: keep it
2470 // plain, so neither can be surprised by it.
2571 const NAME = /^[a-z][a-z0-9-]*$/;
@@ -29,14 +75,14 @@ export function isBundleOf(name: string, file: string): boolean {
2975 return NAME.test(name) && new RegExp(`^${name}\\.[0-9a-f]+\\.js$`).test(file);
3076 }
3177
32 export type Built = { name: string; file: string; code: string };
78 export type Built = { name: string; file: string; code: string; ceiling: Ceiling };
3379
3480 /** Bundle every entry and give each the file name its own contents deserve. */
3581 export async function bundle(entries: Entry[] = ENTRIES): Promise<Built[]> {
3682 if (new Set(entries.map((e) => e.name)).size !== entries.length)
3783 throw new Error("two entries share a name; each bundle needs its own");
3884 const built: Built[] = [];
39 for (const { name, entry } of entries) {
85 for (const { name, entry, ceiling } of entries) {
4086 if (!NAME.test(name)) throw new Error(`entry name ${JSON.stringify(name)} is not a plain name`);
4187 const out = await Bun.build({
4288 entrypoints: [resolve(ROOT, entry)],
@@ -49,7 +95,7 @@ export async function bundle(entries: Entry[] = ENTRIES): Promise<Built[]> {
4995 if (output === undefined) throw new Error(`the bundle for ${name} produced no output`);
5096 const code = await output.text();
5197 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 });
5399 }
54100 return built;
55101 }
@@ -70,4 +116,12 @@ export async function write(entries: Entry[] = ENTRIES, dir = STATIC_DIR): Promi
70116 return built;
71117 }
72118
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));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs