Skip to content

fix(host-selfhost): serve correct Cache-Control for the web app#1221

Merged
RhysSullivan merged 1 commit into
mainfrom
fix/selfhost-spa-cache-headers
Jun 30, 2026
Merged

fix(host-selfhost): serve correct Cache-Control for the web app#1221
RhysSullivan merged 1 commit into
mainfrom
fix/selfhost-spa-cache-headers

Conversation

@RhysSullivan

Copy link
Copy Markdown
Owner

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.html with an ETag/Last-Modified but no Cache-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-Control can also produce a broken page rather than just an old one, if the browser evicted some old hashed chunks but kept the old index.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, immutable
  • index.html and its SPA fallbacks (the mutable entry point) -> no-cache, so the shell always revalidates (ETag -> 304 when unchanged, a fresh 200 after a deploy)

The hashed /assets route 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-store on index.html).

Verification

Built and booted the real self-host server, then checked each path class:

Path Status Cache-Control
/, /default, /default/policies 200 no-cache
/assets/index-*.js 200 public, max-age=31536000, immutable
/assets/<missing>.js 404 (no HTML fallback, so stale chunks don't 200-as-HTML)
/api/health 200 untouched (JSON)
conditional GET / (If-None-Match) 304 0 bytes (revalidation stays cheap)

Gates: typecheck, lint, format, and the host-selfhost suite (70/70) all pass.

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).
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Cloudflare preview

Torn down — the PR is closed.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
executor-cloud 19fc6e6 Jun 30 2026, 12:06 AM

@RhysSullivan RhysSullivan merged commit 3606317 into main Jun 30, 2026
15 checks passed
@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown

Greptile Summary

This 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 index.html being heuristically cached with no explicit Cache-Control directive. The fix splits static serving into two HttpStaticServer layers: one for Vite content-hashed /assets/* files (immutable) and one SPA catch-all for index.html and client routes (no-cache).

  • AssetsLive serves dist/assets/ at the /assets prefix with public, max-age=31536000, immutable — safe because Vite emits new filenames on every build.
  • SpaLive replaces the old single StaticLive layer and adds cacheControl: \"no-cache\", ensuring the entry document always revalidates so a deploy is reflected on the next visit rather than only after a hard refresh.

Confidence Score: 4/5

Safe 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

Filename Overview
apps/host-selfhost/src/serve.ts Splits static serving into two layers: AssetsLive (content-hashed /assets/* with immutable cache) and SpaLive (index.html + SPA fallbacks with no-cache). The change correctly addresses the stale-shell bug and is well-commented. No automated test coverage for the new Cache-Control header values.
.changeset/selfhost-spa-cache-headers.md Accurate changeset entry describing the Cache-Control fix for the self-hosted SPA shell and assets.

Reviews (1): Last reviewed commit: "fix(host-selfhost): cache-control for SP..." | Re-trigger Greptile

Comment on lines +88 to +98
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));

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant