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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/229-a-first-visit-is-116-kb-where-50-kb-woul → main 2 commits fork
10 files
changed +626
−204
README.md +26
| @@ -865,6 +865,32 @@ never checked itself. | ||
| 865 | 865 | `HOST` is also what a shell sets to the machine's name, so only an address — |
| 866 | 866 | an IPv4 or IPv6 literal, or `localhost` — is bound and anything else is |
| 867 | 867 | 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. | |
| 868 | 894 | - `src/policy.ts` — **the content security policy**, and the one part of it that |
| 869 | 895 | is not a constant. The floor is `default-src 'self'`, no inline script, |
| 870 | 896 | `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"; | ||
| 18 | 18 | import { readdirSync, readFileSync } from "node:fs"; |
| 19 | 19 | import { join } from "node:path"; |
| 20 | 20 | import { bundleOf, GZIP_LEVEL, STATIC_DIR } from "../../scripts/build.ts"; |
| 21 | import { GZIP_LEVEL as WIRE_LEVEL } from "../compress.ts"; | |
| 21 | 22 | import { bytes, kB } from "../bytes.ts"; |
| 22 | 23 | import { GAME_SOURCE, lineCount, measureExample, WIRING_SOURCES } from "../cost.ts"; |
| 23 | 24 | import { costHtml, examplePage } from "./example.ts"; |
| @@ -106,7 +107,7 @@ test("no number the page states about itself is one somebody typed", () => { | ||
| 106 | 107 | const allowed = new Set([ |
| 107 | 108 | bytes(cost.script.raw), bytes(cost.shooter.raw), |
| 108 | 109 | 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", | |
| 110 | 111 | "2", // "2D on one canvas", and the two files of wiring |
| 111 | 112 | "404", // the page nobody had to edit |
| 112 | 113 | ]); |
src/pages/example.ts +4 −1
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { bytes, kB } from "../bytes.ts"; |
| 14 | import { GZIP_LEVEL as WIRE_LEVEL } from "../compress.ts"; | |
| 14 | 15 | import { type Cost, GZIP_LEVEL } from "../cost.ts"; |
| 15 | 16 | import { navFor, othersHtml } from "./games.ts"; |
| 16 | 17 | import { page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| @@ -70,7 +71,9 @@ export function costHtml(cost: Cost): string { | ||
| 70 | 71 | <p>Every number in those two paragraphs is measured, not typed. The byte counts are this |
| 71 | 72 | deployment's own committed bundles under <code>static/</code>, read when this server started |
| 72 | 73 | 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 | |
| 74 | 77 | same way. A bundle that grows moves this sentence on the next deploy, and |
| 75 | 78 | <code>src/pages/example.test.ts</code> fails if it does not.</p>`; |
| 76 | 79 | } |
src/server.test.ts +139 −1
| @@ -21,7 +21,8 @@ process.env.PORT = "0"; | ||
| 21 | 21 | // The test is the forge: it holds the same key and signs identities with it. |
| 22 | 22 | const KEY = "the-key-the-forge-signs-with"; |
| 23 | 23 | 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"); | |
| 25 | 26 | const base = `http://${server.hostname}:${server.port}`; |
| 26 | 27 | |
| 27 | 28 | /** 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 () => { | ||
| 813 | 814 | expect(response.headers.get("cache-control")).toContain("immutable"); |
| 814 | 815 | }); |
| 815 | 816 | |
| 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 | ||
| 816 | 954 | // The parser itself is pinned rule by rule in src/result.test.ts, which is a |
| 817 | 955 | // unit test and runs in milliseconds. What is left here is the end-to-end: a |
| 818 | 956 | // POST that reaches it, and the status a refusal comes back as. |
src/server.ts +233 −85
| @@ -23,6 +23,13 @@ | ||
| 23 | 23 | * each throw before this listens, so none of them is ever a 404 a visitor meets |
| 24 | 24 | * instead. |
| 25 | 25 | * |
| 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 | * | |
| 26 | 33 | * A result is the browser's word for the numbers and nobody's word for the |
| 27 | 34 | * name, so the name is not taken from it: `src/who.ts` verifies the forge's |
| 28 | 35 | * signed identity headers and a result is recorded under that, or as a guest's. |
| @@ -30,10 +37,12 @@ | ||
| 30 | 37 | * what socket this listens on. |
| 31 | 38 | */ |
| 32 | 39 | |
| 33 | import { existsSync, readdirSync } from "node:fs"; | |
| 40 | import { existsSync, readdirSync, readFileSync } from "node:fs"; | |
| 34 | 41 | import { join } from "node:path"; |
| 35 | 42 | import { assetOf, bundleOf, STATIC_DIR } from "../scripts/build.ts"; |
| 36 | 43 | import { bind } from "./bind.ts"; |
| 44 | import { kB } from "./bytes.ts"; | |
| 45 | import { acceptsGzip, type Body, weigh, weighText } from "./compress.ts"; | |
| 37 | 46 | import { measureExample } from "./cost.ts"; |
| 38 | 47 | import { openDatabase, type Player, saveMatch } from "./db.ts"; |
| 39 | 48 | import { GAMES } from "./games.ts"; |
| @@ -112,13 +121,30 @@ const SECURITY = floorWith(policyFor()); | ||
| 112 | 121 | */ |
| 113 | 122 | const documentFloor = (extraStyle?: string): Headers => floorWith(policyFor(styleFor(extraStyle))); |
| 114 | 123 | |
| 124 | /** What every document this server writes is: one string, in one content type. */ | |
| 125 | const HTML_TYPE = "text/html; charset=utf-8"; | |
| 126 | ||
| 115 | 127 | const html = (body: string, security: Headers = SECURITY, status = 200, extra: Headers = {}): Response => |
| 116 | 128 | new Response(body, { |
| 117 | 129 | status, |
| 118 | headers: { "content-type": "text/html; charset=utf-8", ...security, ...extra }, | |
| 130 | headers: { "content-type": HTML_TYPE, ...security, ...extra }, | |
| 119 | 131 | }); |
| 120 | 132 | |
| 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 | */ | |
| 122 | 148 | const json = (value: unknown): Response => |
| 123 | 149 | new Response(JSON.stringify(value), { |
| 124 | 150 | 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[ | ||
| 295 | 321 | } |
| 296 | 322 | const assets = findAssets(); |
| 297 | 323 | |
| 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 | ||
| 298 | 378 | /** |
| 299 | 379 | * What `/example` says a third game cost, weighed and counted once, here, |
| 300 | 380 | * against the files this process serves: the committed bundles in static/ and |
| @@ -434,15 +514,77 @@ const MISS_SECURITY = documentFloor(MISS_STYLE); | ||
| 434 | 514 | * is not handed to the page, so there is nothing on it to leak. Exported |
| 435 | 515 | * because it is the whole of what a 500 is here, and a test can call it without |
| 436 | 516 | * 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. | |
| 437 | 522 | */ |
| 438 | 523 | export function onError(error: unknown): Response { |
| 439 | 524 | console.error(error); |
| 440 | 525 | return html(SERVER_ERROR, MISS_SECURITY, 500); |
| 441 | 526 | } |
| 442 | 527 | |
| 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 | } | |
| 446 | 588 | |
| 447 | 589 | const isRead = (request: Request): boolean => request.method === "GET" || request.method === "HEAD"; |
| 448 | 590 | |
| @@ -452,94 +594,100 @@ export const server = Bun.serve({ | ||
| 452 | 594 | port: listen.port, |
| 453 | 595 | hostname: listen.hostname, |
| 454 | 596 | 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)); | |
| 534 | 598 | }, |
| 535 | 599 | error: onError, |
| 536 | 600 | }); |
| 537 | 601 | |
| 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 | ||
| 538 | 685 | if (listen.unusable !== undefined) |
| 539 | 686 | console.log(`HOST=${listen.unusable} is not an address to bind; listening on ${listen.hostname} instead`); |
| 540 | 687 | |
| 541 | 688 | console.log( |
| 542 | 689 | `cs listening on http://${server.hostname}:${server.port} (${env}) serving ${[...scripts.values()].join(", ")}` + |
| 690 | ` — ${staticSummary()}` + | |
| 543 | 691 | `${identityKey ? "" : " — no FORGE_APP_IDENTITY_KEY: every visitor plays as a guest"}` + |
| 544 | 692 | `${planner ? "" : " — no TYPESAFE_API_KEY: the bots plan their rounds locally"}`, |
| 545 | 693 | ); |
static/app.07fd7f4c9f.js deleted −58
Diff not shown — a line 73,606 characters wide (limit 2,000). −58
View filestatic/app.ae4ebe5234.js added +58
Diff not shown — a line 75,645 characters wide (limit 2,000). +58
View filestatic/app.d0901a691f.js deleted −58
Diff not shown — a line 71,841 characters wide (limit 2,000). −58
View file
new above
offline