Precompress the bundle, validate the shell, and pass byte ranges on
The web build now writes a Brotli and a gzip copy of each compressible file, and the static handler serves the best one the browser accepts. The bundle was gzipped again for every request and Brotli was never offered; the main chunk is 122 KB with Brotli against 144 KB gzipped. index.html and every static file carry an ETag, and a matching If-None-Match gets a 304. The shell and the worker are revalidated on every load and were downloaded whole each time. Attachment downloads pass a plain byte Range to Stalwart and relay a 206, so a PDF viewer or a video element can read in pieces where the server allows it. On the reader's own device a blob is cached as immutable, since its id names its content. The upstream session and account-info caches drop entries past their age on a timer; they lost an entry only on sign-out or refusal, not when a session expired. The mock answers byte ranges.
This commit is contained in:
@@ -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`);
|
||||
@@ -116,6 +116,34 @@ test("attachments are kept out of the disk cache of a device that is not the per
|
||||
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 () => {
|
||||
const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" });
|
||||
assert.equal(res.status, 400);
|
||||
|
||||
+15
-3
@@ -803,19 +803,26 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
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, {
|
||||
// Ask for the bytes as they are. undici would otherwise negotiate gzip
|
||||
// on our behalf and hand back a decompressed body whose content-length
|
||||
// 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)),
|
||||
});
|
||||
if (res.status === 416) return c.body(null, 416);
|
||||
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
|
||||
const headers = new Headers();
|
||||
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
|
||||
headers.set("Content-Type", type);
|
||||
const cl = forwardedContentLength(res.headers);
|
||||
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);
|
||||
headers.set(
|
||||
"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
|
||||
// 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) {
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
|
||||
@@ -138,7 +138,21 @@ export const server = createServer(async (req, res) => {
|
||||
const [, , , , blobId] = url.pathname.split("/");
|
||||
const b = blobs.get(blobId ?? "");
|
||||
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);
|
||||
}
|
||||
/*
|
||||
|
||||
@@ -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
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat, readFile } from "node:fs/promises";
|
||||
import { extname, join, normalize, resolve, sep } from "node:path";
|
||||
@@ -77,9 +78,61 @@ export const APP_CSP = [
|
||||
"manifest-src 'self'",
|
||||
].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 {
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -107,13 +160,16 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
||||
const p = join(absRoot, "index.html");
|
||||
const st = await stat(p);
|
||||
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;
|
||||
}
|
||||
warnOnBaseMismatch(indexCache.body);
|
||||
c.header("Content-Type", "text/html; charset=utf-8");
|
||||
c.header("Cache-Control", "no-cache");
|
||||
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);
|
||||
} catch {
|
||||
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);
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
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/")) {
|
||||
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||
} else if (ext === ".html" || isNeverStale(rel, ext)) {
|
||||
@@ -151,8 +208,23 @@ export function staticHandler(root: string, basePath = ""): Handler {
|
||||
} else {
|
||||
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);
|
||||
const stream = Readable.toWeb(createReadStream(filePath)) as ReadableStream;
|
||||
const stream = Readable.toWeb(createReadStream(servePath)) as ReadableStream;
|
||||
return c.body(stream);
|
||||
} catch {
|
||||
// SPA fallback for client-side routes (no file extension) only.
|
||||
|
||||
@@ -233,6 +233,23 @@ export interface AccountInfo {
|
||||
|
||||
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||
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: [] };
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
|
||||
Reference in New Issue
Block a user