#15721 · @BYK · opened Mar 2, 2026 at 2:03 PM UTC · last updated Mar 21, 2026 at 10:38 AM UTC
feat(server): embed web UI assets in binary and serve locally
Score breakdown
Impact
Clarity
Urgency
Ease Of Review
Guidelines
Readiness
Size
Trust
Traction
Summary
This PR aims to enable offline operation for opencode serve by embedding web UI assets directly into the binary at compile time. It provides a detailed technical explanation of the asset embedding and serving mechanism. The PR also includes minor, seemingly unrelated refactoring changes.
Description
Issue for this PR
Closes #17406
Type of change
- [ ] Bug fix
- [x] New feature
- [ ] Refactor / code improvement
- [ ] Documentation
What does this PR do?
The published binary currently proxies all web UI requests to app.opencode.ai, meaning opencode serve requires internet access just to load the UI. This PR embeds the web UI assets directly into the binary at compile time so the server works fully offline.
How it works:
script/build.ts now generates src/server/app-manifest.ts before the Bun compile step. This file maps URL paths (e.g. /assets/index-abc.js) to their embedded $bunfs paths. A Bun plugin with loader: "file" handles non-JS assets (images, fonts, audio) so they are embedded as opaque blobs rather than parsed as modules.
server.ts gains two serving functions:
serveStaticFile()— exact-match only, runs as middleware beforeInstance.provide()so asset requests never trigger DB migrationsserveLocal()— exact match + SPA fallback for extensionless page routes, used in the catch-all after all API routes; skips SPA fallback for paths with file extensions so unmatched assets fall through to CDN
Asset resolution order: embedded $bunfs → OPENCODE_APP_DIR env var → auto-detect packages/app/dist (monorepo dev) → CDN proxy fallback.
Optional Nerd Font files (83 files, ~27 MB) are excluded from embedding and transparently proxied from app.opencode.ai on first use. Core fonts (Inter, IBM Plex Mono) remain embedded. font-display: swap ensures text renders immediately with a fallback font while optional fonts load.
A committed stub src/server/app-manifest.ts ensures the static import resolves in CI and dev without running the full build script.
| | Binary size | |---|---| | v1.2.15 (CDN proxy only) | ~159 MB | | With all fonts embedded | ~201 MB | | With optional fonts externalized (this PR) | ~174 MB |
How did you verify your code works?
Ran a local binary build and verified:
GET /→ 200 with<!DOCTYPE html>- SPA fallback (extensionless route) → 200 with index.html
- Asset requests (PNG, JS, CSS, webmanifest) → 200 with correct MIME types
- CSP header present on all HTML responses
- API routes (
/agent,/health) → not hijacked by static middleware - Optional font → falls through to CDN proxy (not embedded)
- TUI mode works (migration define intact)
Screenshots / recordings
No UI changes.
Checklist
- [x] I have tested my changes locally
- [x] I have not included unrelated changes in this PR
Linked Issues
#17406 Web UI requires internet — binary proxies all assets to CDN instead of serving locally
View issueComments
PR comments
BYK
Thanks for flagging #12829 — @BlankParticle's PR tackles the same problem and uses the same core technique (Bun's import … with { type: "file" } to embed assets into $bunfs). That work was a helpful reference point.
This PR started from the same idea but diverged during iterative testing against an actual deployed binary, where we ran into three issues that the current implementation addresses:
-
Middleware ordering & DB migrations — In #12829, embedded assets are served in the catch-all route after
Instance.provide(), which means every CSS/JS/image request triggers the database migration check. In compiled binaries this adds unnecessary overhead on every asset load. This PR splits serving intoserveStaticFile()(runs beforeInstance.provide()so assets short-circuit immediately) andserveLocal()(catch-all after all API routes for SPA fallback). -
SPA fallback hijacking API routes — #12829 falls back to
index.htmlfor all unmatched paths, including API routes like/agent. This causes the frontend to receive HTML instead of JSON, resulting in aTypeError: t.data.agent.filter is not a functioncrash. This PR only applies SPA fallback to extensionless paths, so API routes are never affected. -
CI compatibility — The SDK build (
@opencode-ai/sdk:build) transitively importsserver.ts. #12829'simport("opencode-web-ui.gen.ts")would fail during that step since the generated file only exists after a binary build. This PR uses a committed stubapp-manifest.tsthat exports{}so typecheck and tests work in CI without special setup.
Beyond the bug fixes, this PR also externalizes optional Nerd Font files (~27 MB) so they're transparently proxied from the CDN on first use, bringing the binary from ~201 MB down to ~174 MB. It also adds a 4-tier asset resolution chain ($bunfs → OPENCODE_APP_DIR env → auto-detect packages/app/dist/ → CDN) which is useful during local development.
Happy to incorporate anything from #12829 that we may have missed, or credit @BlankParticle as a co-author if the maintainers feel that's appropriate given the shared approach. 🙏
Warkeeper
I built opencode locally with this PR applied and verified that the embedded web UI works correctly.
Serving the UI directly from the binary instead of proxying to app.opencode.ai is quite important for my use case (offline / restricted network environments). This PR solves that problem nicely in my testing.
+1 for merging this when maintainers have time.
Changed Files
packages/app/src/components/dialog-connect-provider.tsx
+1−1packages/app/src/components/dialog-custom-provider.tsx
+1−1packages/opencode/script/build.ts
+63−2packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
+1−1packages/opencode/src/flag/flag.ts
+1−0packages/opencode/src/server/app-manifest.ts
+2−0packages/opencode/src/server/server.ts
+160−8