diff --git a/scripts/precompress.mjs b/scripts/precompress.mjs new file mode 100644 index 0000000..fe3fff2 --- /dev/null +++ b/scripts/precompress.mjs @@ -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 "); + 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`); diff --git a/server/src/account.test.ts b/server/src/account.test.ts index 182e877..28a1b51 100644 --- a/server/src/account.test.ts +++ b/server/src/account.test.ts @@ -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); diff --git a/server/src/app.ts b/server/src/app.ts index 5b8c662..c8f6e89 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -803,19 +803,26 @@ export function createApp(basePath = config.basePath): Hono { 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 { } // 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); } diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 168bb9d..bff19bb 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -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); } /* diff --git a/server/src/static-precompressed.test.ts b/server/src/static-precompressed.test.ts new file mode 100644 index 0000000..303b9d3 --- /dev/null +++ b/server/src/static-precompressed.test.ts @@ -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"), "t"); + +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 = {}) => 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); + } +}); diff --git a/server/src/static.ts b/server/src/static.ts index 7499678..d086e62 100644 --- a/server/src/static.ts +++ b/server/src/static.ts @@ -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 }>(); + + async function variantsOf(filePath: string, mtime: number): Promise> { + const known = variants.get(filePath); + if (known && known.mtime === mtime) return known.found; + const found = new Map(); + 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. diff --git a/server/src/upstream.ts b/server/src/upstream.ts index c8d8787..f0e7270 100644 --- a/server/src/upstream.ts +++ b/server/src/upstream.ts @@ -233,6 +233,23 @@ export interface AccountInfo { const infoCache = new Map(); 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: [] }; /** diff --git a/web/package.json b/web/package.json index 93e83d1..7e6ba60 100644 --- a/web/package.json +++ b/web/package.json @@ -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"