fix(host-selfhost): serve correct Cache-Control for the web app#1221
Conversation
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).
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
executor-marketing | 19fc6e6 | Commit Preview URL Branch Preview URL |
Jun 30 2026, 12:05 AM |
Cloudflare previewTorn down — the PR is closed. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
executor-cloud | 19fc6e6 | Jun 30 2026, 12:06 AM |
Greptile SummaryThis PR fixes a stale-UI bug on self-hosted instances where browsers would render an old version of the app after a deploy, caused by
Confidence Score: 4/5Safe to merge — the change is a targeted HTTP caching fix with no effect on API, auth, or MCP routes. The split-layer approach correctly addresses the stale-shell bug. The only gap is the absence of automated assertions on the new Cache-Control header values; the existing test suite covers API/auth paths but does not exercise static-file responses. apps/host-selfhost/src/serve.ts — the new two-layer static setup has no test coverage for the Cache-Control headers it emits. Important Files Changed
Reviews (1): Last reviewed commit: "fix(host-selfhost): cache-control for SP..." | Re-trigger Greptile |
| 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)); |
There was a problem hiding this comment.
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!
Problem
On a self-hosted instance, the web app could look like it had two different versions of the UI: visiting the root showed an older build, while navigating to a route like
/default(or hard-refreshing) showed the newer build.The cause is HTTP caching. The server served
index.htmlwith anETag/Last-Modifiedbut noCache-Control, so browsers apply heuristic freshness and reuse the cached entry document across deploys. Because the content-hashed/assets/*bundles from the previous visit are also still in the browser cache, a revisit to a previously-loaded URL renders an entirely-cached old shell + old bundles (the old UI), with no network hit. A URL the browser never cached as a document fetches fresh and shows the new UI. A hard refresh "fixes" it, which is the fingerprint of this bug.The same shell with no
Cache-Controlcan also produce a broken page rather than just an old one, if the browser evicted some old hashed chunks but kept the oldindex.html, the new shell's now-missing chunks fail to load.Fix
Split static serving by cacheability:
/assets/*(Vite content-hashed, new build emits new filenames) ->public, max-age=31536000, immutableindex.htmland its SPA fallbacks (the mutable entry point) ->no-cache, so the shell always revalidates (ETag ->304when unchanged, a fresh200after a deploy)The hashed
/assetsroute is the more specific match, so it wins over the SPA catch-all. API/auth/MCP/docs routes are unaffected. This mirrors how the desktop/local server already serves its shell (no-storeonindex.html).Verification
Built and booted the real self-host server, then checked each path class:
Cache-Control/,/default,/default/policiesno-cache/assets/index-*.jspublic, max-age=31536000, immutable/assets/<missing>.js/api/healthGET /(If-None-Match)Gates: typecheck, lint, format, and the host-selfhost suite (70/70) all pass.