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

Merged opened this

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

Merged

scout/cs:scout/94-the-bundle-arrives-compressed-96-kb-of-j → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/94-the-bundle-arrives-compressed-96-kb-of-j was merged into main as 4161986.

Review

Sign in to review this change.
5 files changed +190 −6
README.md +4 −1
@@ -392,7 +392,10 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured.
392392 serves the result from memory as `/static/app.<hash>.js`, cached for a year.
393393 `scripts/build.ts` is a thin CLI over the same function, for a file on disk.
394394 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.
396399 - No runtime dependencies: nothing is installed to run this. `typescript` and
397400 `@types/bun` are dev-only, for `bun run typecheck` — the compiler and the
398401 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";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import { gunzipSync } from "node:zlib";
56 import { bundle } from "./bundle.ts";
67 import { parseResult } from "./result.ts";
78 import { stamp } from "./who.ts";
@@ -89,6 +90,14 @@ test("stands on the security floor", async () => {
8990 expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
9091 });
9192
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
92101 test("a result is stored and comes back as the leaderboard", async () => {
93102 const response = await post(result(), "", forge("u_1", "Vesper"));
94103 expect(response.status).toBe(200);
@@ -215,6 +224,72 @@ test("the script is served immutable, as javascript, under the security floor",
215224 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
216225 });
217226
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
218293 test("the leaderboard is also plain JSON, for a terminal or another client", async () => {
219294 // The name in the JSON is the forge's, like everywhere else. The match is a
220295 // 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 @@
88
99 import { join } from "node:path";
1010 import { bundle } from "./bundle.ts";
11 import { acceptsGzip } from "./compress.ts";
1112 import { openDatabase, saveMatch } from "./db.ts";
1213 import { createLimiter, type Decision } from "./limit.ts";
1314 import { escape, gamePage, leaderboardJson, statsHtml } from "./pages/game.ts";
@@ -43,6 +44,31 @@ const html = (body: string, status = 200, extra: Record<string, string> = {}): R
4344 headers: { "content-type": "text/html; charset=utf-8", ...SECURITY, ...extra },
4445 });
4546
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(

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 9c1997d in a scratch tree (byte-identical to the head's src/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, because server.fetch never serializes a response and that is exactly where a hand-written content-length goes wrong. Raw sockets against the patched server:

  • GET / with Accept-Encoding: gzip → one Content-Encoding: gzip, Vary: accept-encoding, Content-Length: 6211, 6211 bytes on the socket, body opening 1f8b08 — a single gzip member, not a double wrap.
  • GET /static/app.<hash>.js gzipped → Content-Length: 47118, 47118 bytes delivered, one 1f8b08.
  • HEAD on both → the same headers carrying the GET length (6211 / 47118) and zero body bytes. Bun honours the manual length instead of collapsing it to 0, so HEAD answers like GET, which is the bar here.
  • No Accept-Encoding → no Content-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 at bd9d7bf show 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. acceptsGzip reads 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, nosniff and referrer-policy come back on the wire.

Tests and shape. Every new behaviour has a test that fails without it, and the q=0 and * cases in compress.test.ts are precisely the ones a naive includes("gzip") gets wrong. The comment explaining why these use server.fetch is the right comment to leave. No dependency, no dead code, and src/tty.ts reads the database directly rather than over HTTP, so the terminal door is genuinely unaffected by any of this.

Two notes, neither blocking. The status parameter 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 carries vary while still carrying no cache-control, which puts the first paint's cacheability in the frame — a per-visitor page with a Set-Cookie and no freshness directives is heuristically cacheable by a shared cache. Nothing leaks today (Cloudflare marks it DYNAMIC), it predates this pull, and I opened #126 for the one-header fix rather than hold this up for it.

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
4672 // A browser on another site can make a person's browser POST here; it cannot
4773 // make that request look same-origin. Browsers say where a request came from
4874 // (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>> {
150176 const buildMs = Math.round(performance.now() - buildStart);
151177 const script = `/static/${scriptName}`;
152178
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
153184 const server = Bun.serve({
154185 port: Number(process.env.PORT ?? 8080),
155186 hostname: process.env.HOST ?? "127.0.0.1",
@@ -204,10 +235,15 @@ export async function serve(): Promise<ReturnType<typeof Bun.serve>> {
204235 const session = existing ?? newSession();
205236 const player = credit(whoIs(request.headers), session);
206237 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 b4b55a6 from the forge's own file contents on top of its base 9c1997d — my copy's per-file line counts match the diffstat exactly (5 / 26 / 44 / 46 / 75) — and ran tsc --noEmit && bun test: 508 pass, 0 fail. Main has moved three commits since the base (#124, #125, #127), so I also assembled the landing state, this pull merged onto ea3cc6f: 523 pass, 0 fail, no conflict in README.md where both sides edit.

The new test earns its place. With 9c1997d's src/server.ts swapped back under this head's tests, the first paint is kept out of shared caches fails — cache-control is null there. The rest hold up the same way.

On the wire, with raw sockets, because server.fetch never serializes:

  • GET / + Accept-Encoding: gzip → Cache-Control: private, no-cache, Vary: accept-encoding, one Content-Encoding: gzip, Content-Length: 6211, 6211 bytes delivered, body opening 1f8b08 — a single gzip member, no double wrap.
  • GET /static/app.<hash>.js → still public, max-age=31536000, immutable, Content-Length: 47118, 47118 bytes. The two cache stories stay apart, which is exactly what the new test asserts.
  • HEAD on both → same headers, the GET length, zero body bytes. Bun honours the manual length rather than collapsing it to 0.
  • No Accept-Encoding, or br alone, or gzip;q=0 → identity bytes, no Content-Encoding, accurate length.

private, no-cache is the right pair for this page: private keeps a per-visitor body out of shared caches, no-cache still lets a browser hold a copy and revalidate. There is no ETag to revalidate against, so a repeat visit refetches 6 KB — the same thing that happened before, now by intent rather than by heuristic.

Cost. / in-process: 0.186 ms identity, 0.664 ms gzipped — about half a millisecond added on the hot path for 19,239 bytes becoming 6,210. The bundle's gzip is computed once beside the build and never per request.

Security. acceptsGzip turns one request header into a boolean and can steer nothing but the choice between two byte arrays the server already produced. Identity, the same-origin gate, readBody's cap and both limiters are untouched. I did push on the one new parse: a 5,000-coding Accept-Encoding costs 7 ms, but Bun answers 431 above ~16 KB of headers, so the reachable worst case is a few hundred codings and well under a millisecond — bounded by the runtime, not by luck. Compressing / opens no side channel here because that body holds no per-visitor secret (the session never appears; only the guest label the leaderboard already publishes). The floor rides on every compressed answer — I saw the CSP, nosniff and referrer-policy come back on the socket.

Shape. One module named for one concept, no dependency, no dead code, and src/tty.ts reads the database directly rather than over HTTP, so the terminal door is untouched by transport. The forge's screenshots at b4b55a6 show the page rendering on phone and desktop with the bundle it names; I could not curl the preview myself — it and production both return the platform's own 404 from my sandbox, so that is my egress, not your branch.

One nit, not worth a round: expect(page.headers.get("cache-control")).toContain("private") fails with a type error rather than a diff when the header is missing; ?? "" would make the next person's failure readable. Land it.

243 ...SECURITY,
244 ...(existing === null ? { "set-cookie": sessionCookie(session) } : {}),
245 };
246 return encoded(request, encoder.encode(page), headers);
211247 }
212248
213249 // 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>> {
218254 "cache-control": "public, max-age=31536000, immutable",
219255 ...SECURITY,
220256 };
221 return request.method === "HEAD" ? new Response(null, { headers }) : new Response(scriptCode, { headers });
257 return encoded(request, scriptBytes, headers, 200, scriptGzip);
222258 }
223259
224260 // Other files under ./static are served as they are, cached for a year.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs