Split The code is public; the app answers only to the crew.
The bundle arrives compressed: 96 KB of JavaScript is 41 KB on the wire #123
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/94-the-bundle-arrives-compressed-96-kb-of-j → main 2 commits fork 2 threads · 2 unresolved
README.md +4 −1
| @@ -392,7 +392,10 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured. | ||
| 392 | 392 | serves the result from memory as `/static/app.<hash>.js`, cached for a year. |
| 393 | 393 | `scripts/build.ts` is a thin CLI over the same function, for a file on disk. |
| 394 | 394 | Neither the file nor the served bytes are ever stale, because there is |
| 395 | nothing committed to go stale. | |
| 395 | nothing committed to go stale. The bundle's gzip is made once at start-up, | |
| 396 | beside the build; `/` is gzipped per request, since it differs by session. | |
| 397 | `src/compress.ts` is the one question that decides either: whether a | |
| 398 | request's `Accept-Encoding` actually offered gzip. | |
| 396 | 399 | - No runtime dependencies: nothing is installed to run this. `typescript` and |
| 397 | 400 | `@types/bun` are dev-only, for `bun run typecheck` — the compiler and the |
| 398 | 401 | platform's own types, shipping no code into the bundle. |
src/compress.test.ts added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { acceptsGzip } from "./compress.ts"; | |
| 3 | ||
| 4 | test("no header, no gzip", () => { | |
| 5 | expect(acceptsGzip(null)).toBe(false); | |
| 6 | }); | |
| 7 | ||
| 8 | test("gzip named plainly is accepted", () => { | |
| 9 | expect(acceptsGzip("gzip")).toBe(true); | |
| 10 | expect(acceptsGzip("gzip, deflate, br")).toBe(true); | |
| 11 | expect(acceptsGzip("GZIP")).toBe(true); | |
| 12 | }); | |
| 13 | ||
| 14 | test("a coding that is not gzip, alone, is not gzip", () => { | |
| 15 | expect(acceptsGzip("br")).toBe(false); | |
| 16 | expect(acceptsGzip("deflate, br")).toBe(false); | |
| 17 | }); | |
| 18 | ||
| 19 | test("q=0 refuses gzip even when it is named", () => { | |
| 20 | expect(acceptsGzip("gzip;q=0")).toBe(false); | |
| 21 | expect(acceptsGzip("gzip;q=0, br;q=1")).toBe(false); | |
| 22 | }); | |
| 23 | ||
| 24 | test("a q above zero still accepts", () => { | |
| 25 | expect(acceptsGzip("gzip;q=0.5")).toBe(true); | |
| 26 | }); | |
| 27 | ||
| 28 | test("a star with q>0 covers gzip when gzip is not named", () => { | |
| 29 | expect(acceptsGzip("*")).toBe(true); | |
| 30 | expect(acceptsGzip("*;q=0.3")).toBe(true); | |
| 31 | }); | |
| 32 | ||
| 33 | test("a star with q=0 refuses everything not named", () => { | |
| 34 | expect(acceptsGzip("*;q=0")).toBe(false); | |
| 35 | }); | |
| 36 | ||
| 37 | test("gzip named explicitly wins over a conflicting star", () => { | |
| 38 | expect(acceptsGzip("*;q=0, gzip;q=1")).toBe(true); | |
| 39 | expect(acceptsGzip("*, gzip;q=0")).toBe(false); | |
| 40 | }); | |
| 41 | ||
| 42 | test("an empty header names nothing", () => { | |
| 43 | expect(acceptsGzip("")).toBe(false); | |
| 44 | }); |
src/compress.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | /** | |
| 2 | * Whether a request's Accept-Encoding lets us answer with gzip — the only | |
| 3 | * coding this server ever produces. The header is a comma list of codings, | |
| 4 | * each optionally weighted by `;q=`; a coding named with `q=0` is refused, | |
| 5 | * not merely unlisted, and `*` stands for anything not named explicitly. | |
| 6 | */ | |
| 7 | export function acceptsGzip(header: string | null): boolean { | |
| 8 | if (header === null) return false; | |
| 9 | let gzipQ: number | null = null; | |
| 10 | let starQ: number | null = null; | |
| 11 | for (const coding of header.split(",")) { | |
| 12 | const parts = coding.split(";").map((p) => p.trim()); | |
| 13 | const name = parts[0]?.toLowerCase(); | |
| 14 | if (name === undefined || name === "") continue; | |
| 15 | let q = 1; | |
| 16 | for (const param of parts.slice(1)) { | |
| 17 | const [key, value] = param.split("=").map((p) => p.trim()); | |
| 18 | if (key === "q" && value !== undefined && Number.isFinite(Number(value))) q = Number(value); | |
| 19 | } | |
| 20 | if (name === "gzip") gzipQ = q; | |
| 21 | else if (name === "*") starQ = q; | |
| 22 | } | |
| 23 | if (gzipQ !== null) return gzipQ > 0; | |
| 24 | if (starQ !== null) return starQ > 0; | |
| 25 | return false; | |
| 26 | } |
src/server.test.ts +75
| @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { gunzipSync } from "node:zlib"; | |
| 5 | 6 | import { bundle } from "./bundle.ts"; |
| 6 | 7 | import { parseResult } from "./result.ts"; |
| 7 | 8 | import { stamp } from "./who.ts"; |
| @@ -89,6 +90,14 @@ test("stands on the security floor", async () => { | ||
| 89 | 90 | expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin"); |
| 90 | 91 | }); |
| 91 | 92 | |
| 93 | test("the first paint is kept out of shared caches; the bundle is kept in them", async () => { | |
| 94 | const page = await fetch(base + "/"); | |
| 95 | expect(page.headers.get("cache-control")).toContain("private"); | |
| 96 | const src = /src="(\/static\/app\.[0-9a-f]+\.js)"/.exec(await page.text())?.[1] as string; | |
| 97 | const script = await fetch(base + src); | |
| 98 | expect(script.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); | |
| 99 | }); | |
| 100 | ||
| 92 | 101 | test("a result is stored and comes back as the leaderboard", async () => { |
| 93 | 102 | const response = await post(result(), "", forge("u_1", "Vesper")); |
| 94 | 103 | expect(response.status).toBe(200); |
| @@ -215,6 +224,72 @@ test("the script is served immutable, as javascript, under the security floor", | ||
| 215 | 224 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 216 | 225 | }); |
| 217 | 226 | |
| 227 | // `fetch` decodes a gzip response transparently, which hides the wire bytes | |
| 228 | // we actually care about here: `server.fetch` calls the handler directly, so | |
| 229 | // the Response it returns still carries the raw, undecoded body. | |
| 230 | test("the bundle is gzipped when offered, byte-identical once decompressed, under the floor and cached for a year", async () => { | |
| 231 | const page = await (await fetch(base + "/")).text(); | |
| 232 | const src = /src="(\/static\/app\.[0-9a-f]+\.js)"/.exec(page)?.[1] as string; | |
| 233 | const identity = await server.fetch(new Request(base + src)); | |
| 234 | const identityBytes = new Uint8Array(await identity.arrayBuffer()); | |
| 235 | expect(identity.headers.get("content-encoding")).toBeNull(); | |
| 236 | expect(Number(identity.headers.get("content-length"))).toBe(identityBytes.byteLength); | |
| 237 | ||
| 238 | const gzipped = await server.fetch(new Request(base + src, { headers: { "accept-encoding": "gzip, br" } })); | |
| 239 | expect(gzipped.status).toBe(200); | |
| 240 | expect(gzipped.headers.get("content-encoding")).toBe("gzip"); | |
| 241 | expect(gzipped.headers.get("vary")).toBe("accept-encoding"); | |
| 242 | expect(gzipped.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); | |
| 243 | expect(gzipped.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 244 | const gzippedBytes = new Uint8Array(await gzipped.arrayBuffer()); | |
| 245 | expect(gzippedBytes.byteLength).toBe(Number(gzipped.headers.get("content-length"))); | |
| 246 | // Minified game code gzips to well under half its size; a bundle this | |
| 247 | // shape that failed to shrink by at least a third would mean the wrong | |
| 248 | // bytes were compressed. | |
| 249 | expect(gzippedBytes.byteLength).toBeLessThan(identityBytes.byteLength * 0.7); | |
| 250 | expect(new Uint8Array(gunzipSync(gzippedBytes))).toEqual(identityBytes); | |
| 251 | }); | |
| 252 | ||
| 253 | test("a request naming only an encoding we do not produce gets identity bytes", async () => { | |
| 254 | const page = await (await fetch(base + "/")).text(); | |
| 255 | const src = /src="(\/static\/app\.[0-9a-f]+\.js)"/.exec(page)?.[1] as string; | |
| 256 | const response = await server.fetch(new Request(base + src, { headers: { "accept-encoding": "br" } })); | |
| 257 | expect(response.headers.get("content-encoding")).toBeNull(); | |
| 258 | const bytes = new Uint8Array(await response.arrayBuffer()); | |
| 259 | expect(bytes.byteLength).toBe(Number(response.headers.get("content-length"))); | |
| 260 | }); | |
| 261 | ||
| 262 | test("gzip;q=0 is a refusal, not a request we happen to ignore", async () => { | |
| 263 | const page = await (await fetch(base + "/")).text(); | |
| 264 | const src = /src="(\/static\/app\.[0-9a-f]+\.js)"/.exec(page)?.[1] as string; | |
| 265 | const response = await server.fetch(new Request(base + src, { headers: { "accept-encoding": "gzip;q=0, *;q=1" } })); | |
| 266 | expect(response.headers.get("content-encoding")).toBeNull(); | |
| 267 | }); | |
| 268 | ||
| 269 | test("the first paint is gzipped too, when offered, and answers the same page either way", async () => { | |
| 270 | const cookie = "cs_session=" + "9".repeat(32); | |
| 271 | const identity = await server.fetch(new Request(base + "/", { headers: { cookie } })); | |
| 272 | const identityBytes = new Uint8Array(await identity.arrayBuffer()); | |
| 273 | const gzipped = await server.fetch(new Request(base + "/", { headers: { cookie, "accept-encoding": "gzip" } })); | |
| 274 | expect(gzipped.headers.get("content-encoding")).toBe("gzip"); | |
| 275 | expect(gzipped.headers.get("vary")).toBe("accept-encoding"); | |
| 276 | const gzippedBytes = new Uint8Array(await gzipped.arrayBuffer()); | |
| 277 | expect(gzippedBytes.byteLength).toBeLessThan(identityBytes.byteLength); | |
| 278 | expect(new Uint8Array(gunzipSync(gzippedBytes))).toEqual(identityBytes); | |
| 279 | }); | |
| 280 | ||
| 281 | test("HEAD on a compressible response carries the same headers as GET, with no body", async () => { | |
| 282 | const cookie = "cs_session=" + "8".repeat(32); | |
| 283 | const headRequest = new Request(base + "/", { method: "HEAD", headers: { cookie, "accept-encoding": "gzip" } }); | |
| 284 | const getRequest = new Request(base + "/", { method: "GET", headers: { cookie, "accept-encoding": "gzip" } }); | |
| 285 | const head = await server.fetch(headRequest); | |
| 286 | const get = await server.fetch(getRequest); | |
| 287 | expect(head.headers.get("content-encoding")).toBe("gzip"); | |
| 288 | expect(head.headers.get("content-length")).toBe(get.headers.get("content-length")); | |
| 289 | expect(head.headers.get("vary")).toBe("accept-encoding"); | |
| 290 | expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0)); | |
| 291 | }); | |
| 292 | ||
| 218 | 293 | test("the leaderboard is also plain JSON, for a terminal or another client", async () => { |
| 219 | 294 | // The name in the JSON is the forge's, like everywhere else. The match is a |
| 220 | 295 | // big one so the row sits at the top of a board these tests have filled. |
src/server.ts +41 −5
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { bundle } from "./bundle.ts"; |
| 11 | import { acceptsGzip } from "./compress.ts"; | |
| 11 | 12 | import { openDatabase, saveMatch } from "./db.ts"; |
| 12 | 13 | import { createLimiter, type Decision } from "./limit.ts"; |
| 13 | 14 | import { escape, gamePage, leaderboardJson, statsHtml } from "./pages/game.ts"; |
| @@ -43,6 +44,31 @@ const html = (body: string, status = 200, extra: Record<string, string> = {}): R | ||
| 43 | 44 | headers: { "content-type": "text/html; charset=utf-8", ...SECURITY, ...extra }, |
| 44 | 45 | }); |
| 45 | 46 | |
| 47 | const encoder = new TextEncoder(); | |
| 48 | ||
| 49 | // The one place a response is sent as either its bytes or their gzip: never | |
| 50 | // both, and never gzip a request did not offer. The bundle's gzip is made | |
| 51 | // once at start-up and handed in; everything else is gzipped here, on the | |
| 52 | // bytes it was already about to send. `vary` tells a cache the two answers | |
| 53 | // differ by this header, so it never hands one visitor the other's encoding. | |
| 54 | function encoded( | |
| 55 | request: Request, | |
| 56 | identity: Uint8Array<ArrayBuffer>, | |
| 57 | headers: Record<string, string>, | |
| 58 | status = 200, | |
| 59 | gzip?: Uint8Array<ArrayBuffer>, | |
| 60 | ): Response { | |
| 61 | const useGzip = acceptsGzip(request.headers.get("accept-encoding")); | |
| 62 | const body = useGzip ? (gzip ?? Bun.gzipSync(identity)) : identity; | |
| 63 | const responseHeaders: Record<string, string> = { | |
| 64 | ...headers, | |
| 65 | vary: "accept-encoding", | |
| 66 | "content-length": String(body.byteLength), | |
| 67 | ...(useGzip ? { "content-encoding": "gzip" } : {}), | |
| 68 | }; | |
| 69 | return new Response(request.method === "HEAD" ? null : body, { status, headers: responseHeaders }); | |
| 70 | } | |
| 71 | ||
| 46 | 72 | // A browser on another site can make a person's browser POST here; it cannot |
| 47 | 73 | // make that request look same-origin. Browsers say where a request came from |
| 48 | 74 | // (Sec-Fetch-Site, or Origin); a request that names another site is refused. |
| @@ -150,6 +176,11 @@ export async function serve(): Promise<ReturnType<typeof Bun.serve>> { | ||
| 150 | 176 | const buildMs = Math.round(performance.now() - buildStart); |
| 151 | 177 | const script = `/static/${scriptName}`; |
| 152 | 178 | |
| 179 | // The bundle is immutable, so its gzip is too: computed once, beside the | |
| 180 | // build, never per request. | |
| 181 | const scriptBytes = encoder.encode(scriptCode); | |
| 182 | const scriptGzip = Bun.gzipSync(scriptBytes); | |
| 183 | ||
| 153 | 184 | const server = Bun.serve({ |
| 154 | 185 | port: Number(process.env.PORT ?? 8080), |
| 155 | 186 | hostname: process.env.HOST ?? "127.0.0.1", |
| @@ -204,10 +235,15 @@ export async function serve(): Promise<ReturnType<typeof Bun.serve>> { | ||
| 204 | 235 | const session = existing ?? newSession(); |
| 205 | 236 | const player = credit(whoIs(request.headers), session); |
| 206 | 237 | const page = gamePage(db, { env, pull, commit, script, ssh, player }); |
| 207 | const response = html(page, 200, existing === null ? { "set-cookie": sessionCookie(session) } : {}); | |
| 208 | return request.method === "HEAD" | |
| 209 | ? new Response(null, { status: response.status, headers: response.headers }) | |
| 210 | : response; | |
| 238 | const headers = { | |
| 239 | "content-type": "text/html; charset=utf-8", | |
| 240 | // Per-visitor (a name, a guest label, a session cookie): kept out | |
| 241 | // of shared caches, but a browser may hold and revalidate its own. | |
| 242 | "cache-control": "private, no-cache", | |
Approved. The second round closed the one thing I left open (#126) without growing the pull: this line, two lines of comment saying why, and a test that pins both halves of the caching story. Verified at this head, not read. I rebuilt The new test earns its place. With On the wire, with raw sockets, because
Cost. Security. Shape. One module named for one concept, no dependency, no dead code, and One nit, not worth a round: |
||
| 243 | ...SECURITY, | |
| 244 | ...(existing === null ? { "set-cookie": sessionCookie(session) } : {}), | |
| 245 | }; | |
| 246 | return encoded(request, encoder.encode(page), headers); | |
| 211 | 247 | } |
| 212 | 248 | |
| 213 | 249 | // The bundle lives in memory, not on disk: it is what start-up just |
| @@ -218,7 +254,7 @@ export async function serve(): Promise<ReturnType<typeof Bun.serve>> { | ||
| 218 | 254 | "cache-control": "public, max-age=31536000, immutable", |
| 219 | 255 | ...SECURITY, |
| 220 | 256 | }; |
| 221 | return request.method === "HEAD" ? new Response(null, { headers }) : new Response(scriptCode, { headers }); | |
| 257 | return encoded(request, scriptBytes, headers, 200, scriptGzip); | |
| 222 | 258 | } |
| 223 | 259 | |
| 224 | 260 | // Other files under ./static are served as they are, cached for a year. |
Approved. Small, one concept, and it does what it says. I anchor this here because
encoded()is the whole change.What I checked, not just read. I reconstructed this head on top of
9c1997din a scratch tree (byte-identical to the head'ssrc/server.ts— this line 54 is where my copy puts it too) and ran the real check:tsc --noEmit && bun test— 507 pass, 0 fail. Then I went past the tests to the wire, becauseserver.fetchnever serializes a response and that is exactly where a hand-writtencontent-lengthgoes wrong. Raw sockets against the patched server:GET /withAccept-Encoding: gzip→ oneContent-Encoding: gzip,Vary: accept-encoding,Content-Length: 6211, 6211 bytes on the socket, body opening1f8b08— a single gzip member, not a double wrap.GET /static/app.<hash>.jsgzipped →Content-Length: 47118, 47118 bytes delivered, one1f8b08.HEADon both → the same headers carrying the GET length (6211 / 47118) and zero body bytes. Bun honours the manual length instead of collapsing it to0, soHEADanswers likeGET, which is the bar here.Accept-Encoding→ noContent-Encoding, identity bytes, accurate length.That settles the double-encoding question the description left open: nothing in the app encodes twice. I could not reach the preview to check the proxy leg —
https://cs--123--…and the production address both return the platform's own 404 from my sandbox, production included, so that is my egress and not your branch. The forge's screenshots atbd9d7bfshow the page rendering on phone and desktop with the bundle it names.Cost, measured rather than taken on faith.
/in-process: 0.189 ms identity, 0.764 ms gzipped — 0.58 ms added on the hot path, and 19,239 bytes become 6,210. The bundle's gzip is computed once beside the build and never per request: the right split, since the immutable thing is precomputed and only the per-session thing is not.Security. No new trust anywhere.
acceptsGzipreads one request header and returns a boolean, splits rather than backtracks, and cannot be steered into anything but a choice between two byte arrays the server already produced. Identity, the same-origin gate,readBody's cap and both rate limiters are untouched. Compressing/is safe here specifically because that body holds no per-visitor secret — I confirmed the session never appears in the HTML (only the four-hex guest label the leaderboard already publishes), so there is nothing for a compression side channel to leak. The floor rides on both compressed answers; I saw the CSP,nosniffandreferrer-policycome back on the wire.Tests and shape. Every new behaviour has a test that fails without it, and the
q=0and*cases incompress.test.tsare precisely the ones a naiveincludes("gzip")gets wrong. The comment explaining why these useserver.fetchis the right comment to leave. No dependency, no dead code, andsrc/tty.tsreads the database directly rather than over HTTP, so the terminal door is genuinely unaffected by any of this.Two notes, neither blocking. The
statusparameter exists only so the bundle call site can reach the fifth argument; if a third caller ever appears, take an options object instead. And/now carriesvarywhile still carrying nocache-control, which puts the first paint's cacheability in the frame — a per-visitor page with aSet-Cookieand no freshness directives is heuristically cacheable by a shared cache. Nothing leaks today (Cloudflare marks itDYNAMIC), it predates this pull, and I opened #126 for the one-header fix rather than hold this up for it.