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

forked from trevato/cs

A first visit is 116 kB where 50 kB would do: every response a browser asked to compress is compressed #236

Merged opened this

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

Merged

torque/cs-engine:torque/229-a-first-visit-is-116-kb-where-50-kb-woul → main 2 commits fork

  • Merged. torque/229-a-first-visit-is-116-kb-where-50-kb-woul was merged into main as cadddb2.

Review

Sign in to review this change.
10 files changed +626 −204
README.md +26
@@ -865,6 +865,32 @@ never checked itself.
865865 `HOST` is also what a shell sets to the machine's name, so only an address —
866866 an IPv4 or IPv6 literal, or `localhost` — is bound and anything else is
867867 ignored with a line in the log rather than failing to resolve at start-up.
868 - `src/compress.ts` — **what a response is sent as.** Nothing reaches a socket
869 except through `send` in `src/server.ts`: one gate where a body meets
870 `accept-encoding`, and the one place a `HEAD` loses its body. A body that is
871 text (or JSON, JavaScript, SVG — the rule is the content type, so a PNG or a
872 font, which are packed already, is never gzipped for nothing), is over
873 `GZIP_MIN` = 1,024 bytes, and comes out smaller gzipped is sent gzipped to a
874 request that asked for gzip, sent as it is to one that did not, and carries
875 `vary: accept-encoding` either way. Anything else — a 9-byte `forbidden`,
876 which gzip's 18-byte frame would *grow* — is the bytes it always was, with no
877 `vary`, because nothing about it depends on that header. A `HEAD` is weighed
878 and compressed exactly like the `GET` it stands for and then has its body
879 dropped, so its headers are the ones a `GET` would have answered with. The
880 level is 6, not the 9 the ceilings in `src/games.ts` are measured at: level 9
881 costs 67% more CPU for 1.5% fewer bytes on the shooter's bundle, and on the
882 weights it is 192 bytes *worse*. The files in `static/` are read and gzipped
883 **once, at start-up** — 4.5 ms for all four, held beside their raw bytes — so
884 a hit on a bundle is a map lookup and a write of bytes that already exist,
885 never 95 kB of JavaScript compressed again for every visitor with a cold
886 cache; a page, which is rendered per request, is weighed with it, which costs
887 about 0.4 ms on an 18 kB document. What a first visit to `/` transfers is the
888 page, the bundle and the weights: 6,758 + 38,119 + 8,059 = 52,936 bytes,
889 **51.7 kB where it was 121.9 kB** (124,811 bytes; a kB is 1,024 of them, the
890 ruler in `src/bytes.ts`). `/range` is 5,693 against 14,945 and `/example`
891 4,180 against 10,761. Those three page numbers are a budget in
892 `src/server.test.ts`, so a page that doubles in size fails a test rather than
893 a review.
868894 - `src/policy.ts` — **the content security policy**, and the one part of it that
869895 is not a constant. The floor is `default-src 'self'`, no inline script,
870896 `frame-ancestors 'none'`, `base-uri 'none'`, `form-action 'self'`, and
src/compress.test.ts added +69
@@ -0,0 +1,69 @@
1 import { expect, test } from "bun:test";
2 import { acceptsGzip, compressible, GZIP_LEVEL, GZIP_MIN, weigh, weighText } from "./compress.ts";
3
4 const prose = "the bomb is down, the site is smoked, and nobody is watching the far door. ".repeat(40);
5 const bytes = (text: string): Uint8Array<ArrayBuffer> => new TextEncoder().encode(text) as Uint8Array<ArrayBuffer>;
6 const gunzip = (data: Uint8Array<ArrayBuffer>): string => new TextDecoder().decode(Bun.gunzipSync(data));
7
8 test("text and the types built on it are worth compressing; bytes that are already packed are not", () => {
9 for (const type of [
10 "text/html; charset=utf-8", "text/plain", "TEXT/CSS", "application/json;charset=utf-8",
11 "application/javascript", "text/javascript;charset=utf-8", "image/svg+xml",
12 "application/manifest+json", "application/atom+xml",
13 ])
14 expect(compressible(type)).toBe(true);
15
16 // A PNG, a font and a zip are compressed already: gzipping them spends CPU to
17 // make them a few bytes larger. An unknown type is assumed to be one of those.
18 for (const type of ["image/png", "image/webp", "font/woff2", "application/zip", "application/octet-stream", ""])
19 expect(compressible(type)).toBe(false);
20 });
21
22 test("a request is only sent gzip when it said it reads gzip", () => {
23 // A browser's header, and the same header with the encoding refused by name.
24 expect(acceptsGzip("gzip, deflate, br, zstd")).toBe(true);
25 expect(acceptsGzip("gzip")).toBe(true);
26 expect(acceptsGzip("GZIP;q=1.0")).toBe(true);
27 expect(acceptsGzip("br, gzip;q=0.5")).toBe(true);
28 expect(acceptsGzip("gzip;q=0")).toBe(false);
29 expect(acceptsGzip("gzip;q=0, *")).toBe(false); // named, so the wildcard does not overrule it
30 expect(acceptsGzip("*")).toBe(true);
31 expect(acceptsGzip("*;q=0")).toBe(false);
32 expect(acceptsGzip("br, deflate")).toBe(false);
33 expect(acceptsGzip("identity")).toBe(false);
34 // No header at all is not an invitation: a client that does not say cannot be
35 // assumed to read it. curl without --compressed is exactly this.
36 expect(acceptsGzip(null)).toBe(false);
37 expect(acceptsGzip("")).toBe(false);
38 });
39
40 test("a body worth compressing is weighed with its gzip beside it, and the gzip is the same bytes back", () => {
41 const body = weighText(prose, "text/html; charset=utf-8");
42 expect(body.raw.byteLength).toBe(prose.length);
43 expect(body.gzip).not.toBeNull();
44 expect(body.gzip?.byteLength ?? 0).toBeLessThan(body.raw.byteLength / 2);
45 expect(gunzip(body.gzip as Uint8Array<ArrayBuffer>)).toBe(prose);
46 });
47
48 test("a short body is left alone, because gzip's frame costs more than it saves", () => {
49 // The sentence in src/compress.ts, as a number: the 9-byte refusal this
50 // server answers a cross-site POST with grows when it is gzipped.
51 const refusal = bytes("forbidden");
52 expect(Bun.gzipSync(refusal, { level: GZIP_LEVEL }).byteLength).toBeGreaterThan(refusal.byteLength);
53 expect(weigh(refusal, "text/plain; charset=utf-8").gzip).toBeNull();
54
55 // The threshold itself: one byte under is left alone, a kilobyte of the same
56 // prose is not.
57 expect(weigh(bytes("a".repeat(GZIP_MIN - 1)), "text/html").gzip).toBeNull();
58 expect(weigh(bytes("a".repeat(GZIP_MIN)), "text/html").gzip).not.toBeNull();
59 });
60
61 test("a body that is already compressed is sent as it is, whatever its size", () => {
62 // Bytes with nothing to find in them: gzip makes them longer, so the weighing
63 // throws its own work away rather than send more bytes than it was given.
64 const noise = new Uint8Array(new ArrayBuffer(8 * 1024));
65 crypto.getRandomValues(noise);
66 expect(weigh(noise as Uint8Array<ArrayBuffer>, "application/json").gzip).toBeNull();
67 // And a type that says so is not even tried.
68 expect(weigh(bytes(prose), "image/png").gzip).toBeNull();
69 });
src/compress.ts added +95
@@ -0,0 +1,95 @@
1 /**
2 * What a response is sent as, once the browser has said what it can read.
3 *
4 * Two questions and nothing else: is this body worth gzipping, and did this
5 * request ask for gzip. Both are answered here so `src/server.ts` has one gate
6 * rather than a decision per route, and both are pure — bytes and a header
7 * string in, bytes and a boolean out — so the rules are tested without a socket.
8 *
9 * The level is 6, not the 9 `scripts/build.ts` measures ceilings at. Measured
10 * on this tree, 2026-09-21: level 9 costs 67% more CPU (all four files in
11 * static/, 7.5 ms against 4.5 ms) and buys 1.5% fewer bytes on the shooter's
12 * bundle (37,531 against 38,119) — and on the weights it is 192 bytes *worse*
13 * (8,251 against 8,059), because level 9's longer searches are not free on
14 * data that barely repeats. The ceilings in `src/games.ts` stay at 9: that
15 * number is a stable ruler between machines, not a promise about the wire.
16 */
17
18 /** The one level this server compresses at. See the note above for why it is not 9. */
19 export const GZIP_LEVEL = 6;
20
21 /**
22 * Bodies under this are sent as they are. gzip's frame is 18 bytes before a
23 * single byte of content, so a short answer gains nothing and a very short one
24 * grows: the 9-byte "forbidden" this server refuses a cross-site POST with
25 * gzips to 29 bytes. A kilobyte is where the saving starts to be worth a
26 * millisecond of anyone's CPU and a `vary` on every cache between here and the
27 * reader — the response already fits in the first packet alongside its headers.
28 */
29 export const GZIP_MIN = 1024;
30
31 /**
32 * Is this content type worth gzipping? Text is, and the structured types built
33 * on it — JSON, JavaScript, SVG, anything `+json` or `+xml`. Everything else is
34 * assumed to be compressed already: a PNG, a font, a zip. Gzipping those spends
35 * CPU to make them a few bytes larger, which is the opposite of the point.
36 *
37 * The rule is by type and not by extension so one answer covers a page, a
38 * fragment, a bundle and a future asset alike.
39 */
40 export function compressible(type: string): boolean {
41 const kind = type.split(";")[0]?.trim().toLowerCase() ?? "";
42 if (kind.startsWith("text/")) return true;
43 if (kind.endsWith("+json") || kind.endsWith("+xml")) return true;
44 return ["application/json", "application/javascript", "application/xml", "image/svg+xml"].includes(kind);
45 }
46
47 /**
48 * Did this request ask for gzip? `accept-encoding: gzip, deflate, br` says yes;
49 * no header at all says no, because a client that does not say cannot be
50 * assumed to read it. A q-value of 0 is a refusal — `gzip;q=0` means "not
51 * that one" — and `*` stands in for anything the header did not name, so a
52 * header that names gzip is read for gzip and the wildcard only decides when
53 * gzip is unnamed.
54 */
55 export function acceptsGzip(header: string | null): boolean {
56 if (header === null) return false;
57 let star: boolean | undefined;
58 for (const part of header.split(",")) {
59 const [name, ...params] = part.trim().toLowerCase().split(";");
60 if (name !== "gzip" && name !== "*") continue;
61 const q = params.map((p) => p.trim()).find((p) => p.startsWith("q="));
62 const weight = q === undefined ? 1 : Number(q.slice(2));
63 const wanted = Number.isFinite(weight) && weight > 0;
64 if (name === "gzip") return wanted;
65 star = wanted;
66 }
67 return star ?? false;
68 }
69
70 /**
71 * A body ready to be written: the bytes as they are, and their gzip when gzip
72 * is worth sending — `null` when it is not, which is also the whole of how the
73 * gate knows whether this body depends on `accept-encoding` and so whether it
74 * needs a `vary`.
75 *
76 * A gzip that came out no smaller than the original is thrown away: it is not
77 * worth a decompression on a phone to send more bytes.
78 */
79 export type Body = { raw: Uint8Array<ArrayBuffer>; gzip: Uint8Array<ArrayBuffer> | null };
80
81 /**
82 * Weigh one body once. The caller decides when: a page is weighed as it is
83 * rendered, a file in static/ is weighed at start-up and held, because the
84 * bytes there never change and gzipping 90 kB on every hit would trade the
85 * bytes this saves for milliseconds nobody asked to spend.
86 */
87 export function weigh(raw: Uint8Array<ArrayBuffer>, type: string): Body {
88 if (raw.byteLength < GZIP_MIN || !compressible(type)) return { raw, gzip: null };
89 const gzip = Bun.gzipSync(raw, { level: GZIP_LEVEL });
90 return { raw, gzip: gzip.byteLength < raw.byteLength ? gzip : null };
91 }
92
93 /** The same, for a body that is still a string — every page this server renders. */
94 export const weighText = (text: string, type: string): Body =>
95 weigh(new TextEncoder().encode(text) as Uint8Array<ArrayBuffer>, type);
src/pages/example.test.ts +2 −1
@@ -18,6 +18,7 @@ import { expect, test } from "bun:test";
1818 import { readdirSync, readFileSync } from "node:fs";
1919 import { join } from "node:path";
2020 import { bundleOf, GZIP_LEVEL, STATIC_DIR } from "../../scripts/build.ts";
21 import { GZIP_LEVEL as WIRE_LEVEL } from "../compress.ts";
2122 import { bytes, kB } from "../bytes.ts";
2223 import { GAME_SOURCE, lineCount, measureExample, WIRING_SOURCES } from "../cost.ts";
2324 import { costHtml, examplePage } from "./example.ts";
@@ -106,7 +107,7 @@ test("no number the page states about itself is one somebody typed", () => {
106107 const allowed = new Set([
107108 bytes(cost.script.raw), bytes(cost.shooter.raw),
108109 kB(cost.script.gzip).replace(" kB", ""), kB(cost.shooter.gzip).replace(" kB", ""),
109 String(cost.game), String(cost.wiring), String(GZIP_LEVEL), "1,024",
110 String(cost.game), String(cost.wiring), String(GZIP_LEVEL), String(WIRE_LEVEL), "1,024",
110111 "2", // "2D on one canvas", and the two files of wiring
111112 "404", // the page nobody had to edit
112113 ]);
src/pages/example.ts +4 −1
@@ -11,6 +11,7 @@
1111 */
1212
1313 import { bytes, kB } from "../bytes.ts";
14 import { GZIP_LEVEL as WIRE_LEVEL } from "../compress.ts";
1415 import { type Cost, GZIP_LEVEL } from "../cost.ts";
1516 import { navFor, othersHtml } from "./games.ts";
1617 import { page, type PageOptions as ShellOptions, stage } from "./shell.ts";
@@ -70,7 +71,9 @@ export function costHtml(cost: Cost): string {
7071 <p>Every number in those two paragraphs is measured, not typed. The byte counts are this
7172 deployment's own committed bundles under <code>static/</code>, read when this server started
7273 and gzipped at level ${GZIP_LEVEL} — the ruler <code>scripts/build.ts</code> weighs the
73 ceilings with, and a kB here is 1,024 bytes. The line counts are those source files read the
74 ceilings with, and a kB here is 1,024 bytes. Your browser really was sent gzip for
75 this page and for that bundle: <code>src/compress.ts</code> writes the wire at level
76 ${WIRE_LEVEL} — a hair above the figure here, and the same bytes on the same ruler. The line counts are those source files read the
7477 same way. A bundle that grows moves this sentence on the next deploy, and
7578 <code>src/pages/example.test.ts</code> fails if it does not.</p>`;
7679 }
src/server.test.ts +139 −1
@@ -21,7 +21,8 @@ process.env.PORT = "0";
2121 // The test is the forge: it holds the same key and signs identities with it.
2222 const KEY = "the-key-the-forge-signs-with";
2323 process.env.FORGE_APP_IDENTITY_KEY = KEY;
24 const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT, PLAN_ID, onError, findScripts } = await import("./server.ts");
24 const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT, PLAN_ID, onError, findScripts, holdStatic } =
25 await import("./server.ts");
2526 const base = `http://${server.hostname}:${server.port}`;
2627
2728 /** The two headers the forge puts on a request it proxies for a signed-in person. */
@@ -813,6 +814,143 @@ test("the script is served immutable and cached for a year", async () => {
813814 expect(response.headers.get("cache-control")).toContain("immutable");
814815 });
815816
817 // What a browser downloads, over the wire. These go through `server.fetch`
818 // rather than `fetch`, because a client decompresses what it is given and then
819 // the header that said so is gone: this is the response as it is written.
820 const raw = async (path: string, headers: Record<string, string> = {}, method = "GET"): Promise<Response> =>
821 await server.fetch(new Request(base + path, { method, headers }));
822
823 const ASKS = { "accept-encoding": "gzip, deflate, br, zstd" };
824 const sent = async (response: Response): Promise<Uint8Array<ArrayBuffer>> =>
825 new Uint8Array(await response.arrayBuffer()) as Uint8Array<ArrayBuffer>;
826
827 test("a browser that asks for gzip is sent gzip, and it is exactly the bytes one that does not asks gets", async () => {
828 const page = await (await fetch(base + "/")).text();
829 const script = /src="(\/static\/app\.[0-9a-f]+\.js)"/.exec(page)?.[1] as string;
830 const weights = /href="(\/static\/weights\.[0-9a-f]+\.json)"/.exec(page)?.[1] as string;
831 // A page rendered for this request, an asset weighed at start-up, and the
832 // 404, which is neither: the same rule under all three.
833 for (const path of ["/", "/range", "/example", script, weights, "/nope"]) {
834 const plain = await raw(path);
835 const zipped = await raw(path, ASKS);
836 expect(zipped.status).toBe(plain.status);
837
838 // The one that asked is compressed and says so; the one that did not is
839 // the bytes it always was. Both say the body depends on the header.
840 expect(zipped.headers.get("content-encoding")).toBe("gzip");
841 expect(plain.headers.get("content-encoding")).toBeNull();
842 expect(zipped.headers.get("vary")).toBe("accept-encoding");
843 expect(plain.headers.get("vary")).toBe("accept-encoding");
844
845 // And it is the same document: gunzipped, byte for byte, what the other got.
846 const original = await sent(plain);
847 const gzipped = await sent(zipped);
848 expect(gzipped.byteLength).toBeLessThan(original.byteLength);
849 expect(Bun.gunzipSync(gzipped)).toEqual(original);
850
851 // The floor is on both, compressed or not, and an asset keeps its year.
852 for (const response of [plain, zipped]) {
853 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
854 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
855 expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
856 expect(response.headers.get("permissions-policy")).toBe("camera=(), microphone=(), geolocation=()");
857 if (path.startsWith("/static/"))
858 expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
859 }
860 expect(zipped.headers.get("content-type")).toBe(plain.headers.get("content-type"));
861 }
862 });
863
864 test("a body too short to gain anything is sent as it is, and does not vary", async () => {
865 // The 9-byte refusal a cross-site POST gets: gzip's frame alone is twice it.
866 const refused = await raw("/api/match", { ...ASKS, "sec-fetch-site": "cross-site" }, "POST");
867 expect(refused.status).toBe(403);
868 expect(refused.headers.get("content-encoding")).toBeNull();
869 expect(refused.headers.get("vary")).toBeNull();
870 expect(await refused.text()).toBe("forbidden");
871 expect(refused.headers.get("content-security-policy")).toContain("default-src 'self'");
872 });
873
874 test("HEAD answers like GET when gzip is asked for: same status, same headers, nothing to read", async () => {
875 const names = (response: Response): string[] =>
876 [...response.headers]
877 .filter(([name]) => name !== "set-cookie" && name !== "date")
878 .map(([name, value]) => `${name}: ${value}`)
879 .sort();
880 for (const path of ["/", "/range", "/nope"]) {
881 const get = await raw(path, ASKS);
882 const head = await raw(path, ASKS, "HEAD");
883 expect(head.status).toBe(get.status);
884 expect(names(head)).toEqual(names(get));
885 expect(head.headers.get("content-encoding")).toBe("gzip");
886 expect(await head.text()).toBe("");
887 }
888 });
889
890 test("every file in static/ is gzipped once, at start-up, and held beside its bytes", () => {
891 // The map the server built before it bound, from the same function: a request
892 // for a bundle writes bytes that already exist rather than gzipping 90 kB of
893 // JavaScript again for every visitor with a cold cache.
894 const held = holdStatic();
895 expect(held.size).toBeGreaterThanOrEqual(GAMES.length);
896 for (const [name, file] of held) {
897 expect(file.body.raw.byteLength).toBeGreaterThan(0);
898 expect(file.body.gzip).not.toBeNull();
899 expect(file.body.gzip?.byteLength ?? 0).toBeLessThan(file.body.raw.byteLength);
900 expect(Bun.gunzipSync(file.body.gzip as Uint8Array<ArrayBuffer>)).toEqual(file.body.raw);
901 expect(file.type).toContain(name.endsWith(".json") ? "application/json" : "text/javascript");
902 }
903
904 // And what is not worth it is not held compressed: a short file, and one that
905 // is compressed already.
906 const dir = mkdtempSync(join(tmpdir(), "held-"));
907 try {
908 writeFileSync(join(dir, "small.0123456789.js"), "let x=1");
909 const noise = new Uint8Array(4096);
910 crypto.getRandomValues(noise);
911 writeFileSync(join(dir, "pic.0123456789.png"), noise);
912 const other = holdStatic(dir);
913 expect(other.get("small.0123456789.js")?.body.gzip).toBeNull();
914 expect(other.get("pic.0123456789.png")?.body.gzip).toBeNull();
915 expect(other.get("pic.0123456789.png")?.type).toBe("image/png");
916 } finally {
917 rmSync(dir, { recursive: true, force: true });
918 }
919 });
920
921 /**
922 * What a first visit transfers to a browser that accepts gzip, per page, and
923 * what each may not pass. Measured 2026-09-21 on this tree, compressed: `/`
924 * 6,758 bytes against 18,662 uncompressed, `/range` 5,693 against 14,945,
925 * `/example` 4,180 against 10,761 — 16.2 kB where 43.3 kB used to go, before
926 * the bundle each of them then downloads.
927 *
928 * The budgets are those numbers with room for a paragraph and a control or two,
929 * the same way `src/games.ts` writes a bundle's ceiling: tight enough that a
930 * page which doubles fails here, loose enough that an honest feature lands
931 * without an edit. The bundles are not in this table — they have ceilings of
932 * their own in `src/games.ts`, weighed by `scripts/build.test.ts`.
933 *
934 * `/` is measured on this file's database, which by now holds every match the
935 * tests above posted, so it is the page at its heaviest rather than at its
936 * emptiest.
937 */
938 const BUDGET: Record<string, number> = { "/": 9000, "/range": 7500, "/example": 5500 };
939
940 test("a first visit stays inside its byte budget, compressed", async () => {
941 for (const [path, budget] of Object.entries(BUDGET)) {
942 const zipped = await sent(await raw(path, ASKS));
943 const plain = await sent(await raw(path));
944 // The failure names the page and both numbers, so whoever trips it knows
945 // what grew and by how much before opening anything.
946 const said = `${path} sends ${zipped.byteLength} B gzipped (${plain.byteLength} B raw), budget ${budget} B`;
947 expect(said + (zipped.byteLength <= budget ? "" : " — over")).toBe(said);
948 // And the compression is doing the work it is here for: a page is prose and
949 // markup, which is where gzip earns its keep.
950 expect(zipped.byteLength).toBeLessThan(plain.byteLength / 2);
951 }
952 });
953
816954 // The parser itself is pinned rule by rule in src/result.test.ts, which is a
817955 // unit test and runs in milliseconds. What is left here is the end-to-end: a
818956 // POST that reaches it, and the status a refusal comes back as.
src/server.ts +233 −85
@@ -23,6 +23,13 @@
2323 * each throw before this listens, so none of them is ever a 404 a visitor meets
2424 * instead.
2525 *
26 * Nothing is written to a socket except through `send`: it is the one gate
27 * where a body meets `accept-encoding` and the one place a HEAD loses its
28 * body, so gzip is a property of this server and not of a route that
29 * remembered. The files in static/ are read and gzipped once, at start-up, and
30 * held — a hit on a bundle is a map lookup and a write of bytes that already
31 * exist. `src/compress.ts` holds what is worth compressing and what asked for it.
32 *
2633 * A result is the browser's word for the numbers and nobody's word for the
2734 * name, so the name is not taken from it: `src/who.ts` verifies the forge's
2835 * signed identity headers and a result is recorded under that, or as a guest's.
@@ -30,10 +37,12 @@
3037 * what socket this listens on.
3138 */
3239
33 import { existsSync, readdirSync } from "node:fs";
40 import { existsSync, readdirSync, readFileSync } from "node:fs";
3441 import { join } from "node:path";
3542 import { assetOf, bundleOf, STATIC_DIR } from "../scripts/build.ts";
3643 import { bind } from "./bind.ts";
44 import { kB } from "./bytes.ts";
45 import { acceptsGzip, type Body, weigh, weighText } from "./compress.ts";
3746 import { measureExample } from "./cost.ts";
3847 import { openDatabase, type Player, saveMatch } from "./db.ts";
3948 import { GAMES } from "./games.ts";
@@ -112,13 +121,30 @@ const SECURITY = floorWith(policyFor());
112121 */
113122 const documentFloor = (extraStyle?: string): Headers => floorWith(policyFor(styleFor(extraStyle)));
114123
124 /** What every document this server writes is: one string, in one content type. */
125 const HTML_TYPE = "text/html; charset=utf-8";
126
115127 const html = (body: string, security: Headers = SECURITY, status = 200, extra: Headers = {}): Response =>
116128 new Response(body, {
117129 status,
118 headers: { "content-type": "text/html; charset=utf-8", ...security, ...extra },
130 headers: { "content-type": HTML_TYPE, ...security, ...extra },
119131 });
120132
121 /** The floor under a document that is data: never stored, never a page. */
133 /**
134 * A refusal, in words: a few bytes of plain text, the floor, and a status. The
135 * type is written down rather than left to the runtime to guess, because `send`
136 * reads it to decide whether a body is worth compressing — and every one of
137 * these is far too short to be.
138 */
139 const text = (body: string, status: number, extra: Headers = {}): Response =>
140 new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY, ...extra } });
141
142 /**
143 * The floor under a document that is data: never stored, never a page. A plan
144 * is JSON, so `send` will gzip one that is worth gzipping — a situation's
145 * answer is a few hundred bytes and usually is not — and `no-store` keeps it
146 * out of every cache either way.
147 */
122148 const json = (value: unknown): Response =>
123149 new Response(JSON.stringify(value), {
124150 headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...SECURITY },
@@ -295,6 +321,60 @@ export function findAssets(dir = STATIC_DIR, games = GAMES): Map<string, Linked[
295321 }
296322 const assets = findAssets();
297323
324 /**
325 * Everything in static/, read and weighed once, here: the bytes on disk, their
326 * gzip when gzip is worth sending, and the content type the name says they are.
327 * A request for one of them is then a map lookup and a write of bytes that
328 * already exist — no open, no read, no compression on the hot path.
329 *
330 * Holding them costs what they weigh. On this tree that is the four committed
331 * files, 162,074 bytes raw and 70,227 gzipped — 158.3 kB and 68.6 kB of this
332 * process — and 4.5 ms of start-up to compress them all. The alternative is
333 * 2.5 ms of gzip on every hit of the shooter's bundle, paid again for every
334 * visitor with a cold cache, which is the wrong trade on a file whose contents
335 * can never change under its name. The boot log prints what it cost this boot.
336 *
337 * Read at start-up and not again, like every other file this module resolves:
338 * the names in static/ are content hashes written by the build before this
339 * process starts, so a file appearing later is a deploy that has not happened.
340 * That also makes the path check the map itself — a key is one file name, so
341 * `/static/../src/server.ts` is a name nothing is filed under, not a path.
342 */
343 export type Held = { body: Body; type: string };
344
345 export function holdStatic(dir = STATIC_DIR): Map<string, Held> {
346 const held = new Map<string, Held>();
347 if (!existsSync(dir)) return held;
348 for (const entry of readdirSync(dir, { withFileTypes: true })) {
349 if (!entry.isFile()) continue;
350 const path = join(dir, entry.name);
351 const type = Bun.file(path).type;
352 held.set(entry.name, { body: weigh(new Uint8Array(readFileSync(path)), type), type });
353 }
354 return held;
355 }
356 const heldFrom = Bun.nanoseconds();
357 const STATIC = holdStatic();
358 const heldIn = (Bun.nanoseconds() - heldFrom) / 1e6;
359
360 /**
361 * What start-up paid for that, in the boot log: the files, what they weigh
362 * raw, what they weigh gzipped and the milliseconds it took to find out. It is
363 * the one cost of holding them, so it is printed rather than described.
364 */
365 const staticSummary = (held = STATIC, ms = heldIn): string => {
366 let raw = 0;
367 let sent = 0;
368 for (const { body } of held.values()) {
369 raw += body.raw.byteLength;
370 sent += body.gzip?.byteLength ?? body.raw.byteLength;
371 }
372 return `${held.size} files in static/, ${kB(raw)} raw / ${kB(sent)} gzipped, weighed in ${ms.toFixed(1)} ms`;
373 };
374
375 /** What every file in static/ is served with: a year in any cache, and the floor. */
376 const IMMUTABLE = { "cache-control": "public, max-age=31536000, immutable", ...SECURITY };
377
298378 /**
299379 * What `/example` says a third game cost, weighed and counted once, here,
300380 * against the files this process serves: the committed bundles in static/ and
@@ -434,15 +514,77 @@ const MISS_SECURITY = documentFloor(MISS_STYLE);
434514 * is not handed to the page, so there is nothing on it to leak. Exported
435515 * because it is the whole of what a 500 is here, and a test can call it without
436516 * leaving a broken route behind.
517 *
518 * The one response that does not pass the gate below: the runtime hands this
519 * an error and no request, so there is no `accept-encoding` to read and the
520 * page goes out as it is. A 500 is the one answer nobody should be measuring
521 * the bytes of.
437522 */
438523 export function onError(error: unknown): Response {
439524 console.error(error);
440525 return html(SERVER_ERROR, MISS_SECURITY, 500);
441526 }
442527
443 // HEAD is GET without the body: the headers, the status and nothing to read.
444 const withoutBodyIfHead = (request: Request, response: Response): Response =>
445 request.method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
528 /**
529 * A body that has already been weighed, with the status and headers it is sent
530 * under: what the static route hands the gate, because those bytes and their
531 * gzip were both made at start-up and neither is made again.
532 */
533 type Ready = { body: Body; status: number; headers: HeadersInit };
534
535 /**
536 * The 404, weighed at start-up with the files it sits beside: the document
537 * never changes, so neither do its bytes or their gzip, and a wrong path costs
538 * a map miss and a write.
539 */
540 const MISS: Ready = {
541 body: weighText(NOT_FOUND, HTML_TYPE),
542 status: 404,
543 headers: { "content-type": HTML_TYPE, ...MISS_SECURITY },
544 };
545
546 /**
547 * The one place a response meets `accept-encoding`, and the one place a HEAD
548 * loses its body. Everything this server answers with goes through here: the
549 * routes above make a Response, or hand over a body weighed once at start-up,
550 * and what is written is decided here and nowhere else.
551 *
552 * The rules, whole: a body with a gzip beside it (compressible type, over
553 * `GZIP_MIN`, and smaller gzipped — `src/compress.ts` decides all three) is
554 * sent gzipped to a request that asked for gzip and as it is to one that did
555 * not, and either way it carries `vary: accept-encoding`, because those are two
556 * different answers to one URL and a cache between here and the reader must
557 * keep them apart. A body with no gzip beside it — a 9-byte refusal, a PNG — is
558 * the bytes it always was, with no `vary`, because nothing about it depends on
559 * that header.
560 *
561 * A HEAD is weighed and compressed like the GET it stands for and then has its
562 * body dropped: the work is wasted on purpose, so that the status and every
563 * header — `content-encoding` and `vary` with the rest — are the ones a GET
564 * would have come back with.
565 *
566 * The security floor rides along untouched: these headers are the route's own,
567 * copied, with at most `vary` and `content-encoding` added.
568 */
569 async function send(request: Request, made: Response | Ready): Promise<Response> {
570 const ready: Ready =
571 made instanceof Response
572 ? {
573 body: weigh(new Uint8Array(await made.arrayBuffer()), made.headers.get("content-type") ?? ""),
574 status: made.status,
575 headers: made.headers,
576 }
577 : made;
578 const headers = new globalThis.Headers(ready.headers);
579 const gzip = ready.body.gzip;
580 if (gzip !== null) {
581 headers.set("vary", "accept-encoding");
582 if (acceptsGzip(request.headers.get("accept-encoding"))) headers.set("content-encoding", "gzip");
583 }
584 const body = headers.get("content-encoding") === "gzip" ? (gzip as Uint8Array<ArrayBuffer>) : ready.body.raw;
585 // HEAD is GET without the body: the headers, the status and nothing to read.
586 return new Response(request.method === "HEAD" ? null : body, { status: ready.status, headers });
587 }
446588
447589 const isRead = (request: Request): boolean => request.method === "GET" || request.method === "HEAD";
448590
@@ -452,94 +594,100 @@ export const server = Bun.serve({
452594 port: listen.port,
453595 hostname: listen.hostname,
454596 async fetch(request, listener) {
455 const url = new URL(request.url);
456 if (request.method === "POST" && !sameOrigin(request))
457 return new Response("forbidden", { status: 403, headers: SECURITY });
458
459 if (request.method === "POST" && url.pathname === "/api/match") {
460 const visitor = who(request, identityKey);
461 const held = readSession(request);
462 if (!mayWrite(visitor, held, listener.requestIP(request)?.address))
463 return new Response("slow down", {
464 status: 429,
465 headers: { ...SECURITY, "retry-after": String(Math.ceil(MATCH_LIMIT.per / 1000)) },
466 });
467 const body = await readBody(request);
468 if (body === undefined) return new Response("too large", { status: 413, headers: SECURITY });
469 let parsed: unknown;
470 try {
471 parsed = JSON.parse(new TextDecoder().decode(body));
472 } catch {
473 return new Response("bad json", { status: 400, headers: SECURITY });
474 }
475 const result = parseResult(parsed);
476 if (result === null) return new Response("bad result", { status: 422, headers: SECURITY });
477 const session = held ?? newSession();
478 const stored = saveMatch(db, playerOf(visitor, session, result.name), result);
479 if (!stored) return new Response("not yours", { status: 409, headers: SECURITY });
480 return html(statsHtml(db), SECURITY, 200, { "set-cookie": sessionCookie(session) });
481 }
482
483 /**
484 * The round the bots are about to play. The page sends the situation — the
485 * map, the round, the score and what each side can afford, and nothing about
486 * whoever is playing — and gets back a plan for both sides.
487 *
488 * It is a POST because it is not free: it may cost this deployment a call
489 * to a third party, so it is same-origin (refused above), rate-limited per
490 * caller, under a spend ceiling for the process, and capped at a body no
491 * honest situation comes close to. Every answer is a plan: a caller over
492 * its limit, a deployment with no key and an upstream that fell over all
493 * get the local one rather than an error, because a bot with no plan is a
494 * bot standing still.
495 */
496 if (request.method === "POST" && url.pathname === "/api/plan") {
497 const body = await readBody(request, PLAN_BODY_MAX);
498 if (body === undefined) return new Response("too large", { status: 413, headers: SECURITY });
499 let parsed: unknown;
500 try {
501 parsed = JSON.parse(new TextDecoder().decode(body));
502 } catch {
503 return new Response("bad json", { status: 400, headers: SECURITY });
504 }
505 const situation = parseSituation(parsed);
506 if (situation === null) return new Response("bad situation", { status: 422, headers: SECURITY });
507 const caller = `plan:${listener.requestIP(request)?.address ?? "?"}|${readSession(request) ?? "-"}`;
508 const mine = planner !== null && planLimit.take(caller) && planSpend.take("all");
509 return json(mine ? await (planner as Planner).plan(situation) : localPlan(situation));
510 }
511
512 // The games, one table rather than one branch each. HEAD is GET without a
513 // body, so a health check on `/` sees the app up.
514 if (isRead(request)) {
515 const render = READ.get(url.pathname);
516 if (render !== undefined) return withoutBodyIfHead(request, render(request));
517 }
518
519 // Files under ./static are served as they are, cached for a year: they are
520 // named by content (app.3f2a9c1b04.js), so a change is a new name, never a
521 // stale cache. The path is confined to the directory, and a name that is
522 // not there — another game's old bundle, a guess — is a 404 like any other.
523 if (url.pathname.startsWith("/static/") && !url.pathname.includes("..")) {
524 const file = Bun.file(join(STATIC_DIR, url.pathname.slice("/static/".length)));
525 if (await file.exists())
526 return new Response(file, {
527 headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY },
528 });
529 }
530
531 // Nothing answers here. HEAD is GET without a body on a miss too: the
532 // status and the headers, and nothing to read.
533 return withoutBodyIfHead(request, html(NOT_FOUND, MISS_SECURITY, 404));
597 return send(request, await route(request, listener));
534598 },
535599 error: onError,
536600 });
537601
602 /**
603 * What answers one request, before anything has been written: a Response, or a
604 * body that was weighed at start-up. Nothing here sets `content-encoding` or
605 * drops a HEAD's body — `send` above does both, for all of it, in one place.
606 */
607 async function route(request: Request, listener: Bun.Server<undefined>): Promise<Response | Ready> {
608 const url = new URL(request.url);
609 if (request.method === "POST" && !sameOrigin(request))
610 return text("forbidden", 403);
611
612 if (request.method === "POST" && url.pathname === "/api/match") {
613 const visitor = who(request, identityKey);
614 const held = readSession(request);
615 if (!mayWrite(visitor, held, listener.requestIP(request)?.address))
616 return text("slow down", 429, { "retry-after": String(Math.ceil(MATCH_LIMIT.per / 1000)) });
617 const body = await readBody(request);
618 if (body === undefined) return text("too large", 413);
619 let parsed: unknown;
620 try {
621 parsed = JSON.parse(new TextDecoder().decode(body));
622 } catch {
623 return text("bad json", 400);
624 }
625 const result = parseResult(parsed);
626 if (result === null) return text("bad result", 422);
627 const session = held ?? newSession();
628 const stored = saveMatch(db, playerOf(visitor, session, result.name), result);
629 if (!stored) return text("not yours", 409);
630 return html(statsHtml(db), SECURITY, 200, { "set-cookie": sessionCookie(session) });
631 }
632
633 /**
634 * The round the bots are about to play. The page sends the situation — the
635 * map, the round, the score and what each side can afford, and nothing about
636 * whoever is playing — and gets back a plan for both sides.
637 *
638 * It is a POST because it is not free: it may cost this deployment a call
639 * to a third party, so it is same-origin (refused above), rate-limited per
640 * caller, under a spend ceiling for the process, and capped at a body no
641 * honest situation comes close to. Every answer is a plan: a caller over
642 * its limit, a deployment with no key and an upstream that fell over all
643 * get the local one rather than an error, because a bot with no plan is a
644 * bot standing still.
645 */
646 if (request.method === "POST" && url.pathname === "/api/plan") {
647 const body = await readBody(request, PLAN_BODY_MAX);
648 if (body === undefined) return text("too large", 413);
649 let parsed: unknown;
650 try {
651 parsed = JSON.parse(new TextDecoder().decode(body));
652 } catch {
653 return text("bad json", 400);
654 }
655 const situation = parseSituation(parsed);
656 if (situation === null) return text("bad situation", 422);
657 const caller = `plan:${listener.requestIP(request)?.address ?? "?"}|${readSession(request) ?? "-"}`;
658 const mine = planner !== null && planLimit.take(caller) && planSpend.take("all");
659 return json(mine ? await (planner as Planner).plan(situation) : localPlan(situation));
660 }
661
662 // The games, one table rather than one branch each. HEAD is GET without a
663 // body, so a health check on `/` sees the app up.
664 if (isRead(request)) {
665 const render = READ.get(url.pathname);
666 if (render !== undefined) return render(request);
667 }
668
669 // Files under ./static are served from the bytes read at start-up, cached
670 // for a year: they are named by content (app.3f2a9c1b04.js), so a change is
671 // a new name, never a stale cache. A name that is not one of those files —
672 // another game's old bundle, a guess, a path with a `..` in it — is a 404
673 // like any other, because the lookup is a file name and not a path.
674 if (isRead(request) && url.pathname.startsWith("/static/")) {
675 const file = STATIC.get(url.pathname.slice("/static/".length));
676 if (file !== undefined)
677 return { body: file.body, status: 200, headers: { "content-type": file.type, ...IMMUTABLE } };
678 }
679
680 // Nothing answers here, and the page saying so is weighed once like the
681 // file it stands in for: a miss stays as cheap as it ever was.
682 return MISS;
683 }
684
538685 if (listen.unusable !== undefined)
539686 console.log(`HOST=${listen.unusable} is not an address to bind; listening on ${listen.hostname} instead`);
540687
541688 console.log(
542689 `cs listening on http://${server.hostname}:${server.port} (${env}) serving ${[...scripts.values()].join(", ")}` +
690 ` — ${staticSummary()}` +
543691 `${identityKey ? "" : " — no FORGE_APP_IDENTITY_KEY: every visitor plays as a guest"}` +
544692 `${planner ? "" : " — no TYPESAFE_API_KEY: the bots plan their rounds locally"}`,
545693 );
static/app.07fd7f4c9f.js deleted −58

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

View file
static/app.ae4ebe5234.js added +58

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

View file
static/app.d0901a691f.js deleted −58

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs