diff --git a/serve.ts b/serve.ts index 1c1a607..c2b3b07 100644 --- a/serve.ts +++ b/serve.ts @@ -14,6 +14,60 @@ const PORT = Number(process.env.PORT ?? 3000); const ROOT = process.env.PUBLIC_DIR ?? "public"; const ROOT_DIR = resolve(ROOT); +/** + * Types worth compressing, and the only ones that are. + * + * Everything this app serves is text — JS, CSS, JSON, HTML, SVG — and the two + * biggest files are also the two most compressible: the bundle is 344 KB raw and + * 100 KB gzipped, the feed 90 KB and 10 KB. Serving them raw is roughly three + * times the bytes and, on anything slower than a laptop on wifi, three times the + * download. + * + * GitHub Pages compresses for us, so the deployed site never had this problem; + * the Docker image serves through this file and did. An image whose payload is + * 3x the site's is not a placeholder detail — it is the only thing a self-hoster + * ever sees. + */ +const COMPRESSIBLE = new Set([ + ".html", + ".js", + ".css", + ".json", + ".svg", + ".webmanifest", +]); + +/** + * Compressed bytes, keyed by path and invalidated by the file's mtime. + * + * These files change only on deploy, so gzipping the bundle on every request is + * pure waste — and at 344 KB it is not cheap waste. Keyed on mtime rather than + * held forever so `bun run dev` still serves what was just rebuilt. + */ +const gzipped = new Map }>(); + +async function gzipFor( + path: string, + file: Bun.BunFile, +): Promise | null> { + try { + const { mtimeMs } = await file.stat(); + const hit = gzipped.get(path); + if (hit !== undefined && hit.mtimeMs === mtimeMs) return hit.body; + // `gzipSync` is typed over `ArrayBufferLike` to allow a SharedArrayBuffer it + // never returns here, and `Response` only takes the plain-buffer view. + const body = Bun.gzipSync( + new Uint8Array(await file.arrayBuffer()), + ) as Uint8Array; + gzipped.set(path, { mtimeMs, body }); + return body; + } catch { + // Compression is an optimisation, never a reason to fail a request: fall + // back to sending the file as it is. + return null; + } +} + /** Long-lived for fingerprint-free assets is wrong; keep it short and revalidate. */ const CACHE: Record = { ".html": "public, max-age=0, must-revalidate", @@ -67,16 +121,39 @@ const server = Bun.serve({ const file = Bun.file(resolved); if (await file.exists()) { - return new Response(file, { - headers: { - "cache-control": CACHE[extname(path)] ?? "public, max-age=600", - // The service worker must never be served stale, or a deploy can be - // pinned by an old worker indefinitely. - ...(path === "/sw.js" - ? { "cache-control": "no-cache", "service-worker-allowed": "/" } - : {}), - }, - }); + const ext = extname(path); + const headers: Record = { + "cache-control": CACHE[ext] ?? "public, max-age=600", + // The service worker must never be served stale, or a deploy can be + // pinned by an old worker indefinitely. + ...(path === "/sw.js" + ? { "cache-control": "no-cache", "service-worker-allowed": "/" } + : {}), + }; + + // `Vary` whether or not this response is compressed: the answer depends on + // the request header either way, and a cache that does not know that will + // hand gzipped bytes to a client that never asked for them. + if (COMPRESSIBLE.has(ext)) headers["vary"] = "accept-encoding"; + + const wantsGzip = + COMPRESSIBLE.has(ext) && + (request.headers.get("accept-encoding") ?? "").includes("gzip"); + + if (wantsGzip) { + const body = await gzipFor(resolved, file); + if (body !== null) { + return new Response(body, { + headers: { + ...headers, + "content-type": file.type, + "content-encoding": "gzip", + }, + }); + } + } + + return new Response(file, { headers }); } // Single-page app: unknown paths fall back to the shell so client routing diff --git a/test/serve.test.ts b/test/serve.test.ts index 4df53f0..ef8a915 100644 --- a/test/serve.test.ts +++ b/test/serve.test.ts @@ -23,6 +23,11 @@ beforeAll(async () => { "shell", ); await writeFile(join(root, "sw.js"), "// worker"); + // Long and repetitive, so gzip is unambiguously smaller than the original — + // a short string compresses to *more* bytes than it started with. + await writeFile(join(root, "main.js"), `console.log("hello");\n`.repeat(400)); + // Already-compressed bytes, which must be served untouched. + await writeFile(join(root, "shot.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 1, 2, 3])); await writeFile( join(root, "data", "events.v1.json"), JSON.stringify({ schemaVersion: 1, generatedAt: "", events: [], sources: [] }), @@ -110,3 +115,66 @@ describe("static server", () => { expect(res.headers.get("cache-control")).toBe("no-cache"); }); }); + +/** + * Compression, which is the whole difference between the Docker image and the + * deployed site. + * + * GitHub Pages gzips on our behalf, so the bundle crosses the wire at a third of + * its size there and did not here — and this file is what the image runs. Three + * times the bytes is the only thing a self-hoster would ever have seen. + */ +describe("static server: compression", () => { + test("gzips a text asset for a client that asks", async () => { + const res = await fetch(`${base}/main.js`, { + headers: { "accept-encoding": "gzip" }, + }); + expect(res.headers.get("content-encoding")).toBe("gzip"); + // Decoded by `fetch` on the way in, so this is the original text back — + // which is the property that matters: compression must be lossless. + expect(await res.text()).toBe(`console.log("hello");\n`.repeat(400)); + }); + + test("and is actually smaller on the wire", async () => { + // Announcing gzip while sending the same number of bytes would be a pure + // regression, so compare the two content-lengths rather than trusting the + // header. + const gz = await fetch(`${base}/main.js`, { + headers: { "accept-encoding": "gzip" }, + }); + const raw = await fetch(`${base}/main.js`, { + headers: { "accept-encoding": "identity" }, + }); + const len = (r: Response) => Number(r.headers.get("content-length")); + expect(len(gz)).toBeGreaterThan(0); + expect(len(gz)).toBeLessThan(len(raw) / 2); + }); + + test("sends it raw to a client that does not ask", async () => { + const res = await fetch(`${base}/main.js`, { + headers: { "accept-encoding": "identity" }, + }); + expect(res.headers.get("content-encoding")).toBeNull(); + }); + + test("varies on accept-encoding either way", async () => { + // A shared cache that does not know the response depends on the request + // header will hand gzipped bytes to a client that never asked, so the header + // has to be there on the uncompressed answer too. + for (const encoding of ["gzip", "identity"]) { + const res = await fetch(`${base}/main.js`, { + headers: { "accept-encoding": encoding }, + }); + expect(res.headers.get("vary")).toBe("accept-encoding"); + } + }); + + test("leaves already-compressed bytes alone", async () => { + // Gzipping a PNG spends CPU to make the file bigger. + const res = await fetch(`${base}/shot.png`, { + headers: { "accept-encoding": "gzip" }, + }); + expect(res.headers.get("content-encoding")).toBeNull(); + expect(res.headers.get("vary")).toBeNull(); + }); +});