From a9bae70c5d912b046def5ef4a684a74c64ae97f4 Mon Sep 17 00:00:00 2001 From: Ash Date: Tue, 1 Sep 2026 16:24:25 +0100 Subject: [PATCH] fix(web): mark static assets no-cache to stop CDN version skew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.js and index.html live at fixed, unhashed paths and every deploy overwrites them in place. On tl.reccd.stream, Cloudflare cached app.js at the edge for hours while index.html (no explicit header) was always DYNAMIC, so a deploy could leave a fresh index.html paired with a stale cached app.js until the CDN's TTL expired — reproduced as two different cached copies of /app.js served simultaneously, one of them wired for a DOM shape the served HTML no longer matched, which threw Cannot read properties of null (reading 'addEventListener') on load. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MPpJwadYAkvKmYvDuMwg6n --- src/web/server.test.ts | 17 +++++++++++++++++ src/web/server.ts | 21 +++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/web/server.test.ts b/src/web/server.test.ts index 28ea32cc..5b178d12 100644 --- a/src/web/server.test.ts +++ b/src/web/server.test.ts @@ -397,6 +397,23 @@ describe("startWebServer", () => { expect(res.headers.get("content-length")).toBe(String(Buffer.byteLength(body))); }); + // /app.js and index.html ship at fixed, unhashed paths and every deploy + // overwrites them in place, so a cache that serves either past its + // freshness window can pair a new index.html with a stale app.js (or vice + // versa) after a deploy. That mismatch is exactly what broke + // tl.reccd.stream: a CDN in front cached /app.js under its default + // Browser Cache TTL while index.html (no explicit header) went stale too, + // and the two drifted apart until the cache expired. no-cache forces a + // revalidation (in practice a refetch, since nothing here emits an + // ETag/Last-Modified) on every request rather than letting an + // intermediary reuse a stale copy. + it("marks static assets no-cache so a CDN cannot pair a stale bundle with a fresh index.html", async () => { + const base = await start({ staticDir: assets() }); + const [index, appJs] = await Promise.all([fetch(`${base}/`), fetch(`${base}/app.js`)]); + expect(index.headers.get("cache-control")).toBe("no-cache"); + expect(appJs.headers.get("cache-control")).toBe("no-cache"); + }); + // resolveAssetPath proves containment but not that the target is a file, so // without the isFile() check this streams a directory and dies with EISDIR // *after* the 200 header is out — a hang or a truncated body, not a 404. diff --git a/src/web/server.ts b/src/web/server.ts index e2e8f56a..1cca00a5 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -590,7 +590,11 @@ export async function startWebServer( } const wrote = await writeWebResponse( res, - { status: 200, filePath: asset, headers: { "Content-Type": contentTypeFor(asset) } }, + { + status: 200, + filePath: asset, + headers: { "Content-Type": contentTypeFor(asset), "Cache-Control": "no-cache" }, + }, log, ); log(`${method} ${urlPath} -> ${wrote}`); @@ -667,9 +671,22 @@ export async function startWebServer( // directory is answered 404 in there, and logging 200 regardless made // every asset failure read as a success — which defeats the point of // warning about missing assets at all. + // No cache validator (ETag/Last-Modified) is generated here, so + // "no-cache" in practice means every request refetches — that is the + // point. app.js and index.html both live at fixed, unhashed paths that + // a deploy overwrites in place, and a downstream cache (browser or + // CDN) that serves either past its freshness window can pair a fresh + // index.html with a stale app.js. That drift is exactly what broke + // tl.reccd.stream: the DOM index.html expected no longer matched what + // the cached bundle's listeners were wired for, so an + // addEventListener call landed on a null element. const wrote = await writeWebResponse( res, - { status: 200, filePath: file, headers: { "Content-Type": contentTypeFor(file) } }, + { + status: 200, + filePath: file, + headers: { "Content-Type": contentTypeFor(file), "Cache-Control": "no-cache" }, + }, log, ); log(`${method} ${urlPath} -> ${wrote}`);