Merge pull request #386 from Coffey-Labs/perf/server-static-and-caches

Precompress the bundle, validate the shell, and pass byte ranges on
This commit is contained in:
jcoffey
2026-09-16 11:02:37 -07:00
committed by GitHub
8 changed files with 278 additions and 9 deletions
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
/*
* Write a Brotli and a gzip copy beside every compressible file in a web build.
*
* The server used to gzip the bundle again on every request that asked for it,
* at a level chosen for speed. These are made once, at the level chosen for
* size, and `server/src/static.ts` hands one out when the browser accepts it.
* Brotli at 11 is about 15% smaller than gzip for this bundle, and too slow to
* do per request, which is why it was never offered.
*
* node scripts/precompress.mjs web/dist
*/
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, extname } from "node:path";
import { brotliCompressSync, constants, gzipSync } from "node:zlib";
const COMPRESSIBLE = new Set([".js", ".mjs", ".css", ".html", ".svg", ".json", ".webmanifest", ".txt", ".wasm"]);
// Below this, the encoding costs more than it saves.
const MIN_BYTES = 1024;
function* files(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) yield* files(p);
else yield p;
}
}
const root = process.argv[2];
if (!root) {
console.error("usage: precompress.mjs <dir>");
process.exit(2);
}
let count = 0;
let before = 0;
let after = 0;
for (const p of files(root)) {
if (!COMPRESSIBLE.has(extname(p)) || statSync(p).size < MIN_BYTES) continue;
const data = readFileSync(p);
const br = brotliCompressSync(data, { params: { [constants.BROTLI_PARAM_QUALITY]: 11, [constants.BROTLI_PARAM_SIZE_HINT]: data.length } });
writeFileSync(`${p}.br`, br);
writeFileSync(`${p}.gz`, gzipSync(data, { level: 9 }));
count++;
before += data.length;
after += br.length;
}
console.log(`precompressed ${count} files: ${(before / 1024).toFixed(0)} KB -> ${(after / 1024).toFixed(0)} KB brotli`);
+28
View File
@@ -116,6 +116,34 @@ test("attachments are kept out of the disk cache of a device that is not the per
await res.arrayBuffer(); await res.arrayBuffer();
}); });
test("a download passes a byte range through, for viewers that read in pieces", async () => {
const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello world" });
const { blobId } = (await up.json()) as { blobId: string };
const url = `/api/blob/a1/${blobId}/greeting.txt?accept=text/plain`;
const part = await app.request(url, { headers: { cookie, range: "bytes=0-4" } });
assert.equal(part.status, 206);
assert.equal(part.headers.get("content-range"), "bytes 0-4/11");
assert.equal(part.headers.get("accept-ranges"), "bytes");
assert.equal(await part.text(), "hello");
const whole = await app.request(url, { headers: { cookie } });
assert.equal(whole.status, 200);
assert.equal(await whole.text(), "hello world");
const beyond = await app.request(url, { headers: { cookie, range: "bytes=50-60" } });
assert.equal(beyond.status, 416);
// Anything that is not a plain byte range is not passed on.
const odd = await app.request(url, { headers: { cookie, range: "items=0-4" } });
assert.equal(odd.status, 200);
await odd.arrayBuffer();
});
test("upstream caches let go of sessions that have aged out", async () => {
const { sweepUpstreamCaches, upstreamCacheSizes } = await import("./upstream.js");
// Signed in above, so this session has an entry.
assert.ok(upstreamCacheSizes().sessions >= 1);
sweepUpstreamCaches(Date.now() + 60 * 60_000);
assert.deepEqual(upstreamCacheSizes(), { sessions: 0, info: 0 });
});
test("an app password needs a name", async () => { test("an app password needs a name", async () => {
const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" }); const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" });
assert.equal(res.status, 400); assert.equal(res.status, 400);
+15 -3
View File
@@ -803,19 +803,26 @@ export function createApp(basePath = config.basePath): Hono<Env> {
try { try {
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username)); const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl); const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }), upstream.baseUrl);
// A PDF viewer or a video element asks for pieces; pass that on. A server
// that ignores it answers with the whole file, as it did before.
const range = c.req.header("range");
const res = await fetch(url, { const res = await fetch(url, {
// Ask for the bytes as they are. undici would otherwise negotiate gzip // Ask for the bytes as they are. undici would otherwise negotiate gzip
// on our behalf and hand back a decompressed body whose content-length // on our behalf and hand back a decompressed body whose content-length
// header still describes the compressed one -- see forwardedContentLength. // header still describes the compressed one -- see forwardedContentLength.
headers: { authorization: session.authorization, "accept-encoding": "identity" }, headers: { authorization: session.authorization, "accept-encoding": "identity", ...(range && /^bytes=[\d,\s-]+$/.test(range) ? { range } : {}) },
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)), signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
}); });
if (res.status === 416) return c.body(null, 416);
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502); if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
const headers = new Headers(); const headers = new Headers();
const type = sanitizeContentType(res.headers.get("content-type") ?? accept); const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
headers.set("Content-Type", type); headers.set("Content-Type", type);
const cl = forwardedContentLength(res.headers); const cl = forwardedContentLength(res.headers);
if (cl) headers.set("Content-Length", cl); if (cl) headers.set("Content-Length", cl);
const partial = res.status === 206 && res.headers.get("content-range");
if (partial) headers.set("Content-Range", partial);
if (res.headers.get("accept-ranges") === "bytes") headers.set("Accept-Ranges", "bytes");
const safeInline = inline && isInlineSafe(type); const safeInline = inline && isInlineSafe(type);
headers.set( headers.set(
"Content-Disposition", "Content-Disposition",
@@ -840,8 +847,13 @@ export function createApp(basePath = config.basePath): Hono<Env> {
} }
// Kept out of the browser's disk cache on a device that is not the // Kept out of the browser's disk cache on a device that is not the
// person's own: signing out wipes what the app stores, not that. // person's own: signing out wipes what the app stores, not that.
headers.set("Cache-Control", session.remember ? "private, max-age=3600" : "no-store"); /*
return new Response(res.body, { status: 200, headers }); * A blob id names its content -- the same id is the same bytes for good
* -- so on the reader's own device there is nothing to revalidate. On
* anyone else's, nothing is left in the disk cache at all.
*/
headers.set("Cache-Control", session.remember ? "private, max-age=31536000, immutable" : "no-store");
return new Response(res.body, { status: partial ? 206 : 200, headers });
} catch (err) { } catch (err) {
return upstreamFailure(c, err); return upstreamFailure(c, err);
} }
+15 -1
View File
@@ -138,7 +138,21 @@ export const server = createServer(async (req, res) => {
const [, , , , blobId] = url.pathname.split("/"); const [, , , , blobId] = url.pathname.split("/");
const b = blobs.get(blobId ?? ""); const b = blobs.get(blobId ?? "");
if (!b) { res.writeHead(404); return res.end(); } if (!b) { res.writeHead(404); return res.end(); }
res.writeHead(200, { "content-type": url.searchParams.get("accept") ?? b.type, "content-length": b.data.length }); const type = url.searchParams.get("accept") ?? b.type;
// One byte range, the way a PDF viewer or a video element asks for one.
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
if (m && (m[1] || m[2])) {
const size = b.data.length;
const start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2]));
const end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
if (start >= size || start > end) {
res.writeHead(416, { "content-range": `bytes */${size}` });
return res.end();
}
res.writeHead(206, { "content-type": type, "content-length": end - start + 1, "content-range": `bytes ${start}-${end}/${size}`, "accept-ranges": "bytes" });
return res.end(b.data.subarray(start, end + 1));
}
res.writeHead(200, { "content-type": type, "content-length": b.data.length, "accept-ranges": "bytes" });
return res.end(b.data); return res.end(b.data);
} }
/* /*
+79
View File
@@ -0,0 +1,79 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, mkdirSync, utimesSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { brotliCompressSync, brotliDecompressSync, gunzipSync, gzipSync } from "node:zlib";
/*
* The bundle goes out compressed once, at build time, and anything revalidated
* can be answered with a 304.
*
* Before this the server gzipped the bundle again for every request that asked,
* never offered Brotli, and sent no validator for the shell -- so each reload's
* revalidation of index.html and sw.js downloaded them in full.
*/
const root = mkdtempSync(join(tmpdir(), "ihasmail-precompressed-"));
mkdirSync(join(root, "assets"));
const js = `console.log(${JSON.stringify("x".repeat(4000))});\n`;
writeFileSync(join(root, "assets", "app-a1b2c3.js"), js);
writeFileSync(join(root, "assets", "app-a1b2c3.js.br"), brotliCompressSync(js));
writeFileSync(join(root, "assets", "app-a1b2c3.js.gz"), gzipSync(js));
writeFileSync(join(root, "assets", "plain-d4e5f6.js"), js);
// A copy left over from an older build of the same name must not be served.
writeFileSync(join(root, "assets", "stale-000000.js"), js);
writeFileSync(join(root, "assets", "stale-000000.js.br"), brotliCompressSync("old"));
const old = new Date(Date.now() - 60_000);
utimesSync(join(root, "assets", "stale-000000.js.br"), old, old);
writeFileSync(join(root, "sw.js"), "/* worker */\n");
writeFileSync(join(root, "index.html"), "<!doctype html><title>t</title>");
process.env.STATIC_DIR = root;
process.env.STALWART_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js");
const app = createApp();
const get = (path: string, headers: Record<string, string> = {}) => app.request(path, { headers });
test("Brotli is served where the browser takes it", async () => {
const res = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, deflate, br" });
assert.equal(res.status, 200);
assert.equal(res.headers.get("content-encoding"), "br");
assert.equal(res.headers.get("vary"), "Accept-Encoding");
assert.equal(res.headers.get("content-type"), "text/javascript; charset=utf-8");
assert.equal(brotliDecompressSync(Buffer.from(await res.arrayBuffer())).toString(), js);
});
test("gzip where Brotli is not accepted, and nothing where neither is", async () => {
const gz = await get("/assets/app-a1b2c3.js", { "accept-encoding": "gzip, br;q=0" });
assert.equal(gz.headers.get("content-encoding"), "gzip");
assert.equal(gunzipSync(Buffer.from(await gz.arrayBuffer())).toString(), js);
const plain = await get("/assets/app-a1b2c3.js");
assert.equal(plain.headers.get("content-encoding"), null);
assert.equal(await plain.text(), js);
});
test("a file without a copy is compressed as before", async () => {
const res = await get("/assets/plain-d4e5f6.js", { "accept-encoding": "gzip" });
assert.equal(res.headers.get("content-encoding"), "gzip");
assert.equal(gunzipSync(Buffer.from(await res.arrayBuffer())).toString(), js);
});
test("a copy older than its file is ignored", async () => {
const res = await get("/assets/stale-000000.js", { "accept-encoding": "br" });
assert.notEqual(res.headers.get("content-encoding"), "br");
});
test("the shell and the worker answer a revalidation with 304", async () => {
for (const path of ["/", "/sw.js"]) {
const first = await get(path);
const etag = first.headers.get("etag");
assert.ok(etag, `${path} carries a validator`);
await first.arrayBuffer();
const again = await get(path, { "if-none-match": etag! });
assert.equal(again.status, 304, `${path} is not sent again`);
assert.equal(await again.text(), "");
const changed = await get(path, { "if-none-match": `"something-else"` });
assert.equal(changed.status, 200);
}
});
+76 -4
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { stat, readFile } from "node:fs/promises"; import { stat, readFile } from "node:fs/promises";
import { extname, join, normalize, resolve, sep } from "node:path"; import { extname, join, normalize, resolve, sep } from "node:path";
@@ -77,9 +78,61 @@ export const APP_CSP = [
"manifest-src 'self'", "manifest-src 'self'",
].join("; "); ].join("; ");
/*
* What a file is, for the purpose of "has it changed". The shell and the
* never-stale files are revalidated on every load; with no validator to send
* back, every revalidation downloaded the whole file again.
*/
function etagOf(size: number, mtimeMs: number): string {
return `W/"${size.toString(36)}-${Math.floor(mtimeMs).toString(36)}"`;
}
function notModified(c: Context, etag: string): boolean {
const sent = c.req.header("if-none-match");
return Boolean(sent && sent.split(",").some((t) => t.trim() === etag || t.trim() === "*"));
}
/*
* The encodings a build can carry beside a file, best first. See
* scripts/precompress.mjs, which writes them.
*/
const PRECOMPRESSED: Array<{ token: string; suffix: string; encoding: string }> = [
{ token: "br", suffix: ".br", encoding: "br" },
{ token: "gzip", suffix: ".gz", encoding: "gzip" },
];
function accepts(c: Context, token: string): boolean {
const header = c.req.header("accept-encoding") ?? "";
return header.split(",").some((part) => {
const [name, ...params] = part.trim().split(";");
if (name?.trim().toLowerCase() !== token) return false;
const q = params.map((p) => p.trim()).find((p) => p.startsWith("q="));
return !q || Number(q.slice(2)) > 0;
});
}
export function staticHandler(root: string, basePath = ""): Handler { export function staticHandler(root: string, basePath = ""): Handler {
const absRoot = resolve(root); const absRoot = resolve(root);
let indexCache: { body: string; mtime: number } | null = null; let indexCache: { body: string; mtime: number; etag: string } | null = null;
/** Which precompressed copies exist, per file and modification time. */
const variants = new Map<string, { mtime: number; found: Map<string, number> }>();
async function variantsOf(filePath: string, mtime: number): Promise<Map<string, number>> {
const known = variants.get(filePath);
if (known && known.mtime === mtime) return known.found;
const found = new Map<string, number>();
for (const v of PRECOMPRESSED) {
try {
const st = await stat(filePath + v.suffix);
// A copy older than the file it came from describes something else.
if (st.isFile() && st.mtimeMs >= mtime) found.set(v.suffix, st.size);
} catch {
/* none */
}
}
variants.set(filePath, { mtime, found });
return found;
}
let mismatchWarned = false; let mismatchWarned = false;
/** /**
@@ -107,13 +160,16 @@ export function staticHandler(root: string, basePath = ""): Handler {
const p = join(absRoot, "index.html"); const p = join(absRoot, "index.html");
const st = await stat(p); const st = await stat(p);
if (!indexCache || indexCache.mtime !== st.mtimeMs) { if (!indexCache || indexCache.mtime !== st.mtimeMs) {
indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs }; const body = await readFile(p, "utf8");
indexCache = { body, mtime: st.mtimeMs, etag: `"${createHash("sha256").update(body).digest("base64url").slice(0, 22)}"` };
mismatchWarned = false; mismatchWarned = false;
} }
warnOnBaseMismatch(indexCache.body); warnOnBaseMismatch(indexCache.body);
c.header("Content-Type", "text/html; charset=utf-8"); c.header("Content-Type", "text/html; charset=utf-8");
c.header("Cache-Control", "no-cache"); c.header("Cache-Control", "no-cache");
c.header("Content-Security-Policy", APP_CSP); c.header("Content-Security-Policy", APP_CSP);
c.header("ETag", indexCache.etag);
if (notModified(c, indexCache.etag)) return c.body(null, 304);
return c.body(indexCache.body); return c.body(indexCache.body);
} catch { } catch {
c.header("Content-Type", "text/plain; charset=utf-8"); c.header("Content-Type", "text/plain; charset=utf-8");
@@ -142,7 +198,8 @@ export function staticHandler(root: string, basePath = ""): Handler {
if (!st.isFile()) return serveIndex(c); if (!st.isFile()) return serveIndex(c);
const ext = extname(filePath).toLowerCase(); const ext = extname(filePath).toLowerCase();
c.header("Content-Type", MIME[ext] ?? "application/octet-stream"); c.header("Content-Type", MIME[ext] ?? "application/octet-stream");
c.header("Content-Length", String(st.size)); const etag = etagOf(st.size, st.mtimeMs);
c.header("ETag", etag);
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) { if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
c.header("Cache-Control", "public, max-age=31536000, immutable"); c.header("Cache-Control", "public, max-age=31536000, immutable");
} else if (ext === ".html" || isNeverStale(rel, ext)) { } else if (ext === ".html" || isNeverStale(rel, ext)) {
@@ -151,8 +208,23 @@ export function staticHandler(root: string, basePath = ""): Handler {
} else { } else {
c.header("Cache-Control", "public, max-age=3600"); c.header("Cache-Control", "public, max-age=3600");
} }
if (notModified(c, etag)) return c.body(null, 304);
// Serve a copy made at build time where the browser takes one.
let servePath = filePath;
let size = st.size;
const found = await variantsOf(filePath, st.mtimeMs);
if (found.size) {
c.header("Vary", "Accept-Encoding");
const pick = PRECOMPRESSED.find((v) => found.has(v.suffix) && accepts(c, v.token));
if (pick) {
servePath = filePath + pick.suffix;
size = found.get(pick.suffix)!;
c.header("Content-Encoding", pick.encoding);
}
}
c.header("Content-Length", String(size));
if (c.req.method === "HEAD") return c.body(null); if (c.req.method === "HEAD") return c.body(null);
const stream = Readable.toWeb(createReadStream(filePath)) as ReadableStream; const stream = Readable.toWeb(createReadStream(servePath)) as ReadableStream;
return c.body(stream); return c.body(stream);
} catch { } catch {
// SPA fallback for client-side routes (no file extension) only. // SPA fallback for client-side routes (no file extension) only.
+17
View File
@@ -233,6 +233,23 @@ export interface AccountInfo {
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>(); const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
const INFO_CACHE_MS = 30 * 60_000; const INFO_CACHE_MS = 30 * 60_000;
/*
* Both caches are keyed by session, and used to lose an entry only when that
* session signed out or was refused -- not when it simply expired, which is how
* most sessions end. An entry past its age is never used again, so dropping
* those on a timer is all it takes to stop them accumulating.
*/
export function sweepUpstreamCaches(now = Date.now()): void {
for (const [id, v] of sessionCache) if (now - v.fetchedAt >= SESSION_CACHE_MS) sessionCache.delete(id);
for (const [id, v] of infoCache) if (now - v.fetchedAt >= INFO_CACHE_MS) infoCache.delete(id);
}
setInterval(() => sweepUpstreamCaches(), SESSION_CACHE_MS).unref();
/** How many sessions the caches hold; for tests. */
export function upstreamCacheSizes(): { sessions: number; info: number } {
return { sessions: sessionCache.size, info: infoCache.size };
}
const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] }; const EMPTY_INFO: AccountInfo = { locale: null, edition: null, permissions: [] };
/** /**
+1 -1
View File
@@ -6,7 +6,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -p tsconfig.json --noEmit && vite build", "build": "tsc -p tsconfig.json --noEmit && vite build && node ../scripts/precompress.mjs dist",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run" "test": "vitest run"