Skip to content

Commit 3606317

Browse files
authored
fix(host-selfhost): cache-control for SPA shell vs hashed assets (#1221)
The self-hosted server served index.html with no Cache-Control, so browsers heuristically cached the entry document across deploys and rendered a stale UI (old bundles, still in cache) until a hard refresh. Visiting an unvisited route (e.g. /default) fetched fresh and showed the new UI, which looked like two different versions of the app. Split static serving: hashed /assets/* are cached immutably, while index.html and its SPA fallbacks are served no-cache so they always revalidate (etag -> 304 when unchanged, fresh after a deploy).
1 parent 7c47b03 commit 3606317

2 files changed

Lines changed: 31 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/host-selfhost": patch
3+
---
4+
5+
Send correct `Cache-Control` headers for the self-hosted web app. The SPA shell (`index.html`) and its client-route fallbacks are now served with `no-cache`, so a new deploy is picked up on the next visit instead of the browser rendering a stale UI from cache until a hard refresh. Content-hashed `/assets/*` are served `immutable` and cached long-term.

apps/host-selfhost/src/serve.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
import { stripMcpOrgSegment } from "./mcp/org-path";
3636

3737
const distDir = fileURLToPath(new URL("../dist/", import.meta.url));
38+
const assetsDir = fileURLToPath(new URL("../dist/assets/", import.meta.url));
3839

3940
// Rewrite `/<org>/mcp` (and its OAuth discovery path) to the bare path before
4041
// routing, so the "Connect an agent" card's org-pinned URL reaches the real
@@ -71,14 +72,32 @@ export const startServer = async (): Promise<void> => {
7172
const config = loadConfig();
7273
const { AppLayer, betterAuth } = await makeSelfHostApp();
7374

74-
// Serve the built SPA. Specific API/docs/auth/mcp routes take precedence;
75-
// `spa: true` falls back to index.html for any other path (client routing).
76-
const StaticLive = HttpStaticServer.layer({ root: distDir, spa: true }).pipe(
77-
Layer.provide(BunFileSystem.layer),
78-
Layer.provide(BunPath.layer),
79-
);
75+
// Serve the built SPA, split by cacheability so a redeploy is picked up at
76+
// once instead of stranding browsers on a stale shell:
77+
// - `/assets/*` are Vite content-hashed (a new build emits new filenames),
78+
// so they're safe to cache forever.
79+
// - index.html (and the SPA fallback for client routes) is the mutable
80+
// entry point that references those hashes; it must always revalidate, or
81+
// a browser keeps an old index.html plus its old hashed bundles (still in
82+
// cache) and renders a stale UI until a hard refresh.
83+
// Without explicit headers `HttpStaticServer` sends no Cache-Control at all,
84+
// so browsers heuristically cache index.html across deploys. The hashed
85+
// `/assets` route is the more specific match, so it wins over the SPA
86+
// catch-all. Other built-in API/docs/auth/mcp routes still take precedence;
87+
// `spa: true` falls back to index.html for any remaining path (client routing).
88+
const AssetsLive = HttpStaticServer.layer({
89+
root: assetsDir,
90+
prefix: "/assets",
91+
cacheControl: "public, max-age=31536000, immutable",
92+
}).pipe(Layer.provide(BunFileSystem.layer), Layer.provide(BunPath.layer));
93+
94+
const SpaLive = HttpStaticServer.layer({
95+
root: distDir,
96+
spa: true,
97+
cacheControl: "no-cache",
98+
}).pipe(Layer.provide(BunFileSystem.layer), Layer.provide(BunPath.layer));
8099

81-
const ServerLive = HttpRouter.serve(Layer.mergeAll(AppLayer, StaticLive), {
100+
const ServerLive = HttpRouter.serve(Layer.mergeAll(AppLayer, AssetsLive, SpaLive), {
82101
middleware: selfHostHttpMiddleware(betterAuth),
83102
}).pipe(
84103
Layer.provide(

0 commit comments

Comments
 (0)