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:
+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.
|
||||
|
||||
Reference in New Issue
Block a user