Public: anyone can read this; the crew can change it.

forked from trevato/cs

No generated file lives in git: the bundles are built at boot into FORGE_APP_DATA, and static/ leaves the repository #247

Merged opened this

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

Merged

torque/cs-engine:torque/241-no-generated-file-lives-in-git-the-bundl → main 1 commit fork

  • Merged. torque/241-no-generated-file-lives-in-git-the-bundl was merged into main as 40ed6fe.

Review

Sign in to review this change.
19 files changed +451 −349
.gitignore +1
@@ -1,5 +1,6 @@
11 node_modules/
22 dist/
3 static/
34 *.db
45 *.db-wal
56 *.db-shm
README.md +36 −30
@@ -18,7 +18,7 @@ and the example is the price tag.
1818
1919 ```sh
2020 PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
21 bun run scripts/build.ts # rebuild every static/<name>.<hash>.js, with its size
21 bun run scripts/build.ts [dir] # build every <name>.<hash>.js on disk, with its size (default static/)
2222 bun run scripts/train.ts --iters 1200 # retrain the bots' policy, on 96 maps
2323 bun run scripts/train.ts --iters 1200 --maps 1 # the way it used to train: de_forge only
2424 bun run scripts/train.ts --bench # how fast the arena steps
@@ -409,7 +409,7 @@ line of it. What it added was:
409409 | `src/pages/example.ts` (its page) | 76 lines |
410410 | `src/games.ts` (its registry entry) | 10 lines |
411411 | `src/server.ts` (one line in `RENDER`) | 1 line |
412 | `static/example.<hash>.js` (its bundle) | 8,754 raw / 4,229 gzipped bytes |
412 | `example.<hash>.js` (its bundle) | 8,754 raw / 4,229 gzipped bytes |
413413
414414 That is 198 lines of game, an eighth of the shooter's bundle, and no new branch
415415 in the server. What it did **not** have to write: the loop and its fixed step,
@@ -420,7 +420,7 @@ on, the document it is served in, the header's nav, the cross-links on the
420420 other two pages and its line in the 404 — the last four because they are
421421 projections of `src/games.ts` and nobody edited them. The 2D is the point: no
422422 renderer, no atlas, no mesh and no sound ship in that bundle, and
423 `src/example/bundle.test.ts` reads the committed file to prove it.
423 `src/example/bundle.test.ts` reads the built file to prove it.
424424
425425 ## The one dependency, and why
426426
@@ -710,16 +710,17 @@ never checked itself.
710710 the buy overlay suppressing fire but not movement, a lost pointer, the end of
711711 a match, one POST per round scored and a browser with no WebGL2 are all
712712 checked with no browser in the room. `bundle.test.ts` then does the same to
713 `static/app.<hash>.js`, the file a browser actually downloads: it installs the
713 `app.<hash>.js`, the file a browser actually downloads: it installs the
714714 same stub page as the globals the shipped entry reads for itself and imports
715 the committed bundle, so booting on the page's ids, a click on **play** that
715 the bundle this tree builds, so booting on the page's ids, a click on **play** that
716716 draws a frame through the canvas's own WebGL2 context, the name it writes to
717717 the store, the POST it makes, a page still parsing that is waited for and the
718718 **No WebGL2** sentence are all checked against the artifact rather than the
719719 sources. Finding that artifact and importing it afresh is
720720 `scripts/bundle.test-helper.ts`, one copy of the trick shared with the range's
721 `bundle.test.ts`; neither rebuilds anything, because whether what is committed
722 is what the sources produce now is `scripts/build.test.ts`'s rule. `hud` draws onto a `Paint` — the
721 `bundle.test.ts`; it builds the games once per test process, because nothing
722 is committed for it to read and the artifact under test is what this tree
723 produces now. `hud` draws onto a `Paint` — the
723724 seventeen members of a 2D context it uses — which is how that stub can be a
724725 recorder and the page can still hand it a canvas. `hud.test.ts` drives it
725726 with a recorder of its own that writes down the brush as well as the call, so
@@ -804,8 +805,9 @@ never checked itself.
804805 for, so the room, one draw per standing plate and the tracer are counted — and
805806 a whole run played with the mouse (turn onto the blip the fallback drew, click,
806807 watch the score move) are all checked with no browser in the room. `bundle.test.ts` then does the same to
807 `static/range.<hash>.js` — through the same
808 `scripts/bundle.test-helper.ts` the shooter's uses: the committed bundle is a build artifact kept in git,
808 `range.<hash>.js` — through the same
809 `scripts/bundle.test-helper.ts` the shooter's uses, which builds the games once
810 per test process into a directory of its own,
809811 and this is what checks that the file a browser downloads boots, sizes its
810812 canvases, draws its ticks, answers a key and — given WebGL2 — uploads its
811813 atlas and draws the room — so a bundling mistake fails
@@ -831,7 +833,7 @@ never checked itself.
831833 `src/example/` — and only `src/example/` — name `src/engine/example.ts` by
832834 path. `index.ts` is untouched and the engine's surface did not move.
833835 `boot.test.ts` boots the entry on the engine's headless page and
834 `bundle.test.ts` boots the committed bundle and reads it for what is *not* in
836 `bundle.test.ts` boots the built bundle and reads it for what is *not* in
835837 it (`webgl2`, `AudioContext`, `Math.tanh`): a door is only worth having if
836838 importing it costs what you use.
837839 - `src/rl/` — training, which no game module imports: `arena` is the environment,
@@ -921,8 +923,8 @@ never checked itself.
921923 dropped, so its headers are the ones a `GET` would have answered with. The
922924 level is 6, not the 9 the ceilings in `src/games.ts` are measured at: level 9
923925 costs 67% more CPU for 1.5% fewer bytes on the shooter's bundle, and on the
924 weights it is 192 bytes *worse*. The files in `static/` are read and gzipped
925 **once, at start-up** — 4.5 ms for all four, held beside their raw bytes — so
926 weights it is 192 bytes *worse*. The built files are read and gzipped
927 **once, at start-up** — about 6 ms for all four, held beside their raw bytes — so
926928 a hit on a bundle is a map lookup and a write of bytes that already exist,
927929 never 95 kB of JavaScript compressed again for every visitor with a cold
928930 cache; a page, which is rendered per request, is weighed with it, which costs
@@ -999,11 +1001,11 @@ never checked itself.
9991001 `src/server.ts` renders both once at start-up, so a miss costs a lookup and
10001002 a write. The icon is in the document too — one `<link rel="icon">` holding an
10011003 SVG data URI of the crosshair — so **`/favicon.ico` is a request no browser
1002 makes here**: no file under `static/`, no second round trip, and the CSP
1004 makes here**: no file to serve, no second round trip, and the CSP
10031005 already allows `img-src 'self' data:`.
10041006 - `src/cost.ts` and `src/bytes.ts` — **the numbers `/example` states about
10051007 itself are measured, not typed.** That page's whole claim is what a third game
1006 costs, so `src/cost.ts` weighs the committed bundles in `static/` with
1008 costs, so `src/cost.ts` weighs the bundles this boot built with
10071009 `scripts/build.ts`'s own `weigh` (bytes on disk, and gzip at level 9, the
10081010 pinned ruler the ceilings are held to) and counts the lines of
10091011 `src/engine/example.ts` and of `src/example/`. It runs **once, at start-up**,
@@ -1033,19 +1035,21 @@ never checked itself.
10331035 `/range` is that recipe followed once and `/example` is it followed twice —
10341036 the third game added one entry, one page and one line in `RENDER`, and the
10351037 header, the 404 and the other two pages grew it without being touched.
1036 - `scripts/build.ts` bundles every entry into `static/<name>.<hash>.js`,
1037 committed and cached for a year. Each entry sweeps
1038 up only its own stale bundles, so one
1039 game's rebuild never deletes another's, and `bun test` fails if any committed
1040 bundle is stale — today that is `app` (the shooter), `range` and `example`. The server
1041 resolves the script for an entry by that exact name, and **a game whose bundle
1042 is not on disk stops the server before it listens**: `findScripts` in
1043 `src/server.ts` reads `static/` once at start-up and throws naming the game,
1044 the name it looked for and `bun run scripts/build.ts`, rather than serving a
1045 perfect page around a `<script>` that 404s. Two bundles for one game — a stale
1046 one left beside a new one — is the same failure, because nothing can say which
1047 of them a visitor should run. A preview that deploys without its committed
1048 bundle is a failed deploy whose first line says so.
1038 - **No generated file is in git.** `scripts/build.ts` bundles every entry to
1039 `<name>.<hash>.js`, and `src/server.ts` runs it at start-up, before it
1040 listens, into `FORGE_APP_DATA/static` (`src/bundles.ts` says where) — so the
1041 files a browser downloads are the ones this tree built this boot, cached for
1042 a year under a name that is their content. Three outages in three days were
1043 the other way round: a built file committed, two branches rebuilding it, git
1044 keeping both, and a server that could not say which one to serve. An artifact
1045 that is in no branch cannot collide in a merge, and a preview cannot be
1046 stale. `bun run scripts/build.ts [dir]` runs the same build onto disk for a
1047 person who wants the files to read or weigh — into `static/`, which git
1048 ignores, unless a directory is named. Each entry sweeps up only its own stale
1049 bundles, so one game's rebuild never deletes another's. **A build that fails
1050 stops the server before it listens**, naming the directory it could not write,
1051 and so does a game that resolved to no bundle or to two: a failed deploy whose
1052 first line says so, rather than a perfect page around a `<script>` that 404s.
10491053 - **Every bundle has a ceiling.** Today, and `bun run scripts/build.ts` prints
10501054 these next to each file name:
10511055
@@ -1057,9 +1061,11 @@ never checked itself.
10571061
10581062 The ceilings are today's sizes plus about 10%. Gzip is measured at level 9
10591063 and only at level 9, so the number does not move between a laptop and the
1060 forge. `scripts/build.test.ts` weighs both the committed file in `static/` and
1061 a fresh build of the sources against both ceilings — a stale commit cannot
1062 hide a fat build — and the failure names the bundle, what it measured, what it
1064 forge. `scripts/build.test.ts` builds the sources and weighs both what the
1065 bundler returned and what landed on disk against both ceilings — and the same
1066 test proves two builds of one tree produce the same bytes under the same
1067 names, which is what makes a year-long cache safe. The failure names the
1068 bundle, what it measured, what it
10631069 may not pass and the overshoot in bytes and percent. **Raising a ceiling is
10641070 allowed**: edit the number on that game's entry in `src/games.ts`, where the
10651071 log of what moved each one lives, and say in the pull what was added and why
scripts/build.test.ts +49 −21
@@ -1,5 +1,5 @@
11 import { expect, test } from "bun:test";
2 import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2 import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import {
@@ -14,7 +14,6 @@ import {
1414 line,
1515 overshoot,
1616 sizes,
17 STATIC_DIR,
1817 weigh,
1918 write,
2019 type Entry,
@@ -142,24 +141,47 @@ test("a bundle that fits says nothing, and one that does not names the overshoot
142141 expect(both).toContain("gzip 501 bytes over");
143142 });
144143
144 test("two builds of one tree produce the same bytes and the same names", async () => {
145 // Nothing is committed any more, so this is what makes a deploy reproducible:
146 // every process that boots this tree serves files under the same names, and a
147 // browser's year-long cache of `app.<hash>.js` stays correct across deploys
148 // that changed nothing. Bun's output is byte-stable here — the whole `code`
149 // is compared, not just the hash of it — and if that ever stops being true
150 // this fails with the two bundles in hand rather than in a preview.
151 const first = await bundle();
152 const second = await bundle();
153 expect(second.map((b) => b.file)).toEqual(first.map((b) => b.file));
154 expect(second.map((b) => b.code)).toEqual(first.map((b) => b.code));
155 // The assets too, which are copies and must hash to what their source holds.
156 expect(collect().map((b) => b.file)).toEqual(collect().map((b) => b.file));
157 }, 60_000);
158
145159 test("a fresh build of every entry is under its ceiling", async () => {
146160 // The sources as they are now: a fat import fails here on the first build,
147 // before anyone has committed the bundle it produced.
161 // before anyone has deployed the bundle it produced.
148162 for (const { name, code, ceiling } of await bundle()) {
149163 expect(overshoot(`${name} (fresh build)`, sizes(code), ceiling)).toBeNull();
150164 }
151165 }, 60_000);
152166
153 test("the committed bundle of every entry is under its ceiling", () => {
154 // The bytes a browser downloads today. Measuring only the fresh build would
155 // let a stale, fat commit sit in static/ and pass.
156 for (const { name, ceiling } of ENTRIES) {
157 const files = readdirSync(STATIC_DIR).filter((file) => isBundleOf(name, file));
158 expect(files.length).toBe(1);
159 const bytes = readFileSync(join(STATIC_DIR, files[0] as string));
160 expect(overshoot(`static/${files[0]} (committed)`, sizes(bytes), ceiling)).toBeNull();
167 test("the bundle every entry is written as is under its ceiling, weighed on disk", async () => {
168 // The bytes a browser downloads: the file, not the string the bundler handed
169 // back. Nothing is committed any more, so this writes the build to a
170 // directory of its own — exactly as a boot writes it to FORGE_APP_DATA — and
171 // weighs what landed there.
172 const dir = mkdtempSync(join(tmpdir(), "ceilings-"));
173 try {
174 await write(ENTRIES, dir, []);
175 for (const { name, ceiling } of ENTRIES) {
176 const files = readdirSync(dir).filter((file) => isBundleOf(name, file));
177 expect(files.length).toBe(1);
178 const bytes = readFileSync(join(dir, files[0] as string));
179 expect(overshoot(`${files[0]} (on disk)`, sizes(bytes), ceiling)).toBeNull();
180 }
181 } finally {
182 rmSync(dir, { recursive: true, force: true });
161183 }
162 });
184 }, 60_000);
163185
164186 test("weighing a committed bundle reads the file and builds nothing", async () => {
165187 const { dir, entries } = fixture();
@@ -204,15 +226,21 @@ test("an asset is copied verbatim, stamped by its own contents, and swept when i
204226 expect(() => assetOf(asset, [first?.file as string, second?.file as string], dir)).toThrow(/has 2 assets/);
205227 });
206228
207 test("the committed asset of every game is under its ceiling, and is what its source holds", () => {
208 const files = readdirSync(STATIC_DIR);
209 for (const asset of ASSETS) {
210 const file = assetOf(asset, files);
211 const bytes = readFileSync(join(STATIC_DIR, file));
212 expect(overshoot(`static/${file} (committed)`, sizes(bytes), asset.ceiling)).toBeNull();
213 // Byte for byte the file the trainer wrote: a stale copy in static/ is a
214 // policy nobody trained, served to every player.
215 expect(bytes.toString("utf8")).toBe(collect([asset])[0]?.code as string);
229 test("the asset every game declares is under its ceiling, and is what its source holds", async () => {
230 const dir = mkdtempSync(join(tmpdir(), "assets-ceilings-"));
231 try {
232 await write([], dir);
233 const files = readdirSync(dir);
234 for (const asset of ASSETS) {
235 const file = assetOf(asset, files, dir);
236 const bytes = readFileSync(join(dir, file));
237 expect(overshoot(`${file} (on disk)`, sizes(bytes), asset.ceiling)).toBeNull();
238 // Byte for byte the file the trainer wrote: a copy that is not the source
239 // is a policy nobody trained, served to every player.
240 expect(bytes.toString("utf8")).toBe(collect([asset])[0]?.code as string);
241 }
242 } finally {
243 rmSync(dir, { recursive: true, force: true });
216244 }
217245 });
218246
scripts/build.ts +48 −27
@@ -1,11 +1,14 @@
11 /**
2 * The build: every game in the registry, bundled to `<name>.<hash>.js` in
3 * static/, and every asset a game declares beside it, copied to
2 * The build: every game in the registry, bundled to `<name>.<hash>.js` in a
3 * directory, and every asset a game declares beside it, copied to
44 * `<name>.<hash>.<ext>` the same way.
5 * `bun run build` writes them; the test compares what is committed against what
6 * the sources produce now, so a stale bundle fails the check instead of the
7 * browser. Each entry also carries the size it may not pass, and `build.test.ts`
8 * weighs both the committed file and a fresh build against it.
5 *
6 * Nothing it writes is committed. `src/server.ts` calls `write` at start-up,
7 * into `FORGE_APP_DATA/static` (`src/bundles.ts` says where), and serves what
8 * came out; `bun run scripts/build.ts` runs the same build into the repository's
9 * own `static/` — ignored by git — for a person who wants the files on disk to
10 * read, weigh or serve another way. Each entry carries the size it may not pass,
11 * and `build.test.ts` builds and weighs it.
912 *
1013 * A bundle and an asset are the same thing here — bytes filed under a name and
1114 * their own content hash, swept when they change and held to a ceiling — and
@@ -18,6 +21,12 @@ import { extname, join, resolve } from "node:path";
1821 import { type Asset, type Ceiling, GAMES } from "../src/games.ts";
1922
2023 const ROOT = join(import.meta.dir, "..");
24 /**
25 * Where `bun run scripts/build.ts` writes when nobody says otherwise: the
26 * repository's own `static/`, which git ignores. The server never uses it — it
27 * passes the directory it wants, under `FORGE_APP_DATA` — and every function
28 * here that takes a `dir` or a `where` takes it for the same reason.
29 */
2130 export const STATIC_DIR = join(ROOT, "static");
2231
2332 export type { Asset, Ceiling };
@@ -82,25 +91,25 @@ export function overshoot(name: string, measured: Ceiling, ceiling: Ceiling): st
8291 // plain, so neither can be surprised by it.
8392 const NAME = /^[a-z][a-z0-9-]*$/;
8493
85 /** Is this file in static/ filed under that name and extension — `app.3f2a9c1b04.js` of `app`? */
94 /** Is this file filed under that name and extension — `app.3f2a9c1b04.js` of `app`? */
8695 export function isNamed(name: string, ext: string, file: string): boolean {
8796 return NAME.test(name) && NAME.test(ext) && new RegExp(`^${name}\\.[0-9a-f]+\\.${ext}$`).test(file);
8897 }
8998
90 /** Is this file in static/ a bundle of that entry — `app.3f2a9c1b04.js` of `app`? */
99 /** Is this file a bundle of that entry — `app.3f2a9c1b04.js` of `app`? */
91100 export const isBundleOf = (name: string, file: string): boolean => isNamed(name, "js", file);
92101
93102 /**
94103 * The one `<name>.<hash>.<ext>` among a directory's `files`, or a sentence
95 * saying what to do about it: none means nobody built or committed it, and two
96 * means a stale one was left behind and whoever picks between them picks at
97 * random. `what` is the word the message uses for it — a bundle, an asset —
98 * and `where` is the directory those names came from, read out in the message
99 * and nowhere else.
104 * saying what to do about it: none means nothing built it, and two means a
105 * stale one was left behind and whoever picks between them picks at random.
106 * `what` is the word the message uses for it — a bundle, an asset — and `where`
107 * is the directory those names came from, read out in the message and nowhere
108 * else.
100109 *
101110 * It is here rather than beside either caller because both — the server, which
102111 * refuses to boot without every game's files, and the tests, which boot the
103 * committed ones — are asking the same question of the same directory, and one
112 * built ones — are asking the same question of the same directory, and one
104113 * answer cannot drift from itself.
105114 */
106115 export function oneNamed(
@@ -113,7 +122,7 @@ export function oneNamed(
113122 const mine = files.filter((file) => isNamed(name, ext, file));
114123 const first = mine[0];
115124 if (mine.length === 1 && first !== undefined) return first;
116 const build = "run `bun run scripts/build.ts` and commit what it writes";
125 const build = "run `bun run scripts/build.ts` to write them there";
117126 throw new Error(
118127 mine.length === 0
119128 ? `the game ${JSON.stringify(name)} has no ${what}: nothing in ${where} is named ${name}.<hash>.${ext} — ${build}`
@@ -131,11 +140,11 @@ export const assetOf = (asset: Asset, files: readonly string[], where = STATIC_D
131140 oneNamed(asset.name, extOf(asset), files, where, "asset");
132141
133142 /**
134 * What the committed bundle of `name` weighs right now: the one
143 * What the built bundle of `name` weighs right now: the one
135144 * `<name>.<hash>.js` in `dir`, on disk and gzipped at `GZIP_LEVEL`. It builds
136 * nothing — this is the file a browser downloads, weighed by the same ruler the
137 * ceilings are held to, so a page that quotes a size and a test that checks a
138 * ceiling are reading one number.
145 * nothing itself — this is the file a browser downloads, already written,
146 * weighed by the same ruler the ceilings are held to, so a page that quotes a
147 * size and a test that checks a ceiling are reading one number.
139148 *
140149 * A missing or duplicated bundle throws here, through `bundleOf`, with the
141150 * sentence that says what to run about it.
@@ -176,7 +185,7 @@ export async function bundle(entries: Entry[] = ENTRIES): Promise<Built[]> {
176185
177186 /**
178187 * Every declared asset, read off disk and stamped with its own content hash —
179 * verbatim, because the bytes in static/ are the bytes the file holds. A
188 * verbatim, because the bytes served are the bytes the file holds. A
180189 * source that is not there throws here, naming the file, rather than deploying
181190 * a page that links to nothing.
182191 */
@@ -193,8 +202,8 @@ export function collect(assets: Asset[] = ASSETS, root = ROOT): Built[] {
193202 /**
194203 * Write every bundle and every asset, and sweep up after each name and
195204 * extension only: a stale `app.*.js` goes, another game's bundle beside it
196 * stays, and so does `weights.*.json`. Anything else in static/ is left where
197 * it is.
205 * stays, and so does `weights.*.json`. Anything else in the directory is left
206 * where it is.
198207 */
199208 export async function write(
200209 entries: Entry[] = ENTRIES,
@@ -213,11 +222,23 @@ export async function write(
213222 }
214223
215224 /** `static/app.3f2a9c1b04.js 68457 B raw / 75400 · 31661 B gzip-9 / 34900`. */
216 export function line({ file, code, ceiling }: Built): string {
225 export function line({ file, code, ceiling }: Built, where = "static"): string {
217226 const { raw, gzip } = sizes(code);
218 return `static/${file} ${raw} B raw / ${ceiling.raw} · ${gzip} B gzip-${GZIP_LEVEL} / ${ceiling.gzip}`;
227 return `${where}/${file} ${raw} B raw / ${ceiling.raw} · ${gzip} B gzip-${GZIP_LEVEL} / ${ceiling.gzip}`;
219228 }
220229
221 // The sizes are printed, not enforced, here: the ceiling is a test, so a build
222 // that overshoots still writes the file a reviewer can then weigh.
223 if (import.meta.main) for (const built of await write()) console.log(line(built));
230 /**
231 * Run as a program: build into the directory named on the command line, or into
232 * the repository's `static/` when none is. Nothing needs this to deploy — the
233 * server builds its own — and it is here for a person who wants the files where
234 * they can read them, and for `src/engine/bundles.test.ts`, which asks a child
235 * process for the bundles because the engine may import nothing outside itself.
236 *
237 * The sizes are printed, not enforced: the ceiling is a test, so a build that
238 * overshoots still writes the file a reviewer can then weigh.
239 */
240 if (import.meta.main) {
241 const asked = process.argv[2];
242 const dir = asked === undefined ? STATIC_DIR : resolve(asked);
243 for (const built of await write(ENTRIES, dir)) console.log(line(built, asked === undefined ? "static" : dir));
244 }
scripts/bundle.test-helper.ts +28 −14
@@ -1,13 +1,20 @@
11 /**
2 * The committed bundle, found and imported. Both games boot their minified file
3 * in a test — `src/range/bundle.test.ts` and `src/client/bundle.test.ts` — and
4 * this is the step the two share: which file in static/ is this game's bundle,
5 * and how to import it more than once when the entry boots itself on the way in.
2 * This tree's bundle, built and imported. All three games boot their minified
3 * file in a test — `src/client/bundle.test.ts`, `src/range/bundle.test.ts` and
4 * `src/example/bundle.test.ts` — and this is the step they share: where this
5 * game's bundle is, and how to import it more than once when the entry boots
6 * itself on the way in.
67 *
7 * It builds nothing. Whether what is committed is what the sources produce now
8 * is `scripts/build.test.ts`'s question, and a test that rebuilt the file first
9 * would prove nothing about the one a browser downloads. Everything here only
10 * ever reads static/ and copies out of it.
8 * It builds, once, into a directory of its own, because there is no committed
9 * bundle to read any more: `src/server.ts` builds into `FORGE_APP_DATA` at
10 * start-up and nothing generated is in the repository. So the artifact under
11 * test is the one this tree produces, which is what a browser downloads from a
12 * deploy of this tree — the question `scripts/build.test.ts` used to ask of a
13 * committed file, answered by not having one.
14 *
15 * The build happens at import, once per test process (about 70 ms for all three
16 * games), and every game's bundle comes out of that one build: three test files
17 * sharing this module share the directory it wrote.
1118 *
1219 * It ships in no bundle: only `.test.ts` files import it, which is why
1320 * `src/independence.test.ts` lets a range test reach for it and a range module
@@ -18,29 +25,36 @@ import { copyFileSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
1825 import { tmpdir } from "node:os";
1926 import { join } from "node:path";
2027 import { pathToFileURL } from "node:url";
21 import { bundleOf, STATIC_DIR } from "./build.ts";
28 import { bundleOf, write } from "./build.ts";
29
30 /** Where this process's build went: a throwaway directory, never the repository. */
31 export const BUILT_DIR = mkdtempSync(join(tmpdir(), "built-"));
32
33 // Top level, so `bundlePath` below can stay the synchronous name lookup its
34 // callers read a file with: by the time any of them runs, the files are there.
35 await write(undefined, BUILT_DIR);
2236
2337 /**
2438 * Where the one `<name>.<hash>.js` in `dir` is. Which file that is — and what is
2539 * said when there is no such file, or two of them — is `bundleOf` in
2640 * `./build.ts`: the test boots the file the server would refuse to boot without.
2741 */
28 export function bundlePath(name: string, dir = STATIC_DIR): string {
42 export function bundlePath(name: string, dir = BUILT_DIR): string {
2943 return join(dir, bundleOf(name, readdirSync(dir), dir));
3044 }
3145
3246 // An import is cached per specifier, so an entry would boot itself once and
3347 // every later boot would come out of the module cache. Two defences: a
3448 // cache-busting query, and — because Bun keys that cache by path and ignores
35 // the query — a copy of the committed bytes at a path of its own each time.
49 // the query — a copy of the built bytes at a path of its own each time.
3650 // The copies go to a throwaway directory, never into the repository.
3751 let copies: string | null = null;
3852 let loads = 0;
3953
4054 /**
41 * Import the committed bundle of `name` as if for the first time: the entry
42 * runs, which for both games means it looks for a page and boots on it. Install
43 * the stub page *before* awaiting this.
55 * Import the bundle of `name` as if for the first time: the entry runs, which
56 * for every game here means it looks for a page and boots on it. Install the
57 * stub page *before* awaiting this.
4458 */
4559 export async function importBundle(name: string): Promise<unknown> {
4660 if (copies === null) copies = mkdtempSync(join(tmpdir(), "bundle-"));
src/bundles.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * Where this deployment's bundles are, and the build that puts them there.
3 *
4 * They are generated files, so they are generated: `src/server.ts` calls
5 * `buildBundles()` before it binds, into `FORGE_APP_DATA/static`, and serves
6 * what came out. Nothing built is committed — three outages in three days were
7 * one cause, two branches each rebuilding `static/app.<hash>.js` and git
8 * keeping both, and an artifact that is never in git cannot collide in it.
9 *
10 * It is also why a preview can never be stale: the process that answers a
11 * request built the files it answers with, from the tree it was deployed from,
12 * this boot.
13 *
14 * The build itself is `scripts/build.ts` — one bundler call per game and one
15 * copy per asset, with the naming, the sweeping and the ceilings — and this
16 * module is only the two things the server adds to it: which directory, and
17 * what a failure says. A build that fails throws here, naming the directory,
18 * and the process stops on it exactly as a missing bundle stopped it before:
19 * a deploy that cannot build is a failed deploy, not a server listening with
20 * nothing to serve.
21 */
22
23 import { join } from "node:path";
24 import { type Built, ENTRIES, sizes, write } from "../scripts/build.ts";
25 import { kB } from "./bytes.ts";
26
27 /**
28 * The directory the bundles are built into and served from: `static/` under
29 * this deployment's data directory, which production and every preview each
30 * have their own of. The default is `./data` like the database's, so a checkout
31 * with no forge around it still runs.
32 *
33 * It is a subdirectory rather than the data directory itself because the
34 * database lives there too, and the server holds every file in this one in
35 * memory at start-up.
36 */
37 export const bundlesDir = (data = process.env.FORGE_APP_DATA ?? "./data"): string => join(data, "static");
38
39 /**
40 * Build every game's bundle and every declared asset into `dir`, and hand back
41 * what was written. A failure — an unwritable directory, a source that does not
42 * compile — is re-thrown with the directory named, because the message a
43 * deploy's log shows should say where it was trying to write.
44 */
45 export async function buildBundles(dir = bundlesDir()): Promise<Built[]> {
46 try {
47 return await write(ENTRIES, dir);
48 } catch (cause) {
49 const why = cause instanceof Error ? cause.message : String(cause);
50 throw new Error(`the bundles could not be built into ${dir}: ${why}`, { cause });
51 }
52 }
53
54 /**
55 * What the build cost this boot, for the log: how many files, what they weigh
56 * on disk, and the milliseconds it took. The gzip of them is weighed separately
57 * when they are held, and `src/server.ts` prints that line beside this one.
58 */
59 export const buildSummary = (built: readonly Built[], ms: number, dir = bundlesDir()): string =>
60 `${built.length} files built into ${dir}, ` +
61 `${kB(built.reduce((total, { code }) => total + sizes(code).raw, 0))} raw, in ${ms.toFixed(1)} ms`;
src/cost.ts +12 −9
@@ -4,23 +4,24 @@
44 * `/example` exists to say what a third game on this engine is worth in bytes
55 * and in lines. A number typed into that prose is wrong the first time a bundle
66 * 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.
7 * still — so nothing here is typed: the bundle sizes are the files this boot's
8 * build wrote, weighed by `scripts/build.ts`'s own ruler, and the line counts
9 * are the sources read and counted.
1010 *
1111 * It reads files, so it is called once, at start-up, by `src/server.ts`, and
1212 * the result is handed to the page. A page that measured itself per request
1313 * would do file I/O on the request path for a number that cannot change while
1414 * this process lives.
1515 *
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.
16 * It describes what *this* process serves: the bundle this deployment built
17 * into `FORGE_APP_DATA` at start-up and the sources in this deployment's tree,
18 * which is the honest thing for a page to claim about its own download.
1919 */
2020
2121 import { readFileSync } from "node:fs";
2222 import { join } from "node:path";
2323 import { weigh } from "../scripts/build.ts";
24 import { bundlesDir } from "./bundles.ts";
2425 import type { Ceiling } from "./games.ts";
2526
2627 /**
@@ -64,10 +65,12 @@ export function lineCount(text: string): number {
6465 const linesOf = (root: string, file: string): number => lineCount(readFileSync(join(root, file), "utf8"));
6566
6667 /**
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.
68 * Weigh and count, now. `dir` is where the bundles were built — the server
69 * hands it the directory it built into a moment earlier — and `root` is the
70 * tree the sources are counted from, an argument so a test can point this at
71 * one of its own.
6972 */
70 export function measureExample(root = ROOT, dir?: string): Cost {
73 export function measureExample(dir = bundlesDir(), root = ROOT): Cost {
7174 return {
7275 script: weigh("example", dir),
7376 shooter: weigh("app", dir),
src/engine/bundles.test.ts added +52
@@ -0,0 +1,52 @@
1 /**
2 * The bundles a browser downloads, for the engine's tests that read them.
3 *
4 * Three tests here ask the same question of the shipped artifact — does a file
5 * a player downloads carry the headless page (`page.test.ts`), the trainer
6 * (`learn.test.ts`) or the digest (`digest.test.ts`)? — and none of them can
7 * build one: the engine imports nothing outside itself, and the build, the
8 * registry of games and the entry paths are all outside. They used to read the
9 * committed files in `static/`; nothing is committed any more, so the bundles
10 * are built here instead, in a child process, exactly the way `purity.test.ts`
11 * runs its probe. A command is not an import, and the door stays shut.
12 *
13 * One build serves all three: it is memoised per process, so `bun test` pays
14 * for it once (about 80 ms, Bun's start-up included) however many tests ask.
15 */
16
17 import { expect, test } from "bun:test";
18 import { mkdtempSync, readdirSync } from "node:fs";
19 import { tmpdir } from "node:os";
20 import { join } from "node:path";
21
22 /** The repository's build script, run rather than imported. */
23 const BUILD = join(import.meta.dir, "..", "..", "scripts", "build.ts");
24
25 let dir: string | null = null;
26
27 /**
28 * Every `<name>.<hash>.js` this tree builds, and the throwaway directory they
29 * were built into. A build that fails throws here with the child's own output,
30 * because a test that silently read an empty directory would pass.
31 */
32 export function builtBundles(): { dir: string; bundles: string[] } {
33 if (dir === null) {
34 const into = mkdtempSync(join(tmpdir(), "engine-bundles-"));
35 const run = Bun.spawnSync([process.execPath, "run", BUILD, into], { stdout: "pipe", stderr: "pipe" });
36 if (!run.success)
37 throw new Error(`the build exited ${run.exitCode}:\n${run.stderr.toString()}${run.stdout.toString()}`);
38 dir = into;
39 }
40 return { dir, bundles: readdirSync(dir).filter((file) => file.endsWith(".js")) };
41 }
42
43 test("the build hands every game's bundle over, and the reader is alive", () => {
44 const { bundles } = builtBundles();
45 // Every game's entry, so no test below passes on an empty directory.
46 expect(bundles.length).toBeGreaterThanOrEqual(3);
47 expect(bundles.some((file) => file.startsWith("app."))).toBe(true);
48 expect(bundles.some((file) => file.startsWith("range."))).toBe(true);
49 expect(bundles.some((file) => file.startsWith("example."))).toBe(true);
50 // Named by content, which is what lets them be cached for a year.
51 for (const file of bundles) expect(file).toMatch(/^[a-z][a-z0-9-]*\.[0-9a-f]+\.js$/);
52 });
src/engine/digest.test.ts +7 −8
@@ -16,14 +16,14 @@
1616 */
1717
1818 import { expect, test } from "bun:test";
19 import { readFileSync, readdirSync } from "node:fs";
20 import { join, resolve } from "node:path";
19 import { readFileSync } from "node:fs";
20 import { join } from "node:path";
21 import { builtBundles } from "./bundles.test.ts";
2122 import {
2223 CHECKPOINT_CHARS, compare, fold, quantise, QUANTUM, UNBUNDLED, type Complaint, type Folded,
2324 type Pin,
2425 } from "./digest.ts";
2526
26 const ROOT = resolve(import.meta.dir, "..", "..");
2727
2828 // --- a state that is not a game -------------------------------------------
2929
@@ -224,14 +224,13 @@ test("a pin with more checkpoints than the run is a divergence, and names what i
224224
225225 /**
226226 * The door re-exports a module no browser will ever load, and this is what says
227 * so: the committed bundles, read the way `page.test.ts` and `learn.test.ts`
228 * read them. A game plays; a digest is how a test says the game still plays the
227 * so: the bundles this tree builds, read the way `page.test.ts` and
228 * `learn.test.ts` read them. A game plays; a digest is how a test says the game still plays the
229229 * same way. If a byte of this lands in a file a browser downloads, the design
230230 * is wrong and not this test.
231231 */
232 test("no committed bundle carries the fold", () => {
233 const dir = join(ROOT, "static");
234 const bundles = readdirSync(dir).filter((file) => file.endsWith(".js"));
232 test("no bundle this tree builds carries the fold", () => {
233 const { dir, bundles } = builtBundles();
235234 // Every game's entry, so this is not a test that passes on an empty directory.
236235 expect(bundles.length).toBeGreaterThanOrEqual(3);
237236 expect(bundles.some((f) => f.startsWith("app."))).toBe(true);
src/engine/learn.test.ts +6 −6
@@ -6,8 +6,9 @@
66 */
77
88 import { expect, test } from "bun:test";
9 import { readFileSync, readdirSync } from "node:fs";
10 import { join, resolve } from "node:path";
9 import { readFileSync } from "node:fs";
10 import { join } from "node:path";
11 import { builtBundles } from "./bundles.test.ts";
1112 import {
1213 applyGrads, backward, computeAdvantages, createAdam, createPolicy, createRollout, forward,
1314 newActivations, rng, sample, tensorsOf, update, zeroGrads, DEFAULT_HYPER,
@@ -179,13 +180,12 @@ test("the same seed trains the same way, and another seed learns too", () => {
179180
180181 /**
181182 * The door re-exports a module no browser will ever load, and this is what says
182 * so: the committed bundles, read the way `page.test.ts` reads them. A game
183 * so: the bundles this tree builds, read the way `page.test.ts` reads them. A game
183184 * ships weights and runs them; if a byte of the trainer lands in a bundle the
184185 * design is wrong, not this test.
185186 */
186 test("no committed bundle carries the trainer", () => {
187 const dir = join(resolve(import.meta.dir, "..", ".."), "static");
188 const bundles = readdirSync(dir).filter((file) => file.endsWith(".js"));
187 test("no bundle this tree builds carries the trainer", () => {
188 const { dir, bundles } = builtBundles();
189189 expect(bundles.length).toBeGreaterThanOrEqual(3);
190190 for (const file of bundles) {
191191 const code = readFileSync(join(dir, file), "utf8");
src/engine/page.test.ts +5 −6
@@ -14,14 +14,14 @@
1414 */
1515
1616 import { expect, test } from "bun:test";
17 import { readFileSync, readdirSync } from "node:fs";
18 import { join, resolve } from "node:path";
17 import { readFileSync } from "node:fs";
18 import { join } from "node:path";
19 import { builtBundles } from "./bundles.test.ts";
1920 import { attachButtons, createControls } from "./controls.ts";
2021 import { overlay } from "./overlay.ts";
2122 import { HEADLESS, fakePage, type FakePage } from "./page.ts";
2223
2324 const ENGINE = import.meta.dir;
24 const ROOT = resolve(ENGINE, "..", "..");
2525
2626 /** A page shaped like a small game's: two canvases, a note, and two buttons. */
2727 const demo = (): FakePage =>
@@ -309,9 +309,8 @@ test("the module has no timer of its own and touches no global", () => {
309309 expect(code).toContain("export function fakePage");
310310 });
311311
312 test("no committed bundle carries the double", () => {
313 const dir = join(ROOT, "static");
314 const bundles = readdirSync(dir).filter((file) => file.endsWith(".js"));
312 test("no bundle this tree builds carries the double", () => {
313 const { dir, bundles } = builtBundles();
315314 // Every game's entry, so this is not a test that passes on an empty directory.
316315 expect(bundles.length).toBeGreaterThanOrEqual(3);
317316 for (const file of bundles) {
src/pages/example.test.ts +20 −8
@@ -12,12 +12,20 @@
1212 * It measures independently of `src/cost.ts` where it can: the sizes here are
1313 * `readFileSync` plus `Bun.gzipSync` at the pinned level, so a mistake in the
1414 * measuring module is a mismatch here rather than the same wrong number twice.
15 *
16 * Nothing here reads a committed file, because there is no committed bundle to
17 * read: the bundles are built, so this builds them once into a directory of its
18 * own — the same `buildBundles` the server calls at start-up — and weighs what
19 * came out. That is the page's claim anyway, that the numbers are this
20 * deployment's own bundles and not last week's.
1521 */
1622
1723 import { expect, test } from "bun:test";
18 import { readdirSync, readFileSync } from "node:fs";
24 import { mkdtempSync, readdirSync, readFileSync } from "node:fs";
25 import { tmpdir } from "node:os";
1926 import { join } from "node:path";
20 import { bundleOf, GZIP_LEVEL, STATIC_DIR } from "../../scripts/build.ts";
27 import { bundleOf, GZIP_LEVEL } from "../../scripts/build.ts";
28 import { buildBundles } from "../bundles.ts";
2129 import { GZIP_LEVEL as WIRE_LEVEL } from "../compress.ts";
2230 import { bytes, kB } from "../bytes.ts";
2331 import { GAME_SOURCE, lineCount, measureExample, WIRING_SOURCES } from "../cost.ts";
@@ -25,9 +33,13 @@ import { costHtml, examplePage } from "./example.ts";
2533
2634 const ROOT = join(import.meta.dir, "..", "..");
2735
28 /** The committed bundle of a game, weighed here rather than asked for. */
36 /** This tree's bundles, built once here, exactly as a boot builds them. */
37 const BUILT = mkdtempSync(join(tmpdir(), "example-cost-"));
38 await buildBundles(BUILT);
39
40 /** The built bundle of a game, weighed here rather than asked for. */
2941 const weighed = (name: string): { raw: number; gzip: number } => {
30 const file = readFileSync(join(STATIC_DIR, bundleOf(name, readdirSync(STATIC_DIR))));
42 const file = readFileSync(join(BUILT, bundleOf(name, readdirSync(BUILT), BUILT)));
3143 return { raw: file.byteLength, gzip: Bun.gzipSync(file, { level: GZIP_LEVEL }).byteLength };
3244 };
3345
@@ -49,7 +61,7 @@ const rendered = (): string =>
4961 commit: "abc1234",
5062 script: "/static/example.x.js",
5163 ssh: undefined,
52 cost: measureExample(),
64 cost: measureExample(BUILT),
5365 }).replace(/\s+/g, " ");
5466
5567 test("a line count is what an editor would number, trailing newline and all", () => {
@@ -64,7 +76,7 @@ test("a line count is what an editor would number, trailing newline and all", ()
6476 });
6577
6678 test("what the page is handed is what the files weigh and what the sources count", () => {
67 const cost = measureExample();
79 const cost = measureExample(BUILT);
6880 expect(cost.script).toEqual(weighed("example"));
6981 expect(cost.shooter).toEqual(weighed("app"));
7082 expect(cost.game).toBe(linesOf(GAME_SOURCE));
@@ -76,7 +88,7 @@ test("what the page is handed is what the files weigh and what the sources count
7688 expect(cost.wiring).toBeGreaterThan(cost.game);
7789 });
7890
79 test("the bytes the page states are the bytes of the committed bundles", () => {
91 test("the bytes the page states are the bytes of the bundles this tree builds", () => {
8092 const body = rendered();
8193 const script = weighed("example");
8294 const shooter = weighed("app");
@@ -102,7 +114,7 @@ test("no number the page states about itself is one somebody typed", () => {
102114 // Every group of digits in the paragraphs about the cost is one of the
103115 // measured numbers, the gzip level, or the 1,024 the unit is defined by. A
104116 // number typed into this prose fails here.
105 const cost = measureExample();
117 const cost = measureExample(BUILT);
106118 const section = costHtml(cost);
107119 const allowed = new Set([
108120 bytes(cost.script.raw), bytes(cost.shooter.raw),
src/pages/example.ts +2 −2
@@ -69,8 +69,8 @@ export function costHtml(cost: Cost): string {
6969 bar, the <code>m:ss</code> clock and the crosshair come out of the engine's
7070 <code>overlay</code>; the room, the plate and the tracer come out of its renderer.</p>
7171 <p>Every number in those two paragraphs is measured, not typed. The byte counts are this
72 deployment's own committed bundles under <code>static/</code>, read when this server started
73 and gzipped at level ${GZIP_LEVEL} — the ruler <code>scripts/build.ts</code> weighs the
72 deployment's own bundles, built into its data directory when this server started, weighed
73 there and gzipped at level ${GZIP_LEVEL} — the ruler <code>scripts/build.ts</code> weighs the
7474 ceilings with, and a kB here is 1,024 bytes. Your browser really was sent gzip for
7575 this page and for that bundle: <code>src/compress.ts</code> writes the wire at level
7676 ${WIRE_LEVEL} — a hair above the figure here, and the same bytes on the same ruler. The line counts are those source files read the
src/server.test.ts +57 −10
@@ -1,8 +1,9 @@
11 import { expect, test } from "bun:test";
2 import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2 import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import { bundle, STATIC_DIR } from "../scripts/build.ts";
5 import { bundle } from "../scripts/build.ts";
6 import { buildBundles, bundlesDir } from "./bundles.ts";
67 import { bytes, kB } from "./bytes.ts";
78 import { measureExample } from "./cost.ts";
89 import { GAMES } from "./games.ts";
@@ -18,6 +19,9 @@ import { EXAMPLE_ACTIONS, EXAMPLE_IDS } from "./example/stage.test-helper.ts";
1819
1920 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
2021 process.env.PORT = "0";
22 /** This tree, and the directory the server under test built its bundles into. */
23 const ROOT = join(import.meta.dir, "..");
24 const BUNDLES = bundlesDir();
2125 // The test is the forge: it holds the same key and signs identities with it.
2226 const KEY = "the-key-the-forge-signs-with";
2327 process.env.FORGE_APP_IDENTITY_KEY = KEY;
@@ -304,7 +308,7 @@ test("the example page's byte counts are the bundles this server is serving", as
304308 // weighed here. `src/pages/example.test.ts` holds the rest of that sentence —
305309 // this is the wiring, that the server measures at start-up and hands it over.
306310 const body = (await (await fetch(base + "/example")).text()).replace(/\s+/g, " ");
307 const cost = measureExample();
311 const cost = measureExample(BUNDLES);
308312 expect(body).toContain(`is ${bytes(cost.script.raw)} bytes`);
309313 expect(body).toContain(`${kB(cost.script.gzip)} of them over the wire`);
310314 expect(body).toContain(`the shooter's ${bytes(cost.shooter.raw)} bytes`);
@@ -749,13 +753,56 @@ test("a bug answers with the floor, not the runtime's page", async () => {
749753 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
750754 });
751755
752 test("the bundle in static/ is the one the sources produce", async () => {
753 // A stale bundle is a browser running last week's game: fail here instead.
754 // Every entry, not the first: a second game goes stale just as quietly.
756 test("every bundle this server serves is the one this tree builds, from under FORGE_APP_DATA", async () => {
757 // The whole of what building at boot buys: the file a browser downloads is
758 // what these sources produce, byte for byte, because this process built it a
759 // moment before it bound. A stale artifact cannot be served because there is
760 // no artifact to go stale.
755761 const built = await bundle();
756762 expect(built.length).toBeGreaterThan(0);
757 const files = readdirSync(STATIC_DIR);
758 for (const { file } of built) expect(files).toContain(file);
763 const written = readdirSync(BUNDLES);
764 for (const { name, file, code } of built) {
765 expect(written).toContain(file);
766 const response = await fetch(`${base}/static/${file}`);
767 expect(response.status).toBe(200);
768 expect(await response.text()).toBe(code);
769 // And it is the file the page points at, not merely a file that exists.
770 expect(findScripts().get(name)).toBe(`/static/${file}`);
771 }
772 });
773
774 test("the directory served out of is under FORGE_APP_DATA, and nothing in the repository is read", async () => {
775 // static/ left the repository: the bundles are generated, so they are
776 // generated, into this deployment's own data directory. Two branches cannot
777 // collide on a file that is in neither of them.
778 expect(BUNDLES).toBe(join(process.env.FORGE_APP_DATA as string, "static"));
779 expect(BUNDLES.startsWith(ROOT + "/")).toBe(false);
780 expect(existsSync(join(ROOT, "static", ".gitkeep"))).toBe(false);
781
782 // A file under the repository's own static/ — where the artifacts used to
783 // live, and where `bun run scripts/build.ts` still writes for a person — is
784 // not something a request can reach. It is put there and taken away again;
785 // the server holds its files from start-up and never opens one per request.
786 const decoy = "decoy.0123456789.js";
787 mkdirSync(join(ROOT, "static"), { recursive: true });
788 writeFileSync(join(ROOT, "static", decoy), "globalThis.pwned = true;\n");
789 try {
790 expect((await fetch(`${base}/static/${decoy}`)).status).toBe(404);
791 } finally {
792 rmSync(join(ROOT, "static", decoy), { force: true });
793 }
794 });
795
796 test("a build that cannot write refuses loudly, naming the directory", async () => {
797 // The refusal a missing bundle used to be, moved one step earlier: a boot
798 // whose data directory cannot be written throws before anything listens,
799 // rather than serving pages whose scripts 404. A path under a *file* is a
800 // directory no permission can make, on any machine this runs on.
801 const file = join(mkdtempSync(join(tmpdir(), "unwritable-")), "data");
802 writeFileSync(file, "not a directory");
803 const where = join(file, "static");
804 expect(buildBundles(where)).rejects.toThrow(`the bundles could not be built into ${where}`);
805 expect(buildBundles(where)).rejects.toThrow(/ENOTDIR|not a directory/i);
759806 });
760807
761808 // The boot contract: a game without its bundle on disk stops the server before
@@ -811,8 +858,8 @@ test("two bundles for one game is a stale one nobody can pick between", () => {
811858
812859 test("the bundle every game is served is the file that is on disk", () => {
813860 // The other end of the contract, read off the running server: what the pages
814 // point at is a name in static/, never a guessed `/static/<name>.js`.
815 const files = readdirSync(STATIC_DIR);
861 // point at is a name the build wrote, never a guessed `/static/<name>.js`.
862 const files = readdirSync(BUNDLES);
816863 for (const [name, script] of findScripts()) {
817864 expect(script.startsWith("/static/")).toBe(true);
818865 expect(files).toContain(script.slice("/static/".length));
src/server.ts +67 −33
@@ -18,15 +18,21 @@
1818 * and the first paint stays two requests, without `'unsafe-inline'` waving in
1919 * every other inline style with it. `src/policy.ts` writes the sentence.
2020 *
21 * Every half of a game is a start-up contract: an entry with no page, an entry
22 * with no bundle in static/, and an entry whose declared asset is not there
23 * each throw before this listens, so none of them is ever a 404 a visitor meets
24 * instead.
21 * The bundles are generated, so this generates them: `src/bundles.ts` builds
22 * every game into `FORGE_APP_DATA/static` before this binds, and the routes
23 * below serve what came out. Nothing built is in the repository, so no two
24 * branches can leave two of a game's bundles behind for this to pick between,
25 * and a preview serves what its own branch builds.
26 *
27 * Every half of a game is a start-up contract: a build that fails, an entry
28 * with no page, an entry with no bundle in that directory, and an entry whose
29 * declared asset is not there each throw before this listens, so none of them
30 * is ever a 404 a visitor meets instead.
2531 *
2632 * Nothing is written to a socket except through `send`: it is the one gate
2733 * where a body meets `accept-encoding` and the one place a HEAD loses its
2834 * body, so gzip is a property of this server and not of a route that
29 * remembered. The files in static/ are read and gzipped once, at start-up, and
35 * remembered. The built files are read and gzipped once, at start-up, and
3036 * held — a hit on a bundle is a map lookup and a write of bytes that already
3137 * exist. `src/compress.ts` holds what is worth compressing and what asked for it.
3238 *
@@ -39,8 +45,9 @@
3945
4046 import { existsSync, readdirSync, readFileSync } from "node:fs";
4147 import { join } from "node:path";
42 import { assetOf, bundleOf, STATIC_DIR } from "../scripts/build.ts";
48 import { assetOf, bundleOf } from "../scripts/build.ts";
4349 import { bind } from "./bind.ts";
50 import { buildBundles, bundlesDir, buildSummary } from "./bundles.ts";
4451 import { kB } from "./bytes.ts";
4552 import { acceptsGzip, type Body, weigh, weighText } from "./compress.ts";
4653 import { measureExample } from "./cost.ts";
@@ -276,24 +283,49 @@ const playerOf = (visitor: Who, session: string, claimed: string): Player =>
276283 ? { userId: "", session, name: cleanName(claimed) }
277284 : { userId: visitor.id, session, name: cleanName(visitor.name) };
278285
286 /**
287 * The build, before anything is resolved and long before anything is served:
288 * every game's bundle and every declared asset, written into this deployment's
289 * `FORGE_APP_DATA/static` by `src/bundles.ts`. Nothing generated is committed,
290 * so this is the only thing that puts a file there, and the files a request is
291 * answered with are the ones this tree produced this boot.
292 *
293 * A build that fails throws here, with the directory named, and the process
294 * stops on it — the same refusal a missing bundle was, moved one step earlier.
295 * A deploy that cannot build its game is a failed deploy, never a page whose
296 * script 404s.
297 *
298 * It is on the deploy's critical path on purpose: measured on the forge it is
299 * tens of milliseconds against a boot that was already a tenth of a second, and
300 * a build after the first request would need a lock and could still serve a
301 * request from a half-written directory.
302 */
303 const BUNDLES = bundlesDir();
304 const builtFrom = Bun.nanoseconds();
305 const built = await buildBundles(BUNDLES);
306 const builtIn = (Bun.nanoseconds() - builtFrom) / 1e6;
307
279308 /**
280309 * The built bundles, one per entry, each named by its content so it can be
281 * cached for a year. The directory is read once, here, and every game must
282 * resolve to a file in it by exact name: with a second game in static/, "the
283 * first file that looks like a bundle" is the wrong game, and with none of them
284 * there a guessed `/static/<name>.js` is a name that is never right.
310 * cached for a year. The directory the build just wrote is read once, here, and
311 * every game must resolve to a file in it by exact name: with a second game
312 * there, "the first file that looks like a bundle" is the wrong game, and with
313 * none of them there a guessed `/static/<name>.js` is a name that is never right.
285314 *
286315 * A game whose bundle is not on disk throws, before `Bun.serve` binds, with the
287316 * game, the name it looked for and the command that writes it — because the
288317 * alternative is a page that renders perfectly, 200 and all, around a script
289318 * that 404s: a black canvas nothing anywhere explains. A missing bundle is a
290319 * failed deploy now rather than a dead page later, the same sentence the read
291 * routes below pass on a game with no page.
320 * routes below pass on a game with no page. With the build above it should be
321 * unreachable, which is the point of leaving it: a build that wrote nothing is
322 * still a refusal and not a dead page.
292323 *
293324 * Exported and taking its directory because the throw is the behaviour: a test
294 * points it at an empty directory, and this module calls it with static/.
325 * points it at an empty directory, and this module calls it with the one the
326 * build wrote into.
295327 */
296 export function findScripts(dir = STATIC_DIR, games = GAMES): Map<string, string> {
328 export function findScripts(dir = BUNDLES, games = GAMES): Map<string, string> {
297329 const files = existsSync(dir) ? readdirSync(dir) : [];
298330 return new Map(games.map(({ name }) => [name, `/static/${bundleOf(name, files, dir)}`]));
299331 }
@@ -310,7 +342,7 @@ const scripts = findScripts();
310342 * the asset's name and the link that carries it is how the entry finds the
311343 * URL without a path written into the client.
312344 */
313 export function findAssets(dir = STATIC_DIR, games = GAMES): Map<string, Linked[]> {
345 export function findAssets(dir = BUNDLES, games = GAMES): Map<string, Linked[]> {
314346 const files = existsSync(dir) ? readdirSync(dir) : [];
315347 return new Map(
316348 games.map(({ name, assets }) => [
@@ -322,27 +354,29 @@ export function findAssets(dir = STATIC_DIR, games = GAMES): Map<string, Linked[
322354 const assets = findAssets();
323355
324356 /**
325 * Everything in static/, read and weighed once, here: the bytes on disk, their
326 * gzip when gzip is worth sending, and the content type the name says they are.
327 * A request for one of them is then a map lookup and a write of bytes that
328 * already exist — no open, no read, no compression on the hot path.
357 * Everything the build wrote, read and weighed once, here: the bytes on disk,
358 * their gzip when gzip is worth sending, and the content type the name says
359 * they are. A request for one of them is then a map lookup and a write of bytes
360 * that already exist — no open, no read, no compression on the hot path.
329361 *
330 * Holding them costs what they weigh. On this tree that is the four committed
331 * files, 162,074 bytes raw and 70,227 gzipped — 158.3 kB and 68.6 kB of this
332 * process — and 4.5 ms of start-up to compress them all. The alternative is
362 * Holding them costs what they weigh. On this tree that is the four built
363 * files, 162,643 bytes raw and 70,488 gzipped — 158.8 kB and 68.8 kB of this
364 * process — and about 6 ms of start-up to compress them all. The alternative is
333365 * 2.5 ms of gzip on every hit of the shooter's bundle, paid again for every
334366 * visitor with a cold cache, which is the wrong trade on a file whose contents
335367 * can never change under its name. The boot log prints what it cost this boot.
336368 *
337369 * Read at start-up and not again, like every other file this module resolves:
338 * the names in static/ are content hashes written by the build before this
339 * process starts, so a file appearing later is a deploy that has not happened.
340 * That also makes the path check the map itself — a key is one file name, so
341 * `/static/../src/server.ts` is a name nothing is filed under, not a path.
370 * the names are content hashes written by the build a few milliseconds earlier
371 * in this same process, so a file appearing later is a deploy that has not
372 * happened. That also makes the path check the map itself — a key is one file
373 * name, so `/static/../src/server.ts` is a name nothing is filed under, not a
374 * path. Nothing under the repository's own `static/` is read here or anywhere
375 * else at request time: that directory is not in the repository at all.
342376 */
343377 export type Held = { body: Body; type: string };
344378
345 export function holdStatic(dir = STATIC_DIR): Map<string, Held> {
379 export function holdStatic(dir = BUNDLES): Map<string, Held> {
346380 const held = new Map<string, Held>();
347381 if (!existsSync(dir)) return held;
348382 for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -369,19 +403,19 @@ const staticSummary = (held = STATIC, ms = heldIn): string => {
369403 raw += body.raw.byteLength;
370404 sent += body.gzip?.byteLength ?? body.raw.byteLength;
371405 }
372 return `${held.size} files in static/, ${kB(raw)} raw / ${kB(sent)} gzipped, weighed in ${ms.toFixed(1)} ms`;
406 return `${held.size} files held, ${kB(raw)} raw / ${kB(sent)} gzipped, weighed in ${ms.toFixed(1)} ms`;
373407 };
374408
375 /** What every file in static/ is served with: a year in any cache, and the floor. */
409 /** What every built file is served with: a year in any cache, and the floor. */
376410 const IMMUTABLE = { "cache-control": "public, max-age=31536000, immutable", ...SECURITY };
377411
378412 /**
379413 * What `/example` says a third game cost, weighed and counted once, here,
380 * against the files this process serves: the committed bundles in static/ and
381 * the sources in this tree. `src/cost.ts` does the reading; the page formats
382 * what it is handed. Per request this is a lookup, not a file.
414 * against the files this process serves: the bundles the build above just
415 * wrote, and the sources in this tree. `src/cost.ts` does the reading; the page
416 * formats what it is handed. Per request this is a lookup, not a file.
383417 */
384 const exampleCost = measureExample();
418 const exampleCost = measureExample(BUNDLES);
385419
386420 /**
387421 * How one game's page is drawn for one request: its document, and any header
@@ -687,7 +721,7 @@ if (listen.unusable !== undefined)
687721
688722 console.log(
689723 `cs listening on http://${server.hostname}:${server.port} (${env}) serving ${[...scripts.values()].join(", ")}` +
690 ` — ${staticSummary()}` +
724 ` — ${buildSummary(built, builtIn, BUNDLES)} — ${staticSummary()}` +
691725 `${identityKey ? "" : " — no FORGE_APP_IDENTITY_KEY: every visitor plays as a guest"}` +
692726 `${planner ? "" : " — no TYPESAFE_API_KEY: the bots plan their rounds locally"}`,
693727 );
static/app.ea37d3064a.js deleted −58

Diff not shown — a line 76,213 characters wide (limit 2,000). −58

View file
static/example.58e2f464cf.js deleted −58

Diff not shown — a line 10,369 characters wide (limit 2,000). −58

View file
static/range.2aa2c09163.js deleted −58

Diff not shown — a line 18,852 characters wide (limit 2,000). −58

View file
static/weights.f18906194d.json deleted −1

Diff not shown — a line 11,119 characters wide (limit 2,000). −1

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs