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
5 changes: 5 additions & 0 deletions .changeset/selfhost-spa-cache-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/host-selfhost": patch
---

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.
33 changes: 26 additions & 7 deletions apps/host-selfhost/src/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
import { stripMcpOrgSegment } from "./mcp/org-path";

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

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

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

const SpaLive = HttpStaticServer.layer({
root: distDir,
spa: true,
cacheControl: "no-cache",
}).pipe(Layer.provide(BunFileSystem.layer), Layer.provide(BunPath.layer));
Comment on lines +88 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No test coverage for the new Cache-Control values

The split-layer caching strategy is the load-bearing fix here, but there are no assertions that /assets/* actually returns Cache-Control: public, max-age=31536000, immutable or that / returns Cache-Control: no-cache. If HttpStaticServer.layer's cacheControl option is ever renamed or its semantics change in a future effect/unstable/http bump, the regression would be silent — all 70 existing tests would still pass because they exercise the API/auth paths, not static-file headers.

A small integration test that boots the server against a fixture directory with an assets/ subdirectory and checks the Cache-Control header on both response classes would lock this in.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


const ServerLive = HttpRouter.serve(Layer.mergeAll(AppLayer, StaticLive), {
const ServerLive = HttpRouter.serve(Layer.mergeAll(AppLayer, AssetsLive, SpaLive), {
middleware: selfHostHttpMiddleware(betterAuth),
}).pipe(
Layer.provide(
Expand Down
Loading