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

Merged opened this

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

Merged

torque/cs-engine:torque/127-a-missing-bundle-is-a-loud-failure-not-a → main 2 commits fork

  • Merged. torque/127-a-missing-bundle-is-a-loud-failure-not-a was merged into main as 5947646.

Review

Sign in to review this change.
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
720720 up only its own stale bundles, so one
721721 game's rebuild never deletes another's, and `bun test` fails if any committed
722722 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.
724731 - **Every bundle has a ceiling.** Today, and `bun run scripts/build.ts` prints
725732 these next to each file name:
726733
727734 | bundle | raw | ceiling | gzipped | ceiling |
728735 | --------- | ------ | ------- | ------- | ------- |
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 |
732739
733740 The ceilings are today's sizes plus about 10%. Gzip is measured at level 9
734741 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 {
6868 return NAME.test(name) && new RegExp(`^${name}\\.[0-9a-f]+\\.js$`).test(file);
6969 }
7070
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
7196 export type Built = { name: string; file: string; code: string; ceiling: Ceiling };
7297
7398 /** 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";
1818 import { tmpdir } from "node:os";
1919 import { join } from "node:path";
2020 import { pathToFileURL } from "node:url";
21 import { isBundleOf, STATIC_DIR } from "./build.ts";
21 import { bundleOf, STATIC_DIR } from "./build.ts";
2222
2323 /**
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.
2727 */
2828 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));
3630 }
3731
3832 // 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 () => {
184184 test("a bundle that is missing or doubled is a sentence saying what to do about it", () => {
185185 const dir = mkdtempSync(join(tmpdir(), "bundle-finder-"));
186186 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`/);
188188 writeFileSync(join(dir, "app.0123456789.js"), "");
189189 expect(bundlePath("app", dir)).toBe(join(dir, "app.0123456789.js"));
190190 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/);
192192 } finally {
193193 rmSync(dir, { recursive: true, force: true });
194194 }
src/server.test.ts +64 −2
@@ -1,5 +1,5 @@
11 import { expect, test } from "bun:test";
2 import { mkdtempSync, readdirSync } from "node:fs";
2 import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { bundle, STATIC_DIR } from "../scripts/build.ts";
@@ -14,7 +14,7 @@ process.env.PORT = "0";
1414 // The test is the forge: it holds the same key and signs identities with it.
1515 const KEY = "the-key-the-forge-signs-with";
1616 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");
1818 const base = `http://${server.hostname}:${server.port}`;
1919
2020 /** 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 () => {
495495 for (const { file } of built) expect(files).toContain(file);
496496 });
497497
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
498560 test("a static name that is not there is a 404 with the floor", async () => {
499561 const response = await fetch(base + "/static/app.deadbeef00.js");
500562 expect(response.status).toBe(404);
src/server.ts +28 −11
@@ -11,6 +11,10 @@
1111 * name to the function that draws its page. The POST route is not in it:
1212 * `/api/match` belongs to the shooter, not to games in general.
1313 *
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 *
1418 * A result is the browser's word for the numbers and nobody's word for the
1519 * name, so the name is not taken from it: `src/who.ts` verifies the forge's
1620 * signed identity headers and a result is recorded under that, or as a guest's.
@@ -20,7 +24,7 @@
2024
2125 import { existsSync, readdirSync } from "node:fs";
2226 import { join } from "node:path";
23 import { isBundleOf, STATIC_DIR } from "../scripts/build.ts";
27 import { bundleOf, STATIC_DIR } from "../scripts/build.ts";
2428 import { bind } from "./bind.ts";
2529 import { openDatabase, type Player, saveMatch } from "./db.ts";
2630 import { GAMES } from "./games.ts";
@@ -172,14 +176,26 @@ const playerOf = (visitor: Who, session: string, claimed: string): Player =>
172176 ? { userId: "", session, name: cleanName(claimed) }
173177 : { userId: visitor.id, session, name: cleanName(visitor.name) };
174178
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)}`]));
183199 }
184200 const scripts = findScripts();
185201
@@ -217,7 +233,8 @@ const READ = new Map<string, (request: Request) => Response>(
217233 GAMES.map((game) => {
218234 const render = RENDER[game.name];
219235 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;
221238 return [game.href, (request: Request) => render(request, script)];
222239 }),
223240 );
@@ -315,6 +332,6 @@ if (listen.unusable !== undefined)
315332 console.log(`HOST=${listen.unusable} is not an address to bind; listening on ${listen.hostname} instead`);
316333
317334 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(", ")}` +
319336 `${identityKey ? "" : " — no FORGE_APP_IDENTITY_KEY: every visitor plays as a guest"}`,
320337 );
static/app.46e58255bb.js deleted −58

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

View file
static/app.4933b16c8f.js added +58

Diff not shown — a line 73,128 characters wide (limit 2,000). +58

View file
static/app.6bbbaf11e0.js deleted −58

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs