Merge pull request #309 from Coffey-Labs/sw-must-not-be-cached
Never serve the service worker from a cache
This commit is contained in:
+30
-1
@@ -5,6 +5,35 @@ import { Readable } from "node:stream";
|
|||||||
import type { Context, Handler } from "hono";
|
import type { Context, Handler } from "hono";
|
||||||
import { stripBasePath } from "../../scripts/basePath.mjs";
|
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> = {
|
const MIME: Record<string, string> = {
|
||||||
".html": "text/html; charset=utf-8",
|
".html": "text/html; charset=utf-8",
|
||||||
".js": "text/javascript; 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));
|
c.header("Content-Length", String(st.size));
|
||||||
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
|
if (rel.startsWith("/assets/") || rel.startsWith("assets/")) {
|
||||||
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
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("Cache-Control", "no-cache");
|
||||||
c.header("Content-Security-Policy", APP_CSP);
|
c.header("Content-Security-Policy", APP_CSP);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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"), "<!doctype html><title>t</title>");
|
||||||
|
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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user