Files
ihasmail-inbuxa/server/src/static.ts
T

164 lines
6.7 KiB
TypeScript

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("; ");
export function staticHandler(root: string, basePath = ""): Handler {
const absRoot = resolve(root);
let indexCache: { body: string; mtime: number } | null = null;
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) {
indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs };
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);
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");
c.header("Content-Length", String(st.size));
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 (c.req.method === "HEAD") return c.body(null);
const stream = Readable.toWeb(createReadStream(filePath)) 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);
}
};
}