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