Skip to content

Commit 686a8dd

Browse files
csillagclaude
andcommitted
feat: make opencode web embeddable in iframes at a subpath
Enables a reverse-proxy / embedding host (e.g. a parent dashboard) to serve opencode web under an arbitrary URL prefix — the built SPA, the server's CSP, and the SDK's default-server-URL resolution all have to cooperate for a same-origin iframe mount to actually work. Four orthogonal changes: 1. packages/app/vite.config.ts — `base: './'` Emits relative asset paths in the built index.html and chunk imports (e.g. `./assets/foo.js` instead of `/assets/foo.js`), so a document loaded under `/some/deep/iframe-prefix/` can resolve its own asset URLs against that prefix rather than against the origin root. No effect on direct-serve at `/`; every Vite-base subpath story just works from one source build. 2. packages/opencode/src/server/routes/ui.ts — add `'unsafe-eval'` to the embedded-UI CSP's script-src directive Zod 4 (pinned via the workspace catalog at `[email protected]`) JIT-compiles its validators at schema-definition time via `new Function(...)` — that's where v4's speed advantage over v3 comes from. Every `z.object({...})` in the bundle therefore needs `'unsafe-eval'`; the existing CSP only granted `'wasm-unsafe-eval'`, which Firefox correctly distinguishes from the broader keyword Zod actually needs. Confirmed by tracing: the first Function-construct trap at page load fires from `packages/app/src/context/global-sdk.tsx:12` (`z.object({ name: z.literal('AbortError') })`) and the stack walks through `zod/v4/core/{core,schemas,util}.js`, which is the v4 codegen pipeline. This relaxation is therefore not optional while we depend on zod 4 — the alternatives are switching the `app` package to `zod/mini` (no-codegen entry, restricted API) or downgrading to zod 3, both larger refactors. 3. packages/app/src/entry.tsx — `getCurrentUrl` honors the localStorage defaultServerUrl override `getCurrentUrl()` was previously hard-wired to `location.origin` (in production) for the initial `servers[0]` entry, while `getDefaultUrl()` would return the localStorage-set `defaultServerUrl` when present for the `defaultServer` key. The two disagreed: the server-context's `current` server resolves via `allServers().find(key === state.active) ?? allServers()[0]`, so if `state.active` pointed at the localStorage URL but that URL wasn't in `allServers()`, the code fell back to `allServers()[0]` — i.e. `location.origin` — and control-plane requests like `/global/config` and `/global/event` bypassed the override entirely. Having `getCurrentUrl` also honor the localStorage override keeps both entries aligned and makes the override globally effective. 4. packages/opencode/src/server/routes/ui.ts — extend `connect-src` to allow `https://opencode.ai` `packages/app/src/context/highlights.tsx` fetches the release changelog from `https://opencode.ai/changelog.json` to surface release highlights to the user. Under the previous `connect-src 'self' data:` the fetch was blocked once the SPA ran from a non-opencode.ai origin (i.e. exactly the embed case). Adding `https://opencode.ai` to `connect-src` lets the changelog feature keep working in embedded deployments and is consistent with the SPA's existing same-domain trust (the production fallback proxy and the `location.hostname.includes('opencode.ai')` logic in `entry.tsx` already treat that origin as canonical). Together these let opencode web embed inside an iframe served from a foreign origin / subpath: assets load, the SPA bundle executes, all SDK calls (including control-plane routes) honor the configured server URL, and the changelog fetch isn't blocked by CSP. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 75a22f8 commit 686a8dd

3 files changed

Lines changed: 14 additions & 2 deletions

File tree

packages/app/src/entry.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,14 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
9898
}
9999

100100
const getCurrentUrl = () => {
101+
// Honor the localStorage `defaultServerUrl` override if set so that the
102+
// initial `servers[0]` entry matches the `defaultServer` key; otherwise
103+
// `allServers().find(...)` in the server context falls back to
104+
// `allServers()[0]` and the SDK ends up calling `location.origin` for
105+
// control-plane ("/global/*") routes even when the user configured a
106+
// different server URL via localStorage.
107+
const lsDefault = readDefaultServerUrl()
108+
if (lsDefault) return lsDefault
101109
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
102110
if (import.meta.env.DEV)
103111
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`

packages/app/vite.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import desktopPlugin from "./vite"
33

44
export default defineConfig({
55
plugins: [desktopPlugin] as any,
6+
// Relative base so the built SPA can be served under any URL subpath
7+
// (iframe embedding via reverse proxy) without requiring absolute-path
8+
// asset resolution at the host origin. Also works for direct-serve at `/`.
9+
base: "./",
610
server: {
711
host: "0.0.0.0",
812
allowedHosts: true,

packages/opencode/src/server/routes/ui.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
1111
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
1212

1313
const DEFAULT_CSP =
14-
"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:"
14+
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data: https://opencode.ai"
1515

1616
const csp = (hash = "") =>
17-
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
17+
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data: https://opencode.ai`
1818

1919
export const UIRoutes = (): Hono =>
2020
new Hono().all("/*", async (c) => {

0 commit comments

Comments
 (0)