Skip to content

fix(opencode): cache policy + etag for the embedded web UI - #51875

Open
afonsoft wants to merge 2 commits into
anomalyco:devfrom
afonsoft:ui-cache-headers
Open

afonsoft wants to merge 2 commits into
anomalyco:devfrom
afonsoft:ui-cache-headers

Conversation

@afonsoft

Copy link
Copy Markdown

Issue for this PR

Closes #51858 (embedded UI served without a usable cache policy). Related: #51857 (SSE liveness), #51860 (connection/version surface), #41280, #50398 (stale UI reports), #48622.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

serveEmbeddedUIEffect returned embedded web UI files with only content-type (plus CSP for HTML) — no Cache-Control, no ETag. With no cache directives, browsers apply heuristic caching and can hold a stale index.html across server upgrades, pinning users to an old app bundle until Ctrl+F5.

Response policy now, by file class:

File cache-control Why
index.html + unhashed files (site.webmanifest, icons, oc-theme-preload.js, verbatim public/assets/ fonts) no-cache Same URL, new content on every release — must revalidate each load
assets/<name>-<hash>.<ext> (Vite content-hashed outputs) public, max-age=31536000, immutable Filename changes with content — permanently cacheable

Plus:

Scope note: the disableEmbeddedWebUi path proxies app.opencode.ai and forwards the upstream host's headers; this PR only fixes the embedded (self-hosted) path, which is the deployment that reports stale UI most often.

How did you verify your code works?

New test in packages/opencode/test/server/httpapi-ui.test.ts ("marks content-hashed assets immutable and everything else revalidating") asserts:

  • index.html → no-cache + etag
  • assets/index-Ab1cD2e3.js (hashed) → immutable
  • assets/Inter.ttf + site.webmanifest (unhashed) → no-cache
  • If-None-Match → 304 with etag preserved

bun test --only-failures ./test/server/httpapi-ui.test.ts — 13 pass / 0 fail; bun typecheck clean; no new lint warnings.

Screenshots / screen recording

N/A — response headers only.

Checklist

  • I have read the contributing docs
  • Tests added for all three cache classes + conditional-request behavior
  • No contract changes for API routes — only the embedded UI fallback handler

Embedded UI responses carried only content-type (and CSP for HTML), so
browsers were free to hold a stale index.html across server upgrades —
leaving the app pinned to an old bundle until a hard refresh. This is
one of the stale-UI mechanisms tracked in anomalyco#51858.

- index.html and all unhashed files (manifest, icons, theme preload,
  verbatim public/assets files): cache-control: no-cache, so the
  document revalidates on every load.
- Bundle assets under assets/ whose filename carries the Vite content
  hash: public, max-age=31536000, immutable.
- ETag (sha256 of body) on every embedded response + If-None-Match ->
  304, so revalidation is cheap.
- x-opencode-version header so clients can detect a server upgrade.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@afonsoft

afonsoft commented Sep 28, 2026 •

Copy link
Copy Markdown
Author

cc @thdxr @adamdotdevin — requesting review when you get a chance.

Fix details:

Embedded UI responses previously sent only content-type (+ CSP for HTML). With no cache directives, browsers apply heuristic caching — a stale index.html after an upgrade pins users to an old bundle, which is one of the mechanisms behind the recurring "stale UI" reports.

The policy split mirrors the build output shape:

  • no-cache for index.html, site.webmanifest, icons, oc-theme-preload.js and the verbatim public/assets/ files — these keep the same URL across releases, so they must revalidate every load.
  • public, max-age=31536000, immutable only for assets/<name>-<hash>.<ext> — the hash detection is on the file path (normalized for Windows separators), matching Vite's [name]-[hash] emission; unhashed files that happen to live under assets/ (e.g. Inter.ttf) are not matched.
  • ETag = sha256(body)[:32] on every embedded response, with If-None-Match → 304 so no-cache revalidations cost a header roundtrip, not the body.
  • x-opencode-version exposes the server version on every UI response — a building block for the version-mismatch surface proposed in [FEATURE]: web: connection health surface — stream status, reconnect progress, client/server version mismatch #51860.

The proxied (--disable-embedded-web-ui) path is untouched — it still forwards app.opencode.ai's own headers.

New test covers all three cache classes + the conditional-request path: 13/13 pass in httpapi-ui.test.ts, typecheck clean.

@kvnloo

kvnloo commented Sep 28, 2026

Copy link
Copy Markdown

Embedded UI responses send no cache directives, so a stale index.html can pin a browser to the previous bundle after an upgrade.

Head 4a8754c5d6dd5e33dd6352d00d4b4d0c3b1a11f5 is unchanged from the tested tip.

Fork leaf kvnloo#197 recorded fail→pass on packages/opencode/test/server/httpapi-ui.test.ts (13 pass, 1 fail with the assertion removed, 13 pass after restore).

That locks hashed assets as immutable and HTML as no-cache. It does not change the UI itself.

@afonsoft

afonsoft commented Oct 3, 2026

Copy link
Copy Markdown
Author

Reviewed — the cache policy split is correct, and crucially the failure direction is safe: the HASHED_ASSET regex errs toward no-cache on misses (uppercase extensions, unhashed files under assets/ like Inter.ttf), so a false negative costs a revalidation rather than pinning a stale asset forever. Findings:

Nit — If-None-Match comparison is strict equality. ifNoneMatch === etag misses a multi-value header ("abc", "def") or *. Browsers echo the single stored value so this works in practice; a tolerant check is cheap insurance for proxies that rewrite or coalesce headers:

const matches = ifNoneMatch?.split(",").map((v) => v.trim()).includes(etag) ?? ifNoneMatch === "*"
if (matches) return HttpServerResponse.empty({ status: 304, headers })

Nit — 304 path does wasted work. embeddedUIResponse decodes the body to compute the CSP header for HTML before the etag check runs. On a no-cache revalidation of index.html — the most common 304 — the file is read, sha256'd, and fully decoded as UTF-8 for a response that discards everything except validators. Moving the etag check before the CSP computation (or computing CSP only for 200s) trims the hot path. Minor, since bodies are small.

FYI — bodies are re-read + re-hashed per request. fs.readFile + sha256 on every embedded request is fine at current asset sizes, but since embedded files are immutable for the life of the process, a (path → { body, etag }) memo would eliminate both the disk read and the hash entirely. Reasonable follow-up, not for this PR.

Verified the claim about scope: serveUIEffect only reaches serveEmbeddedUIEffect when embeddedWebUI is non-null, so the proxied app.opencode.ai path is genuinely untouched — upstream headers still pass through.

Verdict: approve — correct policy split, safe failure direction, good test coverage across all three file classes plus the conditional-request path.

cc @Hona @Brendonovich — could you review and approve? Fixes the stale-index.html-after-upgrade class behind the recurring "old UI pinned until Ctrl+F5" reports, and adds x-opencode-version as groundwork for #51860's version-mismatch UX.

This branch has not been deployed

No deployments
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.

web: embedded UI served without Cache-Control/ETag — HTML can go stale after deploy

2 participants