#18510 · @DroganC · opened Mar 21, 2026 at 9:40 AM UTC · last updated Mar 21, 2026 at 10:02 AM UTC

feat: offline web static serve

appfeat
57
+1381212 files

Score breakdown

Impact

9.0

Clarity

9.0

Urgency

5.0

Ease Of Review

7.0

Guidelines

9.0

Readiness

8.0

Size

4.0

Trust

5.0

Traction

0.0

Summary

This PR enables opencode web to serve its UI from a local build, eliminating reliance on app.opencode.ai for users in air-gapped or offline environments. It introduces a new environment variable for configuration and comprehensive documentation.

Open in GitHub

Description

Issue for this PR

Closes #18510

Type of change

  • [ ] Bug fix
  • [x] New feature
  • [ ] Refactor / code improvement
  • [x] Documentation

What does this PR do?

Problem: opencode web serves the browser UI by proxying to https://app.opencode.ai when no API route matches. On machines without outbound access to that host, the UI never loads.

Changes:

  1. packages/opencode/src/server/server.ts — If packages/app/dist/index.html exists (or OPENCODE_APP_DIST points at a directory that contains it), serve files from that directory with the same SPA fallback as before (unknown paths → index.html). Otherwise keep the existing proxy behavior.
  2. Flag.OPENCODE_APP_DIST in packages/opencode/src/flag/flag.ts — Documents and centralizes the env var used for the absolute path override.
  3. One log line when local dist is used (serving web UI from local dist with root) so operators can confirm the proxy is not in use.
  4. packages/app — Changelog fetch uses /changelog.json (from public/ after build); notification and project avatar use same-origin favicon paths instead of https://opencode.ai/..., so those requests stay on the server.
  5. docs/OFFLINE_WEB.md (English) — How to build packages/app, which env vars matter, and troubleshooting. CONTRIBUTING.md — Short subsection linking to that doc.

Why it works: The Vite production bundle is static files. Serving them from the same process that hosts the API avoids any dependency on app.opencode.ai for HTML/JS/CSS. The API routes are unchanged and still registered before the catch-all.

How did you verify your code works?

  • bun run build in packages/app so dist/index.html exists.
  • OPENCODE_APP_DIST set to that dist path (or rely on default path from packages/opencode), then bun run --conditions=browser ./src/index.ts web from packages/opencode.
  • Confirmed the browser loads the app from http://127.0.0.1:4096 and the server logs serving web UI from local dist once.
  • With dist removed or renamed, confirmed fallback still proxies (when network allows) so default behavior is preserved.

Screenshots / recordings

Optional: browser showing the app loaded from localhost while offline (no change to core UI layout intended).

Checklist

  • [x] I have tested my changes locally
  • [x] I have not included unrelated changes in this PR

Linked Issues

None.

Comments

No comments.

Changed Files

.gitignore

+30
@@ -23,6 +23,9 @@ target
.scripts
.direnv/
# Local mirrors for air-gapped use (see docs/OFFLINE_WEB.md)
offline/
# Local dev files
opencode-dev
logs/

CONTRIBUTING.md

+40
@@ -121,6 +121,10 @@ bun run --cwd packages/app dev
This starts a local dev server at http://localhost:5173 (or similar port shown in output). Most UI changes can be tested here, but the server must be running for full functionality.
### Air-gapped or offline `opencode web`
If the machine cannot reach `app.opencode.ai`, build the web app and point the server at the `dist` output. See [docs/OFFLINE_WEB.md](./docs/OFFLINE_WEB.md) for `OPENCODE_APP_DIST`, optional model list mirroring, and troubleshooting.
### Running the Desktop App
The desktop app is a native Tauri application that wraps the web UI.

docs/OFFLINE_WEB.md

