perf: compress text responses from the static server
GitHub Pages gzips for us, so the deployed site never had this problem. The Docker image serves through serve.ts, which did not — so a self-hoster shipped the bundle at 344 KB where the site sends 100 KB, and the feed at 90 KB where the site sends 10 KB. Roughly three times the bytes, and on anything slower than a laptop on wifi three times the download. Negotiated on `accept-encoding` and applied only to the text types this app serves; a PNG is left alone rather than spending CPU to grow it. `Vary` goes out on both answers, because a shared cache that does not know the response depends on the request header will hand gzipped bytes to a client that never asked. The compressed bytes are cached in memory and keyed by mtime — these files change only on deploy, so re-gzipping 344 KB per request is waste, and keying on mtime rather than holding forever keeps `bun run dev` serving what was just rebuilt. A failure to compress falls through to the raw file: this is an optimisation and never a reason to fail a request. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a7cf59d8e4
commit
3e30ab07aa
@@ -14,6 +14,60 @@ const PORT = Number(process.env.PORT ?? 3000);
|
|||||||
const ROOT = process.env.PUBLIC_DIR ?? "public";
|
const ROOT = process.env.PUBLIC_DIR ?? "public";
|
||||||
const ROOT_DIR = resolve(ROOT);
|
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<string, { mtimeMs: number; body: Uint8Array<ArrayBuffer> }>();
|
||||||
|
|
||||||
|
async function gzipFor(
|
||||||
|
path: string,
|
||||||
|
file: Bun.BunFile,
|
||||||
|
): Promise<Uint8Array<ArrayBuffer> | 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<ArrayBuffer>;
|
||||||
|
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. */
|
/** Long-lived for fingerprint-free assets is wrong; keep it short and revalidate. */
|
||||||
const CACHE: Record<string, string> = {
|
const CACHE: Record<string, string> = {
|
||||||
".html": "public, max-age=0, must-revalidate",
|
".html": "public, max-age=0, must-revalidate",
|
||||||
@@ -67,16 +121,39 @@ const server = Bun.serve({
|
|||||||
const file = Bun.file(resolved);
|
const file = Bun.file(resolved);
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
return new Response(file, {
|
const ext = extname(path);
|
||||||
headers: {
|
const headers: Record<string, string> = {
|
||||||
"cache-control": CACHE[extname(path)] ?? "public, max-age=600",
|
"cache-control": CACHE[ext] ?? "public, max-age=600",
|
||||||
// The service worker must never be served stale, or a deploy can be
|
// The service worker must never be served stale, or a deploy can be
|
||||||
// pinned by an old worker indefinitely.
|
// pinned by an old worker indefinitely.
|
||||||
...(path === "/sw.js"
|
...(path === "/sw.js"
|
||||||
? { "cache-control": "no-cache", "service-worker-allowed": "/" }
|
? { "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
|
// Single-page app: unknown paths fall back to the shell so client routing
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ beforeAll(async () => {
|
|||||||
"<!doctype html><html><body>shell</body></html>",
|
"<!doctype html><html><body>shell</body></html>",
|
||||||
);
|
);
|
||||||
await writeFile(join(root, "sw.js"), "// worker");
|
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(
|
await writeFile(
|
||||||
join(root, "data", "events.v1.json"),
|
join(root, "data", "events.v1.json"),
|
||||||
JSON.stringify({ schemaVersion: 1, generatedAt: "", events: [], sources: [] }),
|
JSON.stringify({ schemaVersion: 1, generatedAt: "", events: [], sources: [] }),
|
||||||
@@ -110,3 +115,66 @@ describe("static server", () => {
|
|||||||
expect(res.headers.get("cache-control")).toBe("no-cache");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user