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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/241-no-generated-file-lives-in-git-the-bundl → main 1 commit fork
19 files
changed +451
−349
.gitignore +1
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | node_modules/ |
| 2 | 2 | dist/ |
| 3 | static/ | |
| 3 | 4 | *.db |
| 4 | 5 | *.db-wal |
| 5 | 6 | *.db-shm |
README.md +36 −30
| @@ -18,7 +18,7 @@ and the example is the price tag. | ||
| 18 | 18 | |
| 19 | 19 | sh |
| 20 | 20 | 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/) | |
| 22 | 22 | bun run scripts/train.ts --iters 1200 # retrain the bots' policy, on 96 maps |
| 23 | 23 | bun run scripts/train.ts --iters 1200 --maps 1 # the way it used to train: de_forge only |
| 24 | 24 | bun run scripts/train.ts --bench # how fast the arena steps |
| @@ -409,7 +409,7 @@ line of it. What it added was: | ||
| 409 | 409 | | `src/pages/example.ts` (its page) | 76 lines | |
| 410 | 410 | | `src/games.ts` (its registry entry) | 10 lines | |
| 411 | 411 | | `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 | | |
| 413 | 413 | |
| 414 | 414 | That is 198 lines of game, an eighth of the shooter's bundle, and no new branch |
| 415 | 415 | 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 | ||
| 420 | 420 | other two pages and its line in the 404 — the last four because they are |
| 421 | 421 | projections of `src/games.ts` and nobody edited them. The 2D is the point: no |
| 422 | 422 | 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. | |
| 424 | 424 | |
| 425 | 425 | ## The one dependency, and why |
| 426 | 426 | |
| @@ -710,16 +710,17 @@ never checked itself. | ||
| 710 | 710 | the buy overlay suppressing fire but not movement, a lost pointer, the end of |
| 711 | 711 | a match, one POST per round scored and a browser with no WebGL2 are all |
| 712 | 712 | 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 | |
| 714 | 714 | 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 | |
| 716 | 716 | draws a frame through the canvas's own WebGL2 context, the name it writes to |
| 717 | 717 | the store, the POST it makes, a page still parsing that is waited for and the |
| 718 | 718 | **No WebGL2** sentence are all checked against the artifact rather than the |
| 719 | 719 | sources. Finding that artifact and importing it afresh is |
| 720 | 720 | `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 | |
| 723 | 724 | seventeen members of a 2D context it uses — which is how that stub can be a |
| 724 | 725 | recorder and the page can still hand it a canvas. `hud.test.ts` drives it |
| 725 | 726 | with a recorder of its own that writes down the brush as well as the call, so |
| @@ -804,8 +805,9 @@ never checked itself. | ||
| 804 | 805 | for, so the room, one draw per standing plate and the tracer are counted — and |
| 805 | 806 | a whole run played with the mouse (turn onto the blip the fallback drew, click, |
| 806 | 807 | 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, | |
| 809 | 811 | and this is what checks that the file a browser downloads boots, sizes its |
| 810 | 812 | canvases, draws its ticks, answers a key and — given WebGL2 — uploads its |
| 811 | 813 | atlas and draws the room — so a bundling mistake fails |
| @@ -831,7 +833,7 @@ never checked itself. | ||
| 831 | 833 | `src/example/` — and only `src/example/` — name `src/engine/example.ts` by |
| 832 | 834 | path. `index.ts` is untouched and the engine's surface did not move. |
| 833 | 835 | `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 | |
| 835 | 837 | it (`webgl2`, `AudioContext`, `Math.tanh`): a door is only worth having if |
| 836 | 838 | importing it costs what you use. |
| 837 | 839 | - `src/rl/` — training, which no game module imports: `arena` is the environment, |
| @@ -921,8 +923,8 @@ never checked itself. | ||
| 921 | 923 | dropped, so its headers are the ones a `GET` would have answered with. The |
| 922 | 924 | level is 6, not the 9 the ceilings in `src/games.ts` are measured at: level 9 |
| 923 | 925 | 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 | |
| 926 | 928 | a hit on a bundle is a map lookup and a write of bytes that already exist, |
| 927 | 929 | never 95 kB of JavaScript compressed again for every visitor with a cold |
| 928 | 930 | cache; a page, which is rendered per request, is weighed with it, which costs |
| @@ -999,11 +1001,11 @@ never checked itself. | ||
| 999 | 1001 | `src/server.ts` renders both once at start-up, so a miss costs a lookup and |
| 1000 | 1002 | a write. The icon is in the document too — one `<link rel="icon">` holding an |
| 1001 | 1003 | 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 | |
| 1003 | 1005 | already allows `img-src 'self' data:`. |
| 1004 | 1006 | - `src/cost.ts` and `src/bytes.ts` — **the numbers `/example` states about |
| 1005 | 1007 | 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 | |
| 1007 | 1009 | `scripts/build.ts`'s own `weigh` (bytes on disk, and gzip at level 9, the |
| 1008 | 1010 | pinned ruler the ceilings are held to) and counts the lines of |
| 1009 | 1011 | `src/engine/example.ts` and of `src/example/`. It runs **once, at start-up**, |
| @@ -1033,19 +1035,21 @@ never checked itself. | ||
| 1033 | 1035 | `/range` is that recipe followed once and `/example` is it followed twice — |
| 1034 | 1036 | the third game added one entry, one page and one line in `RENDER`, and the |
| 1035 | 1037 | 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. | |
| 1049 | 1053 | - **Every bundle has a ceiling.** Today, and `bun run scripts/build.ts` prints |
| 1050 | 1054 | these next to each file name: |
| 1051 | 1055 | |
| @@ -1057,9 +1061,11 @@ never checked itself. | ||
| 1057 | 1061 | |
| 1058 | 1062 | The ceilings are today's sizes plus about 10%. Gzip is measured at level 9 |
| 1059 | 1063 | 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 | |
| 1063 | 1069 | may not pass and the overshoot in bytes and percent. **Raising a ceiling is |
| 1064 | 1070 | allowed**: edit the number on that game's entry in `src/games.ts`, where the |
| 1065 | 1071 | 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 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { |
| @@ -14,7 +14,6 @@ import { | ||
| 14 | 14 | line, |
| 15 | 15 | overshoot, |
| 16 | 16 | sizes, |
| 17 | STATIC_DIR, | |
| 18 | 17 | weigh, |
| 19 | 18 | write, |
| 20 | 19 | type Entry, |
| @@ -142,24 +141,47 @@ test("a bundle that fits says nothing, and one that does not names the overshoot | ||
| 142 | 141 | expect(both).toContain("gzip 501 bytes over"); |
| 143 | 142 | }); |
| 144 | 143 | |
| 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 | ||
| 145 | 159 | test("a fresh build of every entry is under its ceiling", async () => { |
| 146 | 160 | // 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. | |
| 148 | 162 | for (const { name, code, ceiling } of await bundle()) { |
| 149 | 163 | expect(overshoot(`${name} (fresh build)`, sizes(code), ceiling)).toBeNull(); |
| 150 | 164 | } |
| 151 | 165 | }, 60_000); |
| 152 | 166 | |
| 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 }); | |
| 161 | 183 | } |
| 162 | }); | |
| 184 | }, 60_000); | |
| 163 | 185 | |
| 164 | 186 | test("weighing a committed bundle reads the file and builds nothing", async () => { |
| 165 | 187 | const { dir, entries } = fixture(); |
| @@ -204,15 +226,21 @@ test("an asset is copied verbatim, stamped by its own contents, and swept when i | ||
| 204 | 226 | expect(() => assetOf(asset, [first?.file as string, second?.file as string], dir)).toThrow(/has 2 assets/); |
| 205 | 227 | }); |
| 206 | 228 | |
| 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 }); | |
| 216 | 244 | } |
| 217 | 245 | }); |
| 218 | 246 |
scripts/build.ts +48 −27
| @@ -1,11 +1,14 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 4 | 4 | * `<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. | |
| 9 | 12 | * |
| 10 | 13 | * A bundle and an asset are the same thing here — bytes filed under a name and |
| 11 | 14 | * 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"; | ||
| 18 | 21 | import { type Asset, type Ceiling, GAMES } from "../src/games.ts"; |
| 19 | 22 | |
| 20 | 23 | 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 | */ | |
| 21 | 30 | export const STATIC_DIR = join(ROOT, "static"); |
| 22 | 31 | |
| 23 | 32 | export type { Asset, Ceiling }; |
| @@ -82,25 +91,25 @@ export function overshoot(name: string, measured: Ceiling, ceiling: Ceiling): st | ||
| 82 | 91 | // plain, so neither can be surprised by it. |
| 83 | 92 | const NAME = /^[a-z][a-z0-9-]*$/; |
| 84 | 93 | |
| 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`? */ | |
| 86 | 95 | export function isNamed(name: string, ext: string, file: string): boolean { |
| 87 | 96 | return NAME.test(name) && NAME.test(ext) && new RegExp(`^${name}\\.[0-9a-f]+\\.${ext}$`).test(file); |
| 88 | 97 | } |
| 89 | 98 | |
| 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`? */ | |
| 91 | 100 | export const isBundleOf = (name: string, file: string): boolean => isNamed(name, "js", file); |
| 92 | 101 | |
| 93 | 102 | /** |
| 94 | 103 | * 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. | |
| 100 | 109 | * |
| 101 | 110 | * It is here rather than beside either caller because both — the server, which |
| 102 | 111 | * 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 | |
| 104 | 113 | * answer cannot drift from itself. |
| 105 | 114 | */ |
| 106 | 115 | export function oneNamed( |
| @@ -113,7 +122,7 @@ export function oneNamed( | ||
| 113 | 122 | const mine = files.filter((file) => isNamed(name, ext, file)); |
| 114 | 123 | const first = mine[0]; |
| 115 | 124 | 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"; | |
| 117 | 126 | throw new Error( |
| 118 | 127 | mine.length === 0 |
| 119 | 128 | ? `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 | ||
| 131 | 140 | oneNamed(asset.name, extOf(asset), files, where, "asset"); |
| 132 | 141 | |
| 133 | 142 | /** |
| 134 | * What the committed bundle of `name` weighs right now: the one | |
| 143 | * What the built bundle of `name` weighs right now: the one | |
| 135 | 144 | * `<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. | |
| 139 | 148 | * |
| 140 | 149 | * A missing or duplicated bundle throws here, through `bundleOf`, with the |
| 141 | 150 | * sentence that says what to run about it. |
| @@ -176,7 +185,7 @@ export async function bundle(entries: Entry[] = ENTRIES): Promise<Built[]> { | ||
| 176 | 185 | |
| 177 | 186 | /** |
| 178 | 187 | * 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 | |
| 180 | 189 | * source that is not there throws here, naming the file, rather than deploying |
| 181 | 190 | * a page that links to nothing. |
| 182 | 191 | */ |
| @@ -193,8 +202,8 @@ export function collect(assets: Asset[] = ASSETS, root = ROOT): Built[] { | ||
| 193 | 202 | /** |
| 194 | 203 | * Write every bundle and every asset, and sweep up after each name and |
| 195 | 204 | * 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. | |
| 198 | 207 | */ |
| 199 | 208 | export async function write( |
| 200 | 209 | entries: Entry[] = ENTRIES, |
| @@ -213,11 +222,23 @@ export async function write( | ||
| 213 | 222 | } |
| 214 | 223 | |
| 215 | 224 | /** `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 { | |
| 217 | 226 | 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}`; | |
| 219 | 228 | } |
| 220 | 229 | |
| 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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 6 | 7 | * |
| 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. | |
| 11 | 18 | * |
| 12 | 19 | * It ships in no bundle: only `.test.ts` files import it, which is why |
| 13 | 20 | * `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"; | ||
| 18 | 25 | import { tmpdir } from "node:os"; |
| 19 | 26 | import { join } from "node:path"; |
| 20 | 27 | 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); | |
| 22 | 36 | |
| 23 | 37 | /** |
| 24 | 38 | * Where the one `<name>.<hash>.js` in `dir` is. Which file that is — and what is |
| 25 | 39 | * said when there is no such file, or two of them — is `bundleOf` in |
| 26 | 40 | * `./build.ts`: the test boots the file the server would refuse to boot without. |
| 27 | 41 | */ |
| 28 | export function bundlePath(name: string, dir = STATIC_DIR): string { | |
| 42 | export function bundlePath(name: string, dir = BUILT_DIR): string { | |
| 29 | 43 | return join(dir, bundleOf(name, readdirSync(dir), dir)); |
| 30 | 44 | } |
| 31 | 45 | |
| 32 | 46 | // An import is cached per specifier, so an entry would boot itself once and |
| 33 | 47 | // every later boot would come out of the module cache. Two defences: a |
| 34 | 48 | // 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. | |
| 36 | 50 | // The copies go to a throwaway directory, never into the repository. |
| 37 | 51 | let copies: string | null = null; |
| 38 | 52 | let loads = 0; |
| 39 | 53 | |
| 40 | 54 | /** |
| 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. | |
| 44 | 58 | */ |
| 45 | 59 | export async function importBundle(name: string): Promise<unknown> { |
| 46 | 60 | 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 @@ | ||
| 4 | 4 | * `/example` exists to say what a third game on this engine is worth in bytes |
| 5 | 5 | * and in lines. A number typed into that prose is wrong the first time a bundle |
| 6 | 6 | * grows — the shooter's went from 71,646 to 93,714 while the sentence sat |
| 7 | * still — so nothing here is typed: the bundle sizes are the committed files in | |
| 8 | * static/ weighed by `scripts/build.ts`'s own ruler, and the line counts are | |
| 9 | * the sources read and counted. | |
| 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. | |
| 10 | 10 | * |
| 11 | 11 | * It reads files, so it is called once, at start-up, by `src/server.ts`, and |
| 12 | 12 | * the result is handed to the page. A page that measured itself per request |
| 13 | 13 | * would do file I/O on the request path for a number that cannot change while |
| 14 | 14 | * this process lives. |
| 15 | 15 | * |
| 16 | * It describes what *this* process serves: the bundle in this deployment's | |
| 17 | * static/ and the sources in this deployment's tree, which is the honest thing | |
| 18 | * for a page to claim about its own download. | |
| 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. | |
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | 21 | import { readFileSync } from "node:fs"; |
| 22 | 22 | import { join } from "node:path"; |
| 23 | 23 | import { weigh } from "../scripts/build.ts"; |
| 24 | import { bundlesDir } from "./bundles.ts"; | |
| 24 | 25 | import type { Ceiling } from "./games.ts"; |
| 25 | 26 | |
| 26 | 27 | /** |
| @@ -64,10 +65,12 @@ export function lineCount(text: string): number { | ||
| 64 | 65 | const linesOf = (root: string, file: string): number => lineCount(readFileSync(join(root, file), "utf8")); |
| 65 | 66 | |
| 66 | 67 | /** |
| 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. | |
| 69 | 72 | */ |
| 70 | export function measureExample(root = ROOT, dir?: string): Cost { | |
| 73 | export function measureExample(dir = bundlesDir(), root = ROOT): Cost { | |
| 71 | 74 | return { |
| 72 | 75 | script: weigh("example", dir), |
| 73 | 76 | 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 @@ | ||
| 16 | 16 | */ |
| 17 | 17 | |
| 18 | 18 | 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"; | |
| 21 | 22 | import { |
| 22 | 23 | CHECKPOINT_CHARS, compare, fold, quantise, QUANTUM, UNBUNDLED, type Complaint, type Folded, |
| 23 | 24 | type Pin, |
| 24 | 25 | } from "./digest.ts"; |
| 25 | 26 | |
| 26 | const ROOT = resolve(import.meta.dir, "..", ".."); | |
| 27 | 27 | |
| 28 | 28 | // --- a state that is not a game ------------------------------------------- |
| 29 | 29 | |
| @@ -224,14 +224,13 @@ test("a pin with more checkpoints than the run is a divergence, and names what i | ||
| 224 | 224 | |
| 225 | 225 | /** |
| 226 | 226 | * 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 | |
| 229 | 229 | * same way. If a byte of this lands in a file a browser downloads, the design |
| 230 | 230 | * is wrong and not this test. |
| 231 | 231 | */ |
| 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(); | |
| 235 | 234 | // Every game's entry, so this is not a test that passes on an empty directory. |
| 236 | 235 | expect(bundles.length).toBeGreaterThanOrEqual(3); |
| 237 | 236 | expect(bundles.some((f) => f.startsWith("app."))).toBe(true); |
src/engine/learn.test.ts +6 −6
| @@ -6,8 +6,9 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | 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"; | |
| 11 | 12 | import { |
| 12 | 13 | applyGrads, backward, computeAdvantages, createAdam, createPolicy, createRollout, forward, |
| 13 | 14 | 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", () => { | ||
| 179 | 180 | |
| 180 | 181 | /** |
| 181 | 182 | * 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 | |
| 183 | 184 | * ships weights and runs them; if a byte of the trainer lands in a bundle the |
| 184 | 185 | * design is wrong, not this test. |
| 185 | 186 | */ |
| 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(); | |
| 189 | 189 | expect(bundles.length).toBeGreaterThanOrEqual(3); |
| 190 | 190 | for (const file of bundles) { |
| 191 | 191 | const code = readFileSync(join(dir, file), "utf8"); |
src/engine/page.test.ts +5 −6
| @@ -14,14 +14,14 @@ | ||
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | 16 | 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"; | |
| 19 | 20 | import { attachButtons, createControls } from "./controls.ts"; |
| 20 | 21 | import { overlay } from "./overlay.ts"; |
| 21 | 22 | import { HEADLESS, fakePage, type FakePage } from "./page.ts"; |
| 22 | 23 | |
| 23 | 24 | const ENGINE = import.meta.dir; |
| 24 | const ROOT = resolve(ENGINE, "..", ".."); | |
| 25 | 25 | |
| 26 | 26 | /** A page shaped like a small game's: two canvases, a note, and two buttons. */ |
| 27 | 27 | const demo = (): FakePage => |
| @@ -309,9 +309,8 @@ test("the module has no timer of its own and touches no global", () => { | ||
| 309 | 309 | expect(code).toContain("export function fakePage"); |
| 310 | 310 | }); |
| 311 | 311 | |
| 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(); | |
| 315 | 314 | // Every game's entry, so this is not a test that passes on an empty directory. |
| 316 | 315 | expect(bundles.length).toBeGreaterThanOrEqual(3); |
| 317 | 316 | for (const file of bundles) { |
src/pages/example.test.ts +20 −8
| @@ -12,12 +12,20 @@ | ||
| 12 | 12 | * It measures independently of `src/cost.ts` where it can: the sizes here are |
| 13 | 13 | * `readFileSync` plus `Bun.gzipSync` at the pinned level, so a mistake in the |
| 14 | 14 | * 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. | |
| 15 | 21 | */ |
| 16 | 22 | |
| 17 | 23 | 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"; | |
| 19 | 26 | 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"; | |
| 21 | 29 | import { GZIP_LEVEL as WIRE_LEVEL } from "../compress.ts"; |
| 22 | 30 | import { bytes, kB } from "../bytes.ts"; |
| 23 | 31 | import { GAME_SOURCE, lineCount, measureExample, WIRING_SOURCES } from "../cost.ts"; |
| @@ -25,9 +33,13 @@ import { costHtml, examplePage } from "./example.ts"; | ||
| 25 | 33 | |
| 26 | 34 | const ROOT = join(import.meta.dir, "..", ".."); |
| 27 | 35 | |
| 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. */ | |
| 29 | 41 | 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))); | |
| 31 | 43 | return { raw: file.byteLength, gzip: Bun.gzipSync(file, { level: GZIP_LEVEL }).byteLength }; |
| 32 | 44 | }; |
| 33 | 45 | |
| @@ -49,7 +61,7 @@ const rendered = (): string => | ||
| 49 | 61 | commit: "abc1234", |
| 50 | 62 | script: "/static/example.x.js", |
| 51 | 63 | ssh: undefined, |
| 52 | cost: measureExample(), | |
| 64 | cost: measureExample(BUILT), | |
| 53 | 65 | }).replace(/\s+/g, " "); |
| 54 | 66 | |
| 55 | 67 | 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", () | ||
| 64 | 76 | }); |
| 65 | 77 | |
| 66 | 78 | 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); | |
| 68 | 80 | expect(cost.script).toEqual(weighed("example")); |
| 69 | 81 | expect(cost.shooter).toEqual(weighed("app")); |
| 70 | 82 | 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 | ||
| 76 | 88 | expect(cost.wiring).toBeGreaterThan(cost.game); |
| 77 | 89 | }); |
| 78 | 90 | |
| 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", () => { | |
| 80 | 92 | const body = rendered(); |
| 81 | 93 | const script = weighed("example"); |
| 82 | 94 | const shooter = weighed("app"); |
| @@ -102,7 +114,7 @@ test("no number the page states about itself is one somebody typed", () => { | ||
| 102 | 114 | // Every group of digits in the paragraphs about the cost is one of the |
| 103 | 115 | // measured numbers, the gzip level, or the 1,024 the unit is defined by. A |
| 104 | 116 | // number typed into this prose fails here. |
| 105 | const cost = measureExample(); | |
| 117 | const cost = measureExample(BUILT); | |
| 106 | 118 | const section = costHtml(cost); |
| 107 | 119 | const allowed = new Set([ |
| 108 | 120 | bytes(cost.script.raw), bytes(cost.shooter.raw), |
src/pages/example.ts +2 −2
| @@ -69,8 +69,8 @@ export function costHtml(cost: Cost): string { | ||
| 69 | 69 | bar, the <code>m:ss</code> clock and the crosshair come out of the engine's |
| 70 | 70 | <code>overlay</code>; the room, the plate and the tracer come out of its renderer.</p> |
| 71 | 71 | <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 | |
| 74 | 74 | ceilings with, and a kB here is 1,024 bytes. Your browser really was sent gzip for |
| 75 | 75 | this page and for that bundle: <code>src/compress.ts</code> writes the wire at level |
| 76 | 76 | ${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 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | 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"; | |
| 6 | 7 | import { bytes, kB } from "./bytes.ts"; |
| 7 | 8 | import { measureExample } from "./cost.ts"; |
| 8 | 9 | import { GAMES } from "./games.ts"; |
| @@ -18,6 +19,9 @@ import { EXAMPLE_ACTIONS, EXAMPLE_IDS } from "./example/stage.test-helper.ts"; | ||
| 18 | 19 | |
| 19 | 20 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); |
| 20 | 21 | 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(); | |
| 21 | 25 | // The test is the forge: it holds the same key and signs identities with it. |
| 22 | 26 | const KEY = "the-key-the-forge-signs-with"; |
| 23 | 27 | 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 | ||
| 304 | 308 | // weighed here. `src/pages/example.test.ts` holds the rest of that sentence — |
| 305 | 309 | // this is the wiring, that the server measures at start-up and hands it over. |
| 306 | 310 | const body = (await (await fetch(base + "/example")).text()).replace(/\s+/g, " "); |
| 307 | const cost = measureExample(); | |
| 311 | const cost = measureExample(BUNDLES); | |
| 308 | 312 | expect(body).toContain(`is ${bytes(cost.script.raw)} bytes`); |
| 309 | 313 | expect(body).toContain(`${kB(cost.script.gzip)} of them over the wire`); |
| 310 | 314 | 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 () => { | ||
| 749 | 753 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 750 | 754 | }); |
| 751 | 755 | |
| 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. | |
| 755 | 761 | const built = await bundle(); |
| 756 | 762 | 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); | |
| 759 | 806 | }); |
| 760 | 807 | |
| 761 | 808 | // 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", () => { | ||
| 811 | 858 | |
| 812 | 859 | test("the bundle every game is served is the file that is on disk", () => { |
| 813 | 860 | // 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); | |
| 816 | 863 | for (const [name, script] of findScripts()) { |
| 817 | 864 | expect(script.startsWith("/static/")).toBe(true); |
| 818 | 865 | expect(files).toContain(script.slice("/static/".length)); |
src/server.ts +67 −33
| @@ -18,15 +18,21 @@ | ||
| 18 | 18 | * and the first paint stays two requests, without `'unsafe-inline'` waving in |
| 19 | 19 | * every other inline style with it. `src/policy.ts` writes the sentence. |
| 20 | 20 | * |
| 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. | |
| 25 | 31 | * |
| 26 | 32 | * Nothing is written to a socket except through `send`: it is the one gate |
| 27 | 33 | * where a body meets `accept-encoding` and the one place a HEAD loses its |
| 28 | 34 | * 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 | |
| 30 | 36 | * held — a hit on a bundle is a map lookup and a write of bytes that already |
| 31 | 37 | * exist. `src/compress.ts` holds what is worth compressing and what asked for it. |
| 32 | 38 | * |
| @@ -39,8 +45,9 @@ | ||
| 39 | 45 | |
| 40 | 46 | import { existsSync, readdirSync, readFileSync } from "node:fs"; |
| 41 | 47 | import { join } from "node:path"; |
| 42 | import { assetOf, bundleOf, STATIC_DIR } from "../scripts/build.ts"; | |
| 48 | import { assetOf, bundleOf } from "../scripts/build.ts"; | |
| 43 | 49 | import { bind } from "./bind.ts"; |
| 50 | import { buildBundles, bundlesDir, buildSummary } from "./bundles.ts"; | |
| 44 | 51 | import { kB } from "./bytes.ts"; |
| 45 | 52 | import { acceptsGzip, type Body, weigh, weighText } from "./compress.ts"; |
| 46 | 53 | import { measureExample } from "./cost.ts"; |
| @@ -276,24 +283,49 @@ const playerOf = (visitor: Who, session: string, claimed: string): Player => | ||
| 276 | 283 | ? { userId: "", session, name: cleanName(claimed) } |
| 277 | 284 | : { userId: visitor.id, session, name: cleanName(visitor.name) }; |
| 278 | 285 | |
| 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 | ||
| 279 | 308 | /** |
| 280 | 309 | * 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. | |
| 285 | 314 | * |
| 286 | 315 | * A game whose bundle is not on disk throws, before `Bun.serve` binds, with the |
| 287 | 316 | * game, the name it looked for and the command that writes it — because the |
| 288 | 317 | * alternative is a page that renders perfectly, 200 and all, around a script |
| 289 | 318 | * that 404s: a black canvas nothing anywhere explains. A missing bundle is a |
| 290 | 319 | * 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. | |
| 292 | 323 | * |
| 293 | 324 | * 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. | |
| 295 | 327 | */ |
| 296 | export function findScripts(dir = STATIC_DIR, games = GAMES): Map<string, string> { | |
| 328 | export function findScripts(dir = BUNDLES, games = GAMES): Map<string, string> { | |
| 297 | 329 | const files = existsSync(dir) ? readdirSync(dir) : []; |
| 298 | 330 | return new Map(games.map(({ name }) => [name, `/static/${bundleOf(name, files, dir)}`])); |
| 299 | 331 | } |
| @@ -310,7 +342,7 @@ const scripts = findScripts(); | ||
| 310 | 342 | * the asset's name and the link that carries it is how the entry finds the |
| 311 | 343 | * URL without a path written into the client. |
| 312 | 344 | */ |
| 313 | export function findAssets(dir = STATIC_DIR, games = GAMES): Map<string, Linked[]> { | |
| 345 | export function findAssets(dir = BUNDLES, games = GAMES): Map<string, Linked[]> { | |
| 314 | 346 | const files = existsSync(dir) ? readdirSync(dir) : []; |
| 315 | 347 | return new Map( |
| 316 | 348 | games.map(({ name, assets }) => [ |
| @@ -322,27 +354,29 @@ export function findAssets(dir = STATIC_DIR, games = GAMES): Map<string, Linked[ | ||
| 322 | 354 | const assets = findAssets(); |
| 323 | 355 | |
| 324 | 356 | /** |
| 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. | |
| 329 | 361 | * |
| 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 | |
| 333 | 365 | * 2.5 ms of gzip on every hit of the shooter's bundle, paid again for every |
| 334 | 366 | * visitor with a cold cache, which is the wrong trade on a file whose contents |
| 335 | 367 | * can never change under its name. The boot log prints what it cost this boot. |
| 336 | 368 | * |
| 337 | 369 | * 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. | |
| 342 | 376 | */ |
| 343 | 377 | export type Held = { body: Body; type: string }; |
| 344 | 378 | |
| 345 | export function holdStatic(dir = STATIC_DIR): Map<string, Held> { | |
| 379 | export function holdStatic(dir = BUNDLES): Map<string, Held> { | |
| 346 | 380 | const held = new Map<string, Held>(); |
| 347 | 381 | if (!existsSync(dir)) return held; |
| 348 | 382 | for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| @@ -369,19 +403,19 @@ const staticSummary = (held = STATIC, ms = heldIn): string => { | ||
| 369 | 403 | raw += body.raw.byteLength; |
| 370 | 404 | sent += body.gzip?.byteLength ?? body.raw.byteLength; |
| 371 | 405 | } |
| 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`; | |
| 373 | 407 | }; |
| 374 | 408 | |
| 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. */ | |
| 376 | 410 | const IMMUTABLE = { "cache-control": "public, max-age=31536000, immutable", ...SECURITY }; |
| 377 | 411 | |
| 378 | 412 | /** |
| 379 | 413 | * 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. | |
| 383 | 417 | */ |
| 384 | const exampleCost = measureExample(); | |
| 418 | const exampleCost = measureExample(BUNDLES); | |
| 385 | 419 | |
| 386 | 420 | /** |
| 387 | 421 | * How one game's page is drawn for one request: its document, and any header |
| @@ -687,7 +721,7 @@ if (listen.unusable !== undefined) | ||
| 687 | 721 | |
| 688 | 722 | console.log( |
| 689 | 723 | `cs listening on http://${server.hostname}:${server.port} (${env}) serving ${[...scripts.values()].join(", ")}` + |
| 690 | ` — ${staticSummary()}` + | |
| 724 | ` — ${buildSummary(built, builtIn, BUNDLES)} — ${staticSummary()}` + | |
| 691 | 725 | `${identityKey ? "" : " — no FORGE_APP_IDENTITY_KEY: every visitor plays as a guest"}` + |
| 692 | 726 | `${planner ? "" : " — no TYPESAFE_API_KEY: the bots plan their rounds locally"}`, |
| 693 | 727 | ); |
static/app.ea37d3064a.js deleted −58
Diff not shown — a line 76,213 characters wide (limit 2,000). −58
View filestatic/example.58e2f464cf.js deleted −58
Diff not shown — a line 10,369 characters wide (limit 2,000). −58
View filestatic/range.2aa2c09163.js deleted −58
Diff not shown — a line 18,852 characters wide (limit 2,000). −58
View filestatic/weights.f18906194d.json deleted −1
Diff not shown — a line 11,119 characters wide (limit 2,000). −1
View file
new above
offline