#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

appfeat
67
+229137 files

Score breakdown

Impact

9.0

Clarity

8.0

Urgency

7.0

Ease Of Review

8.0

Guidelines

6.0

Readiness

7.0

Size

4.0

Trust

5.0

Traction

6.0

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.

Open in GitHub

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 before Instance.provide() so asset requests never trigger DB migrations
  • serveLocal() — 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 $bunfsOPENCODE_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 issue

Comments

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:

  1. 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 into serveStaticFile() (runs before Instance.provide() so assets short-circuit immediately) and serveLocal() (catch-all after all API routes for SPA fallback).

  2. SPA fallback hijacking API routes — #12829 falls back to index.html for all unmatched paths, including API routes like /agent. This causes the frontend to receive HTML instead of JSON, resulting in a TypeError: t.data.agent.filter is not a function crash. This PR only applies SPA fallback to extensionless paths, so API routes are never affected.

  3. CI compatibility — The SDK build (@opencode-ai/sdk:build) transitively imports server.ts. #12829's import("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 stub app-manifest.ts that 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 ($bunfsOPENCODE_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

+11
@@ -383,7 +383,7 @@ export function DialogConnectProvider(props: { provider: string }) {
setFormStore("error", undefined)
await globalSDK.client.auth.set({
providerID: props.provider,
auth: {
body: {
type: "api",
key: apiKey,
},

packages/app/src/components/dialog-custom-provider.tsx

+11
@@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
const auth = result.key
? globalSDK.client.auth.set({
providerID: result.providerID,
auth: {
body: {
type: "api",
key: result.key,
},

packages/opencode/script/build.ts

+632
@@ -1,6 +1,6 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { $, Glob } from "bun"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
@@ -56,6 +56,52 @@ const migrations = await Promise.all(
)
console.log(`Loaded ${migrations.length} migrations`)
// Glob web UI build output to embed in binary. The app must be built first
// (turbo ensures this via dependsOn: ["^build"]).
// We generate a manifest module that maps relative URL paths to embedded $bunfs
// asset paths. Each file is imported with { type: "file" } so Bun embeds it as
// an opaque asset. A plugin forces non-JS files through the "file" loader so
// Bun does not try to parse/bundle them.
const appDistDir = path.resolve(dir, "../../packages/app/dist")
const appDistAll = fs.existsSync(appDistDir)
? Array.from(new Glob("**/*").scanSync({ cwd: appDistDir, onlyFiles: true }))
: []
// Exclude optional font files from embedding — they fall through to CDN proxy
// at runtime. Core fonts (Inter UI font + IBM Plex Mono default monospace) are
// kept. This saves ~27MB on the binary since users only use one of 12+ optional
// monospace fonts.
const CORE_FONT = /\/(int

packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx

+11
@@ -265,7 +265,7 @@ function ApiMethod(props: ApiMethodProps) {
if (!value) return
await sdk.client.auth.set({
providerID: props.providerID,
auth: {
body: {
type: "api",
key: value,
},

packages/opencode/src/flag/flag.ts

+10
@@ -36,6 +36,7 @@ export namespace Flag {
export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export declare const OPENCODE_CLIENT: string
export const OPENCODE_APP_DIR = process.env["OPENCODE_APP_DIR"]
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL")

packages/opencode/src/server/app-manifest.ts

+20
@@ -0,0 +1,2 @@
// No assets found at build time
export default {} as Record<string, string>

packages/opencode/src/server/server.ts

+1608
@@ -1,3 +1,4 @@
import appManifest from "./app-manifest"
import { Log } from "../util/log"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono"
@@ -32,7 +33,7 @@ import { ExperimentalRoutes } from "./routes/experimental"
import { ProviderRoutes } from "./routes/provider"
import { EventRoutes } from "./routes/event"
import { InstanceBootstrap } from "../project/bootstrap"
import { NotFoundError } from "../storage/db"
import { Storage } from "../storage/storage"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
@@ -47,9 +48,144 @@ import { lazy } from "@/util/lazy"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
declare global {
// Injected at compile time by build.ts when web UI assets are embedded
const OPENCODE_APP_EMBEDDED: boolean | undefined
}
export namespace Server {
const lo