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.
236 lines
9.6 KiB
TypeScript
236 lines
9.6 KiB
TypeScript
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";
|
|
import { Readable } from "node:stream";
|
|
import type { Context, Handler } from "hono";
|
|
import { stripBasePath } from "../../scripts/basePath.mjs";
|
|
|
|
/*
|
|
* Files that must not be served from anybody's cache, the way index.html is
|
|
* not.
|
|
*
|
|
* They went out with `max-age=3600` because they are neither hashed assets nor
|
|
* HTML, and an hour looks harmless. It is not, for two of them, and a CDN in
|
|
* front makes it worse: on a deploy the origin had the new build while
|
|
* Cloudflare went on handing out the previous `sw.js` for hours, with
|
|
* `cf-cache-status: HIT` and an edge TTL of its own that was longer than what
|
|
* we asked for. Caught on the 2026-09-08 deploy, where the new worker was live
|
|
* at the origin and the old one was still being installed by every browser
|
|
* that asked.
|
|
*
|
|
* What that costs is specific rather than general. The service worker is the
|
|
* app's whole update mechanism: a stale one keeps serving the shell it knows
|
|
* and never learns there is a newer build, so the deploy simply does not
|
|
* arrive. And a manifest and a worker that disagree is worse than either being
|
|
* old -- a fresh manifest advertising a share target to the operating system,
|
|
* answered by a worker that has never heard of one, sends the share to the
|
|
* server for a 405.
|
|
*
|
|
* `no-cache` does not mean "do not store": the browser and the CDN may both
|
|
* keep it and revalidate, which is a 304 and costs nothing. It means neither
|
|
* gets to serve it without asking first, which is the whole requirement.
|
|
*/
|
|
function isNeverStale(rel: string, ext: string): boolean {
|
|
return ext === ".webmanifest" || rel === "/sw.js" || rel === "sw.js";
|
|
}
|
|
|
|
const MIME: Record<string, string> = {
|
|
".html": "text/html; charset=utf-8",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".mjs": "text/javascript; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".webmanifest": "application/manifest+json; charset=utf-8",
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".gif": "image/gif",
|
|
".svg": "image/svg+xml",
|
|
".ico": "image/x-icon",
|
|
".webp": "image/webp",
|
|
".woff": "font/woff",
|
|
".woff2": "font/woff2",
|
|
".ttf": "font/ttf",
|
|
".map": "application/json",
|
|
".txt": "text/plain; charset=utf-8",
|
|
".wasm": "application/wasm",
|
|
};
|
|
|
|
/**
|
|
* Content Security Policy for the app shell. Inline styles are required because
|
|
* sanitized HTML email carries style attributes; everything else is strict.
|
|
*/
|
|
export const APP_CSP = [
|
|
"default-src 'self'",
|
|
"script-src 'self'",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' data: blob:",
|
|
"font-src 'self' data:",
|
|
"connect-src 'self'",
|
|
"media-src 'self' blob:",
|
|
"frame-src 'self'",
|
|
"object-src 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
"frame-ancestors 'none'",
|
|
"worker-src 'self'",
|
|
"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; 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;
|
|
|
|
/**
|
|
* A build that does not know the prefix loads nothing under it, and says so
|
|
* with a blank page and a 404 in a console nobody has open. The shell is
|
|
* already being read here, so checking what it asks for costs one substring
|
|
* search per rebuild and turns a mystery into a line in the log.
|
|
*
|
|
* A warning rather than a refusal: this reads a built artifact to guess at a
|
|
* misconfiguration, and a wrong guess that stops the server from starting is
|
|
* worse than the problem it is describing.
|
|
*/
|
|
function warnOnBaseMismatch(body: string) {
|
|
if (mismatchWarned || !basePath) return;
|
|
if (body.includes(`src="${basePath}/assets/`)) return;
|
|
mismatchWarned = true;
|
|
console.warn(
|
|
`[ihasmail] BASE_PATH is ${basePath}, but the web build in ${absRoot} references its assets elsewhere. ` +
|
|
`The prefix is baked in at build time: rebuild with BASE_PATH=${basePath} set, or the app will not load.`,
|
|
);
|
|
}
|
|
|
|
async function serveIndex(c: Context) {
|
|
try {
|
|
const p = join(absRoot, "index.html");
|
|
const st = await stat(p);
|
|
if (!indexCache || indexCache.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");
|
|
return c.body("ihasmail: web build not found. Run `npm run build` first.", 503);
|
|
}
|
|
}
|
|
|
|
return async (c) => {
|
|
if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.text("Method Not Allowed", 405);
|
|
/*
|
|
* Everything below works in paths relative to the mount, so the prefix
|
|
* comes off once, here. Anything outside it is a 404 and not the app
|
|
* shell: under `/mail` this process shares a hostname with whatever else
|
|
* the proxy serves, and answering `/` or `/other-app/thing` with our
|
|
* index would shadow a neighbor rather than let it 404 honestly.
|
|
*/
|
|
const fullPath = decodeURIComponent(new URL(c.req.url).pathname);
|
|
const urlPath = stripBasePath(basePath, fullPath);
|
|
if (urlPath === null) return c.text("Not Found", 404);
|
|
if (urlPath === "/" || urlPath === "/index.html") return serveIndex(c);
|
|
const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
|
|
const filePath = join(absRoot, rel);
|
|
if (!filePath.startsWith(absRoot + sep)) return serveIndex(c);
|
|
try {
|
|
const st = await stat(filePath);
|
|
if (!st.isFile()) return serveIndex(c);
|
|
const ext = extname(filePath).toLowerCase();
|
|
c.header("Content-Type", MIME[ext] ?? "application/octet-stream");
|
|
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)) {
|
|
c.header("Cache-Control", "no-cache");
|
|
c.header("Content-Security-Policy", APP_CSP);
|
|
} 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(servePath)) as ReadableStream;
|
|
return c.body(stream);
|
|
} catch {
|
|
// SPA fallback for client-side routes (no file extension) only.
|
|
if (!extname(rel)) return serveIndex(c);
|
|
return c.text("Not Found", 404);
|
|
}
|
|
};
|
|
}
|