Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/web/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 19 additions & 2 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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}`);
Expand Down
Loading