+820
@@ -0,0 +1,82 @@
# Air-gapped or offline `opencode web`
Serve the web UI from a local **`vite build`** output so the server does not proxy to `https://app.opencode.ai`. If no build is present (no `index.html` under the resolved path), behavior is unchanged: the server falls back to that proxy (requires outbound access).
**Before opening a PR for related code changes:** the project expects an [issue first](https://github.com/anomalyco/opencode/blob/dev/CONTRIBUTING.md#issue-first-policy) (`Fixes #…` / `Closes #…` in the PR description).
---
## 1. Build the web app (while online)
From the repository root:
```bash
bun install
cd packages/app
bun run build
```
Confirm **`packages/app/dist/index.html`** exists.
---
## 2. Environment variables
| Variable | Purpose |
|----------|---------|
| `OPENCODE_APP_DIST` | Absolute path to the `dist` directory (must contain `index.html`). If unset, the server looks for `packages/app/dist` relative to the running server package. |
| `OPENCODE_DISABLE_MODELS_FETCH` | Set to `1` to disable periodic fetches to `https://models.dev`. |
| `OPENCODE_MODELS_PATH` | Optional path to a local `api.json`–compatible file (e

packages/app/public/changelog.json

+10
@@ -0,0 +1 @@
{"releases":[{"tag":"v1.2.27","name":"v1.2.27","date":"2026-03-16T02:34:10Z","url":"https://github.com/anomalyco/opencode/releases/tag/v1.2.27","highlights":[],"sections":[{"title":"Core","items":["Fixed VCS watcher if statement logic","Delete legacy permission module","Clean up pending entry when question is aborted","Remove SIGHUP exit handler","Effectify PermissionNext and fix InstanceState ALS bug","Inline branded ID schemas","Refactor QuestionService to use effects","Ensure that compaction message is tracked as agent initiated","Increase default chunk timeout from 2 minutes to 5 minutes","Fix lost sessions across worktrees and orphan branches (@michaeldwan)"]},{"title":"Desktop","items":["Remove open label from UI","Handle multiline web paste in prompt composer"]}]},{"tag":"v1.2.26","name":"v1.2.26","date":"2026-03-13T16:33:18Z","url":"https://github.com/anomalyco/opencode/releases/tag/v1.2.26","highlights":[],"sections":[{"title":"Core","items":["Scaffold effect-to-zod bridge for schema conversion","Serialize configuration for Bun installations","Support text attachments in app","Paginate session history for improved server performance","Sessions lost after git

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/app/src/context/highlights.tsx

+11
@@ -7,7 +7,7 @@ import { useSettings } from "@/context/settings"
import { persisted } from "@/utils/persist"
import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes"
const CHANGELOG_URL = "https://opencode.ai/changelog.json"
const CHANGELOG_URL = "/changelog.json"
type Store = {
version?: string

packages/app/src/entry.tsx

+11
@@ -67,7 +67,7 @@ const notify: Platform["notify"] = async (title, description, href) => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
icon: "/favicon-96x96-v3.png",
})
notification.onclick = () => {

packages/app/src/pages/layout/sidebar-items.tsx

+11
@@ -44,7 +44,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
<Avatar
fallback={name()}
src={
props.project.id === OPENCODE_PROJECT_ID ? "https://opencode.ai/favicon.svg" : props.project.icon?.override
props.project.id === OPENCODE_PROJECT_ID ? "/favicon-v3.svg" : props.project.icon?.override
}
{...getAvatarColors(props.project.icon?.color)}
class="size-full rounded"

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

+20
@@ -69,6 +69,8 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
/** Absolute path to `packages/app` production build output (`vite build` → `dist/`). Used by `opencode web` to serve the UI without proxying to app.opencode.ai. */
export const OPENCODE_APP_DIST = process.env["OPENCODE_APP_DIST"]
export const OPENCODE_DB = process.env["OPENCODE_DB"]
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")

packages/opencode/src/server/server.ts

+406
@@ -43,12 +43,16 @@ import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
import { MDNS } from "./mdns"
import { lazy } from "@/util/lazy"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { stat } from "node:fs/promises"
// @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
export namespace Server {
const log = Log.create({ service: "server" })
let webUiLocalLogged = false
export const Default = lazy(() => createApp({}))
@@ -497,19 +501,49 @@ export namespace Server {
},
)
.all("/*", async (c) => {
const path = c.req.path
const webCsp =
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
const response = await proxy(`https://app.opencode.ai${path}`, {
const root = Flag.OPENCODE_APP_DIS