Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
A missing bundle is a loud failure, not a dead canvas: the server refuses to start without the script each game ships #198
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/127-a-missing-bundle-is-a-loud-failure-not-a → main 2 commits fork
9 files
changed +193
−146
README.md +11 −4
| @@ -720,15 +720,22 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 720 | 720 | up only its own stale bundles, so one |
| 721 | 721 | game's rebuild never deletes another's, and `bun test` fails if any committed |
| 722 | 722 | bundle is stale — today that is `app` (the shooter), `range` and `example`. The server |
| 723 | resolves the script for an entry by that exact name. | |
| 723 | resolves the script for an entry by that exact name, and **a game whose bundle | |
| 724 | is not on disk stops the server before it listens**: `findScripts` in | |
| 725 | `src/server.ts` reads `static/` once at start-up and throws naming the game, | |
| 726 | the name it looked for and `bun run scripts/build.ts`, rather than serving a | |
| 727 | perfect page around a `<script>` that 404s. Two bundles for one game — a stale | |
| 728 | one left beside a new one — is the same failure, because nothing can say which | |
| 729 | of them a visitor should run. A preview that deploys without its committed | |
| 730 | bundle is a failed deploy whose first line says so. | |
| 724 | 731 | - **Every bundle has a ceiling.** Today, and `bun run scripts/build.ts` prints |
| 725 | 732 | these next to each file name: |
| 726 | 733 | |
| 727 | 734 | | bundle | raw | ceiling | gzipped | ceiling | |
| 728 | 735 | | --------- | ------ | ------- | ------- | ------- | |
| 729 | | `app` | 71,646 | 75,400 | 33,273 | 34,900 | | |
| 730 | | `range` | 27,268 | 29,400 | 11,674 | 12,700 | | |
| 731 | | `example` | 8,754 | 9,700 | 4,229 | 4,700 | | |
| 736 | | `app` | 92,018 | 96,000 | 41,774 | 43,500 | | |
| 737 | | `range` | 34,507 | 37,000 | 14,449 | 15,500 | | |
| 738 | | `example` | 9,735 | 10,800 | 4,619 | 5,100 | | |
| 732 | 739 | |
| 733 | 740 | The ceilings are today's sizes plus about 10%. Gzip is measured at level 9 |
| 734 | 741 | and only at level 9, so the number does not move between a laptop and the |
scripts/build.ts +25
| @@ -68,6 +68,31 @@ export function isBundleOf(name: string, file: string): boolean { | ||
| 68 | 68 | return NAME.test(name) && new RegExp(`^${name}\\.[0-9a-f]+\\.js$`).test(file); |
| 69 | 69 | } |
| 70 | 70 | |
| 71 | /** | |
| 72 | * The one `<name>.<hash>.js` among a directory's `files`, or a sentence saying | |
| 73 | * what to do about it: none means nobody built or committed it, and two means a | |
| 74 | * stale one was left behind and whoever picks between them picks at random. | |
| 75 | * `where` is the directory those names came from, and is only ever read out in | |
| 76 | * the message. | |
| 77 | * | |
| 78 | * It is here rather than beside either caller because both — the server, which | |
| 79 | * refuses to boot without every game's bundle, and the tests, which boot the | |
| 80 | * committed file — are asking the same question of the same directory, and one | |
| 81 | * answer cannot drift from itself. | |
| 82 | */ | |
| 83 | export function bundleOf(name: string, files: readonly string[], where = STATIC_DIR): string { | |
| 84 | const mine = files.filter((file) => isBundleOf(name, file)); | |
| 85 | const first = mine[0]; | |
| 86 | if (mine.length === 1 && first !== undefined) return first; | |
| 87 | const build = "run `bun run scripts/build.ts` and commit what it writes"; | |
| 88 | throw new Error( | |
| 89 | mine.length === 0 | |
| 90 | ? `the game ${JSON.stringify(name)} has no bundle: nothing in ${where} is named ${name}.<hash>.js — ${build}` | |
| 91 | : `the game ${JSON.stringify(name)} has ${mine.length} bundles in ${where}: ${mine.join(", ")} — ` + | |
| 92 | `all but one are stale, so ${build}`, | |
| 93 | ); | |
| 94 | } | |
| 95 | ||
| 71 | 96 | export type Built = { name: string; file: string; code: string; ceiling: Ceiling }; |
| 72 | 97 | |
| 73 | 98 | /** Bundle every entry and give each the file name its own contents deserve. */ |
scripts/bundle.test-helper.ts +5 −11
| @@ -18,21 +18,15 @@ import { copyFileSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; | ||
| 18 | 18 | import { tmpdir } from "node:os"; |
| 19 | 19 | import { join } from "node:path"; |
| 20 | 20 | import { pathToFileURL } from "node:url"; |
| 21 | import { isBundleOf, STATIC_DIR } from "./build.ts"; | |
| 21 | import { bundleOf, STATIC_DIR } from "./build.ts"; | |
| 22 | 22 | |
| 23 | 23 | /** |
| 24 | * The one `<name>.<hash>.js` in `dir`, or a sentence saying what to do about it: | |
| 25 | * none means nobody committed the build, and two means a stale one was left | |
| 26 | * behind and the test would have no way to say which it booted. | |
| 24 | * Where the one `<name>.<hash>.js` in `dir` is. Which file that is — and what is | |
| 25 | * said when there is no such file, or two of them — is `bundleOf` in | |
| 26 | * `./build.ts`: the test boots the file the server would refuse to boot without. | |
| 27 | 27 | */ |
| 28 | 28 | export function bundlePath(name: string, dir = STATIC_DIR): string { |
| 29 | const files = readdirSync(dir).filter((file) => isBundleOf(name, file)); | |
| 30 | if (files.length !== 1) | |
| 31 | throw new Error( | |
| 32 | `expected exactly one ${name}.<hash>.js in ${dir}, found ${files.length}` + | |
| 33 | `${files.length === 0 ? " — run \`bun run build\` and commit the bundle" : `: ${files.join(", ")}`}`, | |
| 34 | ); | |
| 35 | return join(dir, files[0] as string); | |
| 29 | return join(dir, bundleOf(name, readdirSync(dir), dir)); | |
| 36 | 30 | } |
| 37 | 31 | |
| 38 | 32 | // An import is cached per specifier, so an entry would boot itself once and |
src/client/bundle.test.ts +2 −2
| @@ -184,11 +184,11 @@ test("a page still being parsed is waited for, not booted on", async () => { | ||
| 184 | 184 | test("a bundle that is missing or doubled is a sentence saying what to do about it", () => { |
| 185 | 185 | const dir = mkdtempSync(join(tmpdir(), "bundle-finder-")); |
| 186 | 186 | try { |
| 187 | expect(() => bundlePath("app", dir)).toThrow(/found 0 — run `bun run build`/); | |
| 187 | expect(() => bundlePath("app", dir)).toThrow(/has no bundle.*run `bun run scripts\/build\.ts`/); | |
| 188 | 188 | writeFileSync(join(dir, "app.0123456789.js"), ""); |
| 189 | 189 | expect(bundlePath("app", dir)).toBe(join(dir, "app.0123456789.js")); |
| 190 | 190 | writeFileSync(join(dir, "app.abcdef0123.js"), ""); |
| 191 | expect(() => bundlePath("app", dir)).toThrow(/found 2: /); | |
| 191 | expect(() => bundlePath("app", dir)).toThrow(/has 2 bundles.*app\.0123456789\.js/); | |
| 192 | 192 | } finally { |
| 193 | 193 | rmSync(dir, { recursive: true, force: true }); |
| 194 | 194 | } |
src/server.test.ts +64 −2
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { mkdtempSync, readdirSync } from "node:fs"; | |
| 2 | import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; | |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { bundle, STATIC_DIR } from "../scripts/build.ts"; |
| @@ -14,7 +14,7 @@ process.env.PORT = "0"; | ||
| 14 | 14 | // The test is the forge: it holds the same key and signs identities with it. |
| 15 | 15 | const KEY = "the-key-the-forge-signs-with"; |
| 16 | 16 | process.env.FORGE_APP_IDENTITY_KEY = KEY; |
| 17 | const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT, onError } = await import("./server.ts"); | |
| 17 | const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT, onError, findScripts } = await import("./server.ts"); | |
| 18 | 18 | const base = `http://${server.hostname}:${server.port}`; |
| 19 | 19 | |
| 20 | 20 | /** The two headers the forge puts on a request it proxies for a signed-in person. */ |
| @@ -495,6 +495,68 @@ test("the bundle in static/ is the one the sources produce", async () => { | ||
| 495 | 495 | for (const { file } of built) expect(files).toContain(file); |
| 496 | 496 | }); |
| 497 | 497 | |
| 498 | // The boot contract: a game without its bundle on disk stops the server before | |
| 499 | // it binds, rather than serving a page whose script 404s. The resolver takes its | |
| 500 | // directory so these can point it somewhere it will fail; the module calls it | |
| 501 | // with static/, which is why importing this file at all proves the happy path. | |
| 502 | test("a game whose bundle is not on disk stops the server, naming it and the build", () => { | |
| 503 | const dir = mkdtempSync(join(tmpdir(), "no-bundle-")); | |
| 504 | try { | |
| 505 | expect(() => findScripts(dir)).toThrow(/the game "app" has no bundle/); | |
| 506 | expect(() => findScripts(dir)).toThrow(new RegExp(`nothing in ${dir} is named app\\.<hash>\\.js`)); | |
| 507 | expect(() => findScripts(dir)).toThrow(/run `bun run scripts\/build\.ts`/); | |
| 508 | // The second game is held to it too, not just the first one looked at. | |
| 509 | writeFileSync(join(dir, "app.0123456789.js"), ""); | |
| 510 | expect(() => findScripts(dir)).toThrow(/the game "range" has no bundle/); | |
| 511 | // And a directory that is not there at all reads as one with nothing in it. | |
| 512 | expect(() => findScripts(join(dir, "gone"))).toThrow(/the game "app" has no bundle/); | |
| 513 | } finally { | |
| 514 | rmSync(dir, { recursive: true, force: true }); | |
| 515 | } | |
| 516 | }); | |
| 517 | ||
| 518 | test("every game resolves to the exact file in static/ that is its own bundle", () => { | |
| 519 | const dir = mkdtempSync(join(tmpdir(), "bundles-")); | |
| 520 | try { | |
| 521 | const named = GAMES.map(({ name }, i) => ({ name, file: `${name}.${String(i).repeat(10)}.js` })); | |
| 522 | // A file that is nobody's bundle, and a bundle-shaped name for a game that | |
| 523 | // is not in the registry: neither may be picked up as any game's. | |
| 524 | for (const { file } of named) writeFileSync(join(dir, file), ""); | |
| 525 | writeFileSync(join(dir, "notes.txt"), ""); | |
| 526 | writeFileSync(join(dir, "other.abcdef0123.js"), ""); | |
| 527 | const found = findScripts(dir); | |
| 528 | expect(found.size).toBe(GAMES.length); | |
| 529 | for (const { name, file } of named) expect(found.get(name)).toBe(`/static/${file}`); | |
| 530 | } finally { | |
| 531 | rmSync(dir, { recursive: true, force: true }); | |
| 532 | } | |
| 533 | }); | |
| 534 | ||
| 535 | test("two bundles for one game is a stale one nobody can pick between", () => { | |
| 536 | // What the forge's own static/ held after two pulls each committed an app | |
| 537 | // bundle: whichever readdir handed over first would have been served. | |
| 538 | const dir = mkdtempSync(join(tmpdir(), "two-bundles-")); | |
| 539 | try { | |
| 540 | for (const { name } of GAMES) writeFileSync(join(dir, `${name}.0123456789.js`), ""); | |
| 541 | writeFileSync(join(dir, "app.abcdef0123.js"), ""); | |
| 542 | expect(() => findScripts(dir)).toThrow(/the game "app" has 2 bundles/); | |
| 543 | expect(() => findScripts(dir)).toThrow(/app\.0123456789\.js/); | |
| 544 | } finally { | |
| 545 | rmSync(dir, { recursive: true, force: true }); | |
| 546 | } | |
| 547 | }); | |
| 548 | ||
| 549 | test("the bundle every game is served is the file that is on disk", () => { | |
| 550 | // The other end of the contract, read off the running server: what the pages | |
| 551 | // point at is a name in static/, never a guessed `/static/<name>.js`. | |
| 552 | const files = readdirSync(STATIC_DIR); | |
| 553 | for (const [name, script] of findScripts()) { | |
| 554 | expect(script.startsWith("/static/")).toBe(true); | |
| 555 | expect(files).toContain(script.slice("/static/".length)); | |
| 556 | expect(script).not.toBe(`/static/${name}.js`); | |
| 557 | } | |
| 558 | }); | |
| 559 | ||
| 498 | 560 | test("a static name that is not there is a 404 with the floor", async () => { |
| 499 | 561 | const response = await fetch(base + "/static/app.deadbeef00.js"); |
| 500 | 562 | expect(response.status).toBe(404); |
src/server.ts +28 −11
| @@ -11,6 +11,10 @@ | ||
| 11 | 11 | * name to the function that draws its page. The POST route is not in it: |
| 12 | 12 | * `/api/match` belongs to the shooter, not to games in general. |
| 13 | 13 | * |
| 14 | * Both halves of a game are a start-up contract: an entry with no page and an | |
| 15 | * entry with no bundle in static/ each throw before this listens, so neither is | |
| 16 | * ever a 404 a visitor meets instead. | |
| 17 | * | |
| 14 | 18 | * A result is the browser's word for the numbers and nobody's word for the |
| 15 | 19 | * name, so the name is not taken from it: `src/who.ts` verifies the forge's |
| 16 | 20 | * signed identity headers and a result is recorded under that, or as a guest's. |
| @@ -20,7 +24,7 @@ | ||
| 20 | 24 | |
| 21 | 25 | import { existsSync, readdirSync } from "node:fs"; |
| 22 | 26 | import { join } from "node:path"; |
| 23 | import { isBundleOf, STATIC_DIR } from "../scripts/build.ts"; | |
| 27 | import { bundleOf, STATIC_DIR } from "../scripts/build.ts"; | |
| 24 | 28 | import { bind } from "./bind.ts"; |
| 25 | 29 | import { openDatabase, type Player, saveMatch } from "./db.ts"; |
| 26 | 30 | import { GAMES } from "./games.ts"; |
| @@ -172,14 +176,26 @@ const playerOf = (visitor: Who, session: string, claimed: string): Player => | ||
| 172 | 176 | ? { userId: "", session, name: cleanName(claimed) } |
| 173 | 177 | : { userId: visitor.id, session, name: cleanName(visitor.name) }; |
| 174 | 178 | |
| 175 | // The built bundles, one per entry, each named by its content so it can be | |
| 176 | // cached for a year. Resolved once at start-up and by exact name: with a second | |
| 177 | // game in static/, "the first file that looks like a bundle" is the wrong game. | |
| 178 | function findScripts(): Record<string, string> { | |
| 179 | const files = existsSync(STATIC_DIR) ? readdirSync(STATIC_DIR) : []; | |
| 180 | const found: Record<string, string> = {}; | |
| 181 | for (const { name } of GAMES) found[name] = `/static/${files.find((f) => isBundleOf(name, f)) ?? `${name}.js`}`; | |
| 182 | return found; | |
| 179 | /** | |
| 180 | * The built bundles, one per entry, each named by its content so it can be | |
| 181 | * cached for a year. The directory is read once, here, and every game must | |
| 182 | * resolve to a file in it by exact name: with a second game in static/, "the | |
| 183 | * first file that looks like a bundle" is the wrong game, and with none of them | |
| 184 | * there a guessed `/static/<name>.js` is a name that is never right. | |
| 185 | * | |
| 186 | * A game whose bundle is not on disk throws, before `Bun.serve` binds, with the | |
| 187 | * game, the name it looked for and the command that writes it — because the | |
| 188 | * alternative is a page that renders perfectly, 200 and all, around a script | |
| 189 | * that 404s: a black canvas nothing anywhere explains. A missing bundle is a | |
| 190 | * failed deploy now rather than a dead page later, the same sentence the read | |
| 191 | * routes below pass on a game with no page. | |
| 192 | * | |
| 193 | * Exported and taking its directory because the throw is the behaviour: a test | |
| 194 | * points it at an empty directory, and this module calls it with static/. | |
| 195 | */ | |
| 196 | export function findScripts(dir = STATIC_DIR, games = GAMES): Map<string, string> { | |
| 197 | const files = existsSync(dir) ? readdirSync(dir) : []; | |
| 198 | return new Map(games.map(({ name }) => [name, `/static/${bundleOf(name, files, dir)}`])); | |
| 183 | 199 | } |
| 184 | 200 | const scripts = findScripts(); |
| 185 | 201 | |
| @@ -217,7 +233,8 @@ const READ = new Map<string, (request: Request) => Response>( | ||
| 217 | 233 | GAMES.map((game) => { |
| 218 | 234 | const render = RENDER[game.name]; |
| 219 | 235 | if (render === undefined) throw new Error(`the game ${JSON.stringify(game.name)} has no page to render it`); |
| 220 | const script = scripts[game.name] ?? `/static/${game.name}.js`; | |
| 236 | // Every game resolved to a bundle that exists, or this module threw above. | |
| 237 | const script = scripts.get(game.name) as string; | |
| 221 | 238 | return [game.href, (request: Request) => render(request, script)]; |
| 222 | 239 | }), |
| 223 | 240 | ); |
| @@ -315,6 +332,6 @@ if (listen.unusable !== undefined) | ||
| 315 | 332 | console.log(`HOST=${listen.unusable} is not an address to bind; listening on ${listen.hostname} instead`); |
| 316 | 333 | |
| 317 | 334 | console.log( |
| 318 | `cs listening on http://${server.hostname}:${server.port} (${env}) serving ${Object.values(scripts).join(", ")}` + | |
| 335 | `cs listening on http://${server.hostname}:${server.port} (${env}) serving ${[...scripts.values()].join(", ")}` + | |
| 319 | 336 | `${identityKey ? "" : " — no FORGE_APP_IDENTITY_KEY: every visitor plays as a guest"}`, |
| 320 | 337 | ); |
static/app.46e58255bb.js deleted −58
Diff not shown — a line 73,131 characters wide (limit 2,000). −58
View filestatic/app.4933b16c8f.js added +58
Diff not shown — a line 73,128 characters wide (limit 2,000). +58
View filestatic/app.6bbbaf11e0.js deleted −58
Diff not shown — a line 72,599 characters wide (limit 2,000). −58
View file
new above
offline