diff --git a/server/src/static.ts b/server/src/static.ts index 6cfc649..7b16901 100644 --- a/server/src/static.ts +++ b/server/src/static.ts @@ -5,6 +5,35 @@ 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 = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", @@ -116,7 +145,7 @@ export function staticHandler(root: string, basePath = ""): Handler { 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") { + } else if (ext === ".html" || isNeverStale(rel, ext)) { c.header("Cache-Control", "no-cache"); c.header("Content-Security-Policy", APP_CSP); } else { diff --git a/server/src/staticcache.test.ts b/server/src/staticcache.test.ts new file mode 100644 index 0000000..9759657 --- /dev/null +++ b/server/src/staticcache.test.ts @@ -0,0 +1,81 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/* + * What may be served stale, and what may not. + * + * This is not a preference about freshness. The service worker is the app's + * whole update mechanism: a browser holding an old one goes on being served + * the shell that worker knows and never finds out a deploy happened. On + * 2026-09-08 the origin had the new build while Cloudflare handed out the + * previous `sw.js` for hours, because it was neither a hashed asset nor HTML + * and so went out with an hour's max-age that the CDN then extended. + * + * A static root of our own, since CI runs the tests before the build and + * `web/dist` does not exist yet. + */ +const root = mkdtempSync(join(tmpdir(), "ihasmail-cache-")); +mkdirSync(join(root, "assets")); +writeFileSync(join(root, "assets", "app-a1b2c3.js"), "console.log(1)\n"); +writeFileSync(join(root, "sw.js"), "/* worker */\n"); +writeFileSync(join(root, "manifest.webmanifest"), `{"name":"ihasmail"}`); +writeFileSync(join(root, "index.html"), "t"); +writeFileSync(join(root, "img.png"), "not really a png"); + +process.env.STATIC_DIR = root; +process.env.STALWART_URL = "http://127.0.0.1:1"; +const { createApp } = await import("./app.js"); + +const cacheControl = async (path: string) => { + const res = await createApp().request(path); + assert.equal(res.status, 200, `${path} should be served`); + return res.headers.get("cache-control") ?? ""; +}; + +test("the service worker is never served from a cache without asking", async () => { + // `no-cache` permits storing it and requires revalidating it, which is a 304 + // and costs nothing. What it forbids is a browser or a CDN answering with + // its own copy, which is the whole failure. + assert.match(await cacheControl("/sw.js"), /no-cache/); +}); + +test("nor is the manifest, which the worker has to agree with", async () => { + // A fresh manifest advertising a share target, answered by a worker that has + // never heard of one, sends the share to the server for a 405. Either being + // old is survivable; the two disagreeing is not. + assert.match(await cacheControl("/manifest.webmanifest"), /no-cache/); +}); + +test("the manifest is still served as a manifest", async () => { + const res = await createApp().request("/manifest.webmanifest"); + assert.match(res.headers.get("content-type") ?? "", /application\/manifest\+json/); +}); + +test("index.html was already revalidated, and still is", async () => { + assert.match(await cacheControl("/"), /no-cache/); +}); + +test("hashed assets are still immutable for a year", async () => { + // The name changes when the bytes do, so there is nothing to go stale -- + // and this is the caching that makes the app load quickly at all. + const cc = await cacheControl("/assets/app-a1b2c3.js"); + assert.match(cc, /immutable/); + assert.match(cc, /max-age=31536000/); +}); + +test("everything else keeps its ordinary hour", async () => { + // The rule is narrow on purpose: two files, named, rather than a policy that + // quietly stops the icons and fonts being cached too. + assert.match(await cacheControl("/img.png"), /max-age=3600/); +}); + +test("under a prefix, the worker is still the worker", async () => { + // The mount comes off before the path is matched, so this has to hold for a + // subpath deployment as well -- where a stale worker is exactly as bad. + const res = await createApp("/mail").request("/mail/sw.js"); + assert.equal(res.status, 200); + assert.match(res.headers.get("cache-control") ?? "", /no-cache/); +});