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

forked from trevato/cs · proposes · 4 behind

The build takes a list of entries and the server serves a bundle per game #15

Merged opened this

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

Merged

torque/cs-engine:torque/12-the-build-takes-a-list-of-entries-and-th → main 1 commit fork

  • Merged. torque/12-the-build-takes-a-list-of-entries-and-th was merged into main as bfd4386.

Review

Sign in to review this change.
5 files changed +173 −46
README.md +10 −3
@@ -7,7 +7,7 @@ back.
77
88 ```sh
99 PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
10 bun run scripts/build.ts # rebuild static/app.<hash>.js
10 bun run scripts/build.ts # rebuild every static/<name>.<hash>.js
1111 bun run scripts/train.ts --iters 1000 # retrain the bots' policy
1212 bun run scripts/train.ts --bench # how fast the arena steps
1313 bun test # the check every pull runs
@@ -72,8 +72,15 @@ the rest.
7272 - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the
7373 schema and its queries, `src/result.ts` validates what a browser may claim,
7474 `src/pages/game.ts` renders the page.
75 - `scripts/build.ts` bundles the client into `static/app.<hash>.js`, committed
76 and cached for a year. `bun test` fails if what is committed is stale.
75 - `scripts/build.ts` bundles a table of entries — `ENTRIES`, one line per game:
76 a name and a source file — into `static/<name>.<hash>.js`, committed and
77 cached for a year. Each entry sweeps up only its own stale bundles, so one
78 game's rebuild never deletes another's, and `bun test` fails if any committed
79 bundle is stale. The server resolves the script for an entry by that exact
80 name, so **adding a second game is one line in `ENTRIES` plus a route in
81 `src/server.ts`** that renders its page with `scripts["<name>"]` in the
82 `<script type="module" src>` — no inline script, the CSP is `default-src
83 'self'`.
7784 - No dependencies. Nothing is installed to run this.
7885
7986 It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app
scripts/build.test.ts added +76
@@ -0,0 +1,76 @@
1 import { expect, test } from "bun:test";
2 import { existsSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { bundle, ENTRIES, isBundleOf, write } from "./build.ts";
6
7 /** A throwaway pair of entries: two sources and a directory to write them to. */
8 function fixture(): { dir: string; entries: { name: string; entry: string }[]; source: (n: string, t: string) => void } {
9 const dir = mkdtempSync(join(tmpdir(), "build-"));
10 const source = (name: string, text: string): void => writeFileSync(join(dir, `${name}.ts`), text);
11 source("app", `export const game = "app";\nconsole.log(game);\n`);
12 source("sandbox", `export const game = "sandbox";\nconsole.log(game);\n`);
13 return {
14 dir,
15 entries: [
16 { name: "app", entry: join(dir, "app.ts") },
17 { name: "sandbox", entry: join(dir, "sandbox.ts") },
18 ],
19 source,
20 };
21 }
22
23 test("the entry table is what the app ships today", () => {
24 expect(ENTRIES).toEqual([{ name: "app", entry: "src/client/main.ts" }]);
25 });
26
27 test("every entry is bundled to its own name and its own content hash", async () => {
28 const { dir, entries } = fixture();
29 const built = await write(entries, dir);
30 expect(built.map((b) => b.name)).toEqual(["app", "sandbox"]);
31 for (const { name, file, code } of built) {
32 expect(file).toMatch(new RegExp(`^${name}\\.[0-9a-f]{10}\\.js$`));
33 const hash = new Bun.CryptoHasher("sha256").update(code).digest("hex").slice(0, 10);
34 expect(file).toBe(`${name}.${hash}.js`);
35 expect(existsSync(join(dir, file))).toBe(true);
36 }
37 expect(built[0]?.file).not.toBe(built[1]?.file);
38 });
39
40 test("rebuilding one entry sweeps up its own stale bundle and leaves the other game alone", async () => {
41 const { dir, entries, source } = fixture();
42 const first = await write(entries, dir);
43 const [staleApp, sandbox] = first;
44 writeFileSync(join(dir, "keep.txt"), "not a bundle");
45
46 source("app", `export const game = "app";\nconsole.log(game, "again");\n`);
47 const second = await write(entries, dir);
48 const [freshApp, sandboxAgain] = second;
49
50 expect(freshApp?.file).not.toBe(staleApp?.file);
51 expect(existsSync(join(dir, staleApp?.file as string))).toBe(false);
52 expect(existsSync(join(dir, freshApp?.file as string))).toBe(true);
53 // The trap: the other game's bundle did not change and must still be there.
54 expect(sandboxAgain?.file).toBe(sandbox?.file as string);
55 expect(existsSync(join(dir, sandbox?.file as string))).toBe(true);
56 expect(readdirSync(dir)).toContain("keep.txt");
57 });
58
59 test("a bundle belongs to one entry by exact name", () => {
60 expect(isBundleOf("app", "app.6864d577a4.js")).toBe(true);
61 expect(isBundleOf("app", "sandbox.6864d577a4.js")).toBe(false);
62 expect(isBundleOf("sandbox", "app.6864d577a4.js")).toBe(false);
63 expect(isBundleOf("app", "app.js")).toBe(false);
64 expect(isBundleOf("app", "app.6864d577a4.js.map")).toBe(false);
65 expect(isBundleOf("app", "appx6864d577a4.js")).toBe(false);
66 // A name that is not a plain name never becomes a pattern.
67 expect(isBundleOf("a.p", "axp.6864d577a4.js")).toBe(false);
68 expect(isBundleOf(".*", "app.6864d577a4.js")).toBe(false);
69 });
70
71 test("two entries cannot share a name, and a name is plain", async () => {
72 const { dir } = fixture();
73 const entry = join(dir, "app.ts");
74 expect(bundle([{ name: "app", entry }, { name: "app", entry }])).rejects.toThrow(/share a name/);
75 expect(bundle([{ name: "../app", entry }])).rejects.toThrow(/plain name/);
76 });
scripts/build.ts +57 −29
@@ -1,42 +1,70 @@
11 /**
2 * The build: one bundle, named by its content hash, written to static/.
3 * `bun run build` writes it; the test compares what is committed against what
2 * The build: a table of entries, each bundled to `<name>.<hash>.js` in static/.
3 * `bun run build` writes them; the test compares what is committed against what
44 * the sources produce now, so a stale bundle fails the check instead of the
55 * browser.
66 */
77
88 import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
9 import { join } from "node:path";
9 import { join, resolve } from "node:path";
1010
11 export const STATIC_DIR = join(import.meta.dir, "..", "static");
12 const ENTRY = join(import.meta.dir, "..", "src", "client", "main.ts");
11 const ROOT = join(import.meta.dir, "..");
12 export const STATIC_DIR = join(ROOT, "static");
1313
14 /** Bundle the client and return the file name its contents deserve. */
15 export async function bundle(): Promise<{ name: string; code: string }> {
16 const built = await Bun.build({
17 entrypoints: [ENTRY],
18 target: "browser",
19 minify: true,
20 format: "esm",
21 });
22 if (!built.success) throw new Error(built.logs.map(String).join("\n"));
23 const output = built.outputs[0];
24 if (output === undefined) throw new Error("the bundle produced no output");
25 const code = await output.text();
26 const hash = new Bun.CryptoHasher("sha256").update(code).digest("hex").slice(0, 10);
27 return { name: `app.${hash}.js`, code };
14 /** One game: the name its bundle is filed under, and the source it starts from. */
15 export type Entry = { name: string; entry: string };
16
17 /** Adding a game is one line here plus a route in `src/server.ts`. */
18 export const ENTRIES: Entry[] = [{ name: "app", entry: "src/client/main.ts" }];
19
20 // A name becomes part of a file name and part of a regular expression: keep it
21 // plain, so neither can be surprised by it.
22 const NAME = /^[a-z][a-z0-9-]*$/;
23
24 /** Is this file in static/ a bundle of that entry — `app.3f2a9c1b04.js` of `app`? */
25 export function isBundleOf(name: string, file: string): boolean {
26 return NAME.test(name) && new RegExp(`^${name}\\.[0-9a-f]+\\.js$`).test(file);
2827 }
2928
30 export async function write(): Promise<string> {
31 const { name, code } = await bundle();
32 mkdirSync(STATIC_DIR, { recursive: true });
33 for (const file of readdirSync(STATIC_DIR))
34 if (/^app\.[0-9a-f]+\.js$/.test(file) && file !== name) rmSync(join(STATIC_DIR, file));
35 writeFileSync(join(STATIC_DIR, name), code);
36 return name;
29 export type Built = { name: string; file: string; code: string };
30
31 /** Bundle every entry and give each the file name its own contents deserve. */
32 export async function bundle(entries: Entry[] = ENTRIES): Promise<Built[]> {
33 if (new Set(entries.map((e) => e.name)).size !== entries.length)
34 throw new Error("two entries share a name; each bundle needs its own");
35 const built: Built[] = [];
36 for (const { name, entry } of entries) {
37 if (!NAME.test(name)) throw new Error(`entry name ${JSON.stringify(name)} is not a plain name`);
38 const out = await Bun.build({
39 entrypoints: [resolve(ROOT, entry)],
40 target: "browser",
41 minify: true,
42 format: "esm",
43 });
44 if (!out.success) throw new Error(out.logs.map(String).join("\n"));
45 const output = out.outputs[0];
46 if (output === undefined) throw new Error(`the bundle for ${name} produced no output`);
47 const code = await output.text();
48 const hash = new Bun.CryptoHasher("sha256").update(code).digest("hex").slice(0, 10);
49 built.push({ name, file: `${name}.${hash}.js`, code });
50 }
51 return built;
3752 }
3853
39 if (import.meta.main) {
40 const name = await write();
41 console.log(`static/${name}`);
54 /**
55 * Write every bundle, and sweep up after each entry only: a stale `app.*.js`
56 * goes, another game's bundle beside it stays. Anything else in static/ is left
57 * where it is.
58 */
59 export async function write(entries: Entry[] = ENTRIES, dir = STATIC_DIR): Promise<Built[]> {
60 const built = await bundle(entries);
61 mkdirSync(dir, { recursive: true });
62 const present = readdirSync(dir);
63 for (const { name, file, code } of built) {
64 for (const old of present) if (old !== file && isBundleOf(name, old)) rmSync(join(dir, old));
65 writeFileSync(join(dir, file), code);
66 }
67 return built;
4268 }
69
70 if (import.meta.main) for (const { file } of await write()) console.log(`static/${file}`);
src/server.test.ts +11 −2
@@ -122,8 +122,17 @@ test("a bug answers with the floor, not the runtime's page", async () => {
122122
123123 test("the bundle in static/ is the one the sources produce", async () => {
124124 // A stale bundle is a browser running last week's game: fail here instead.
125 const { name } = await bundle();
126 expect(readdirSync(STATIC_DIR)).toContain(name);
125 // Every entry, not the first: a second game goes stale just as quietly.
126 const built = await bundle();
127 expect(built.length).toBeGreaterThan(0);
128 const files = readdirSync(STATIC_DIR);
129 for (const { file } of built) expect(files).toContain(file);
130 });
131
132 test("a static name that is not there is a 404 with the floor", async () => {
133 const response = await fetch(base + "/static/app.deadbeef00.js");
134 expect(response.status).toBe(404);
135 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
127136 });
128137
129138 test("the script is served immutable and cached for a year", async () => {
src/server.ts +19 −12
@@ -2,12 +2,13 @@
22 * cs — a browser defusal shooter with a leaderboard.
33 *
44 * The game itself runs in the page; this server renders the first paint,
5 * serves the one bundled script, and keeps match results in SQLite under
5 * serves each game's bundled script, and keeps match results in SQLite under
66 * FORGE_APP_DATA. Production and each preview get their own directory.
77 */
88
99 import { existsSync, readdirSync } from "node:fs";
1010 import { join } from "node:path";
11 import { ENTRIES, isBundleOf, STATIC_DIR } from "../scripts/build.ts";
1112 import { openDatabase, saveMatch } from "./db.ts";
1213 import { escape, gamePage, statsHtml } from "./pages/game.ts";
1314 import { parseResult } from "./result.ts";
@@ -109,14 +110,17 @@ const newSession = (): string =>
109110 const sessionCookie = (session: string): string =>
110111 `${SESSION_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${secure ? "; Secure" : ""}`;
111112
112 /** The built bundle, named by its content so it can be cached for a year. */
113 function findScript(): string {
114 const dir = join(import.meta.dir, "..", "static");
115 if (!existsSync(dir)) return "/static/app.js";
116 const name = readdirSync(dir).find((f) => /^app\.[0-9a-f]+\.js$/.test(f));
117 return `/static/${name ?? "app.js"}`;
113 // The built bundles, one per entry, each named by its content so it can be
114 // cached for a year. Resolved once at start-up and by exact name: with a second
115 // game in static/, "the first file that looks like a bundle" is the wrong game.
116 function findScripts(): Record<string, string> {
117 const files = existsSync(STATIC_DIR) ? readdirSync(STATIC_DIR) : [];
118 const found: Record<string, string> = {};
119 for (const { name } of ENTRIES) found[name] = `/static/${files.find((f) => isBundleOf(name, f)) ?? `${name}.js`}`;
120 return found;
118121 }
119 const script = findScript();
122 const scripts = findScripts();
123 const script = scripts["app"] ?? "/static/app.js";
120124
121125 export const server = Bun.serve({
122126 port: Number(process.env.PORT ?? 8080),
@@ -154,10 +158,11 @@ export const server = Bun.serve({
154158 }
155159
156160 // Files under ./static are served as they are, cached for a year: they are
157 // named by content (app.3f2a9c.js), so a change is a new name, never a
158 // stale cache. The path is confined to the directory.
161 // named by content (app.3f2a9c1b04.js), so a change is a new name, never a
162 // stale cache. The path is confined to the directory, and a name that is
163 // not there — another game's old bundle, a guess — is a 404 like any other.
159164 if (url.pathname.startsWith("/static/") && !url.pathname.includes("..")) {
160 const file = Bun.file(join(import.meta.dir, "..", url.pathname.slice(1)));
165 const file = Bun.file(join(STATIC_DIR, url.pathname.slice("/static/".length)));
161166 if (await file.exists())
162167 return new Response(file, {
163168 headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY },
@@ -175,4 +180,6 @@ export const server = Bun.serve({
175180 },
176181 });
177182
178 console.log(`cs listening on http://${server.hostname}:${server.port} (${env}) serving ${script}`);
183 console.log(
184 `cs listening on http://${server.hostname}:${server.port} (${env}) serving ${Object.values(scripts).join(", ")}`,
185 );

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs