From 413358a5805aab2e5c4e8beca28c42e2db6a252e Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Tue, 19 May 2026 11:30:39 +0200 Subject: [PATCH 1/4] feat(server): add --base-path CLI flag and config option --- packages/opencode/src/cli/network.ts | 20 +++++++++++++++++++- packages/opencode/src/config/server.ts | 3 +++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index 41f8184ef5d7..d50ef1cb4cad 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -29,6 +29,20 @@ const options = { describe: "additional domains to allow for CORS", default: [] as string[], }, + "base-path": { + type: "string" as const, + describe: "base URL path prefix for reverse proxy (e.g., /opencode)", + default: "", + }, +} + +function normalizeBasePath(input: string | undefined): string { + if (!input) return "" + let p = input.trim() + if (!p || p === "/") return "" + if (!p.startsWith("/")) p = "/" + p + p = p.replace(/\/+$/, "") + return p } export type NetworkOptions = InferredOptionTypes @@ -57,6 +71,10 @@ export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Con const configCors = config?.server?.cors ?? [] const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : [] const cors = [...configCors, ...argsCors] + const basePathExplicitlySet = process.argv.includes("--base-path") + const basePath = normalizeBasePath( + basePathExplicitlySet ? args["base-path"] : (config?.server?.basePath ?? args["base-path"]), + ) - return { hostname, port, mdns, mdnsDomain, cors } + return { hostname, port, mdns, mdnsDomain, cors, basePath } } diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts index 642adbe51dcb..6617cbdab8f4 100644 --- a/packages/opencode/src/config/server.ts +++ b/packages/opencode/src/config/server.ts @@ -13,6 +13,9 @@ export const Server = Schema.Struct({ cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional domains to allow for CORS", }), + basePath: Schema.optional(Schema.String).annotate({ + description: "Base path prefix for hosting behind a reverse proxy (e.g., '/opencode')", + }), }).annotate({ identifier: "ServerConfig" }) export type Server = Schema.Schema.Type From 11e0cca9ebbbc169abd2f240bc1062ed0f6f13c9 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Tue, 19 May 2026 11:30:46 +0200 Subject: [PATCH 2/4] feat(server): base path URL rewriting and prefix stripping --- packages/opencode/src/cli/cmd/serve.ts | 2 +- packages/opencode/src/cli/cmd/web.ts | 7 +-- .../server/routes/instance/httpapi/server.ts | 30 +++++------ packages/opencode/src/server/server.ts | 50 ++++++++++++++++--- 4 files changed, 65 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 76f6276af5da..b667627507d1 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -17,7 +17,7 @@ export const ServeCommand = effectCmd({ } const opts = yield* resolveNetworkOptions(args) const server = yield* Effect.promise(() => Server.listen(opts)) - console.log(`opencode server listening on http://${server.hostname}:${server.port}`) + console.log(`opencode server listening on http://${server.hostname}:${server.port}${server.basePath}`) yield* Effect.never }), diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 384290c6ac39..75082c0c1e6c 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -46,9 +46,10 @@ export const WebCommand = effectCmd({ UI.println(UI.logo(" ")) UI.empty() + const suffix = server.basePath || "" if (opts.hostname === "0.0.0.0") { // Show localhost for local access - const localhostUrl = `http://localhost:${server.port}` + const localhostUrl = `http://localhost:${server.port}${suffix}` UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl) // Show network IPs for remote access @@ -58,7 +59,7 @@ export const WebCommand = effectCmd({ UI.println( UI.Style.TEXT_INFO_BOLD + " Network access: ", UI.Style.TEXT_NORMAL, - `http://${ip}:${server.port}`, + `http://${ip}:${server.port}${suffix}`, ) } } @@ -67,7 +68,7 @@ export const WebCommand = effectCmd({ UI.println( UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, - `${opts.mdnsDomain}:${server.port}`, + `${opts.mdnsDomain}:${server.port}${suffix}`, ) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 94301bd8a4f1..05bf3f0b25f7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -161,16 +161,18 @@ const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effe Layer.provide(authOnlyRouterLayer), ) -const uiRoute = HttpRouter.use((router) => - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const client = yield* HttpClient.HttpClient - const flags = yield* RuntimeFlags.Service - yield* router.add("*", "/*", (request) => - serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }), - ) - }), -).pipe(Layer.provide(authOnlyRouterLayer)) +function uiRouteWithBasePath(basePath?: string) { + return HttpRouter.use((router) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const client = yield* HttpClient.HttpClient + const flags = yield* RuntimeFlags.Service + yield* router.add("*", "/*", (request) => + serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi, basePath }), + ) + }), + ).pipe(Layer.provide(authOnlyRouterLayer)) +} type RouteRequirements = | HttpRouter.HttpRouter @@ -180,15 +182,15 @@ type RouteRequirements = | HttpRouter.Request<"GlobalRequires", never> export function createRoutes( - corsOptions?: CorsOptions, + options?: CorsOptions & { basePath?: string }, ): Layer.Layer { - return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe( + return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRouteWithBasePath(options?.basePath)).pipe( Layer.provide([ errorLayer, compressionLayer, corsVaryFix, fenceLayer, - cors(corsOptions), + cors(options), Account.defaultLayer, Agent.defaultLayer, Auth.defaultLayer, @@ -234,7 +236,7 @@ export function createRoutes( FetchHttpClient.layer, HttpServer.layerServices, ]), - Layer.provide(Layer.succeed(CorsConfig)(corsOptions)), + Layer.provide(Layer.succeed(CorsConfig)(options)), Layer.provide(InstanceLayer.layer), Layer.provide(Observability.layer), ) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 9a448b78d626..5af7640069ad 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -5,7 +5,8 @@ import * as Log from "@opencode-ai/core/util/log" import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" -import { createServer } from "node:http" +import { createServer, type IncomingMessage, type ServerResponse } from "node:http" +import type { Duplex } from "node:stream" import { MDNS } from "./mdns" import { HttpApiApp } from "./routes/instance/httpapi/server" import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" @@ -23,6 +24,7 @@ export type Listener = { hostname: string port: number url: URL + basePath: string stop: (close?: boolean) => Promise } @@ -36,6 +38,7 @@ type ListenOptions = CorsOptions & { hostname: string mdns?: boolean mdnsDomain?: string + basePath?: string } type ListenerState = { scope: Scope.Scope @@ -43,7 +46,8 @@ type ListenerState = { http: ListenerServer websockets: WebSocketTracker.Interface } -type EffectListener = Omit & { +type EffectListener = Omit & { + basePath: string stop: (close?: boolean) => Effect.Effect } @@ -78,15 +82,17 @@ export async function listen(opts: ListenOptions): Promise { hostname: listener.hostname, port: listener.port, url: listener.url, + basePath: listener.basePath, stop: (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined), } } const listenEffect: (opts: ListenOptions) => Effect.Effect = Effect.fn("Server.listen")( function* (opts: ListenOptions) { + const basePath = opts.basePath ?? "" const state = yield* startWithPortFallback(opts) const address = yield* tcpAddress(state) - const listenerUrl = makeURL(opts.hostname, address.port) + const listenerUrl = makeURL(opts.hostname, address.port, basePath) url = listenerUrl const unpublishMdns = yield* setupMdns(opts, address.port, state.scope) @@ -95,6 +101,7 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect { + if (event === "request" || event === "upgrade") { + const req = args[0] as IncomingMessage + if (req.url === bp && event === "request") { + const res = args[1] as ServerResponse + res.writeHead(301, { Location: bp + "/" }) + res.end() + return true + } else if (req.url && req.url.startsWith(bp + "/")) { + req.url = req.url.slice(bp.length) || "/" + } else { + if (event === "request") { + const res = args[1] as ServerResponse + res.writeHead(404, { "Content-Type": "text/plain" }) + res.end("Not Found") + } else { + const socket = args[1] as Duplex + socket.destroy() + } + return true + } + } + return originalEmit(event, ...args) + }) as typeof server.emit + } + const serverRef = { closeStarted: false, forceStop: false } const close = server.close.bind(server) // Keep shutdown owned by NodeHttpServer, but honor listener.stop(true) by From 3c4e4611b92c13a47e86ea2055c989e6a746b671 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Tue, 19 May 2026 11:30:54 +0200 Subject: [PATCH 3/4] feat(server): runtime HTML injection and CSP for base path --- packages/opencode/src/server/shared/ui.ts | 45 +++++++++++++++++------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index fd4c73188063..c551c36a5c80 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -8,17 +8,24 @@ let embeddedUIPromise: Promise | null> | undefined export const UI_UPSTREAM = new URL("https://app.opencode.ai") -export const csp = (hash = "") => - `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 * data:` +export const csp = (...hashes: string[]) => { + const hashParts = hashes.filter(Boolean).map((h) => ` 'sha256-${h}'`).join("") + return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hashParts}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src * data:` +} export const DEFAULT_CSP = csp() -export function themePreloadHash(body: string) { - return body.match(/]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i) +function inlineScriptHashes(body: string): string[] { + const hashes: string[] = [] + const re = /]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/gi + let m + while ((m = re.exec(body))) { + if (m[1].trim()) hashes.push(createHash("sha256").update(m[1]).digest("base64")) + } + return hashes } export function cspForHtml(body: string) { - const match = themePreloadHash(body) - return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "") + return csp(...inlineScriptHashes(body)) } function requestBody(request: HttpServerRequest.HttpServerRequest) { @@ -48,15 +55,26 @@ export function embeddedUI(disableEmbeddedWebUi: boolean) { import("opencode-web-ui.gen.ts").then((module) => module.default as Record).catch(() => null)) } +export function injectBasePath(html: string, basePath: string): string { + if (!basePath) return html + const script = `` + html = html.replace("", `\n${script}`) + html = html.replace(/((?:href|src|content)=["'])\/(?!\/)/g, `$1${basePath}/`) + return html +} + function notFound() { return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) } -function embeddedUIResponse(file: string, body: Uint8Array) { +function embeddedUIResponse(file: string, body: Uint8Array, basePath = "") { const mime = AppFileSystem.mimeType(file) const headers = new Headers({ "content-type": mime }) if (mime.startsWith("text/html")) { - headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body))) + let html = new TextDecoder().decode(body) + if (basePath) html = injectBasePath(html, basePath) + headers.set("content-security-policy", cspForHtml(html)) + return HttpServerResponse.text(html, { headers }) } return HttpServerResponse.raw(body, { headers }) } @@ -65,25 +83,27 @@ export function serveEmbeddedUIEffect( requestPath: string, fs: AppFileSystem.Interface, embeddedWebUI: Record, + basePath = "", ) { const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null if (!file) return Effect.succeed(notFound()) return fs.readFile(file).pipe( - Effect.map((body) => embeddedUIResponse(file, body)), + Effect.map((body) => embeddedUIResponse(file, body, basePath)), Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())), ) } export function serveUIEffect( request: HttpServerRequest.HttpServerRequest, - services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient; disableEmbeddedWebUi: boolean }, + services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient; disableEmbeddedWebUi: boolean; basePath?: string }, ) { return Effect.gen(function* () { const embeddedWebUI = yield* Effect.promise(() => embeddedUI(services.disableEmbeddedWebUi)) const path = new URL(request.url, "http://localhost").pathname + const bp = services.basePath ?? "" - if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI) + if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI, bp) const response = yield* services.client.execute( HttpClientRequest.make(request.method)(upstreamURL(path), { @@ -94,7 +114,8 @@ export function serveUIEffect( const headers = proxyResponseHeaders(response.headers) if (response.headers["content-type"]?.includes("text/html")) { - const body = yield* response.text + let body = yield* response.text + if (bp) body = injectBasePath(body, bp) headers.set("Content-Security-Policy", cspForHtml(body)) return HttpServerResponse.text(body, { status: response.status, headers }) } From 9884f8d398811cb1b84d3c9a5de7f068cd20de15 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Tue, 19 May 2026 11:31:01 +0200 Subject: [PATCH 4/4] feat(app): base path support for router and API URLs --- packages/app/src/app.tsx | 1 + packages/app/src/entry.tsx | 8 ++++++-- packages/app/src/env.d.ts | 5 +++++ packages/app/vite.config.ts | 1 + 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 3189d80257df..43e3f00c02a9 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -313,6 +313,7 @@ export function AppInterface(props: { {routerProps.children}} + base={((window as any).__OPENCODE_BASE_PATH__ || import.meta.env.BASE_URL).replace(/\/$/, "") || undefined} > diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 5115f0348ad4..8c81a417b10a 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -100,10 +100,14 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) { } const getCurrentUrl = () => { + const runtimeBasePath = (window as any).__OPENCODE_BASE_PATH__ ?? "" + let serverBaseUrl = runtimeBasePath || (import.meta.env.VITE_OPENCODE_SERVER_BASE_URL ?? "") + serverBaseUrl = ("/" + serverBaseUrl.replace(/^\//, "")).replace(/\/$/, "") + if (serverBaseUrl === "/") serverBaseUrl = "" if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" if (import.meta.env.DEV) - return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` - return location.origin + return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}${serverBaseUrl}` + return location.origin + serverBaseUrl } const getDefaultUrl = () => { diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index 39f827eb64a9..8545a758420b 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -1,4 +1,5 @@ interface ImportMetaEnv { + readonly BASE_URL: string readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_PORT: string readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod" @@ -12,6 +13,10 @@ interface ImportMeta { readonly env: ImportMetaEnv } +interface Window { + __OPENCODE_BASE_PATH__?: string +} + export declare module "solid-js" { namespace JSX { interface Directives { diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index 8df324ddc916..1cd118fb5667 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -20,6 +20,7 @@ const sentry = : false export default defineConfig({ + base: process.env.VITE_BASE_URL || "./", plugins: [desktopPlugin, sentry] as any, server: { host: "0.0.0.0",