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}`);