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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/12-the-build-takes-a-list-of-entries-and-th → main 1 commit fork
5 files
changed +173
−46
README.md +10 −3
| @@ -7,7 +7,7 @@ back. | ||
| 7 | 7 | |
| 8 | 8 | sh |
| 9 | 9 | 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 | |
| 11 | 11 | bun run scripts/train.ts --iters 1000 # retrain the bots' policy |
| 12 | 12 | bun run scripts/train.ts --bench # how fast the arena steps |
| 13 | 13 | bun test # the check every pull runs |
| @@ -72,8 +72,15 @@ the rest. | ||
| 72 | 72 | - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the |
| 73 | 73 | schema and its queries, `src/result.ts` validates what a browser may claim, |
| 74 | 74 | `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'`. | |
| 77 | 84 | - No dependencies. Nothing is installed to run this. |
| 78 | 85 | |
| 79 | 86 | 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 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 4 | 4 | * the sources produce now, so a stale bundle fails the check instead of the |
| 5 | 5 | * browser. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; |
| 9 | import { join } from "node:path"; | |
| 9 | import { join, resolve } from "node:path"; | |
| 10 | 10 | |
| 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"); | |
| 13 | 13 | |
| 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); | |
| 28 | 27 | } |
| 29 | 28 | |
| 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; | |
| 37 | 52 | } |
| 38 | 53 | |
| 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; | |
| 42 | 68 | } |
| 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 () => { | ||
| 122 | 122 | |
| 123 | 123 | test("the bundle in static/ is the one the sources produce", async () => { |
| 124 | 124 | // 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'"); | |
| 127 | 136 | }); |
| 128 | 137 | |
| 129 | 138 | test("the script is served immutable and cached for a year", async () => { |
src/server.ts +19 −12
| @@ -2,12 +2,13 @@ | ||
| 2 | 2 | * cs — a browser defusal shooter with a leaderboard. |
| 3 | 3 | * |
| 4 | 4 | * 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 | |
| 6 | 6 | * FORGE_APP_DATA. Production and each preview get their own directory. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { existsSync, readdirSync } from "node:fs"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | import { ENTRIES, isBundleOf, STATIC_DIR } from "../scripts/build.ts"; | |
| 11 | 12 | import { openDatabase, saveMatch } from "./db.ts"; |
| 12 | 13 | import { escape, gamePage, statsHtml } from "./pages/game.ts"; |
| 13 | 14 | import { parseResult } from "./result.ts"; |
| @@ -109,14 +110,17 @@ const newSession = (): string => | ||
| 109 | 110 | const sessionCookie = (session: string): string => |
| 110 | 111 | `${SESSION_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${secure ? "; Secure" : ""}`; |
| 111 | 112 | |
| 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; | |
| 118 | 121 | } |
| 119 | const script = findScript(); | |
| 122 | const scripts = findScripts(); | |
| 123 | const script = scripts["app"] ?? "/static/app.js"; | |
| 120 | 124 | |
| 121 | 125 | export const server = Bun.serve({ |
| 122 | 126 | port: Number(process.env.PORT ?? 8080), |
| @@ -154,10 +158,11 @@ export const server = Bun.serve({ | ||
| 154 | 158 | } |
| 155 | 159 | |
| 156 | 160 | // 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. | |
| 159 | 164 | 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))); | |
| 161 | 166 | if (await file.exists()) |
| 162 | 167 | return new Response(file, { |
| 163 | 168 | headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY }, |
| @@ -175,4 +180,6 @@ export const server = Bun.serve({ | ||
| 175 | 180 | }, |
| 176 | 181 | }); |
| 177 | 182 | |
| 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 | ); |
new above
offline