Skip to content

Commit 8dcbd06

Browse files
committed
Merge PR anomalyco#7625: feat: base path support
Add support for running OpenCode behind a reverse proxy with a configurable base path prefix (e.g., /myapp/). - Adds --base-path CLI option, OPENCODE_BASE_PATH env var, and server.basePath config - Rewrites HTML/JS/CSS responses at runtime to include the base path - Wraps history.pushState/replaceState to prepend base path to URLs
2 parents 2ccaa10 + e393b1f commit 8dcbd06

File tree

10 files changed

+496
-15
lines changed

10 files changed

+496
-15
lines changed

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,27 @@ If you're interested in contributing to OpenCode, please read our [contributing
9696

9797
If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
9898

99+
### Running Behind a Reverse Proxy
100+
101+
OpenCode supports running behind a reverse proxy with a base path prefix:
102+
103+
```bash
104+
# CLI flag
105+
opencode web --base-path /my-prefix/
106+
107+
# Environment variable
108+
OPENCODE_BASE_PATH=/my-prefix/ opencode web
109+
110+
# Config file (opencode.json)
111+
{
112+
"server": {
113+
"basePath": "/my-prefix/"
114+
}
115+
}
116+
```
117+
118+
This is useful for deploying behind a reverse proxy with path-based routing (e.g., Kubernetes Ingress, nginx, traefik).
119+
99120
### FAQ
100121

101122
#### How is this different from Claude Code?

packages/app/src/app.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
3434
declare global {
3535
interface Window {
3636
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string }
37+
__OPENCODE_BASE_PATH__?: string
3738
}
3839
}
3940

@@ -66,13 +67,17 @@ function ServerKey(props: ParentProps) {
6667
}
6768

6869
export function AppInterface(props: { defaultUrl?: string }) {
70+
const basePath = window.__OPENCODE_BASE_PATH__ || ""
71+
6972
const defaultServerUrl = () => {
7073
if (props.defaultUrl) return props.defaultUrl
7174
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
7275
if (import.meta.env.DEV)
7376
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
7477

75-
return window.location.origin
78+
// Support for reverse proxy with base path
79+
// The server injects window.__OPENCODE_BASE_PATH__ when serving under a base path
80+
return window.location.origin + basePath
7681
}
7782

7883
return (
@@ -81,6 +86,7 @@ export function AppInterface(props: { defaultUrl?: string }) {
8186
<GlobalSDKProvider>
8287
<GlobalSyncProvider>
8388
<Router
89+
base={basePath}
8490
root={(props) => (
8591
<PermissionProvider>
8692
<LayoutProvider>

packages/opencode/src/cli/cmd/serve.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Server } from "../../server/server"
22
import { cmd } from "./cmd"
33
import { withNetworkOptions, resolveNetworkOptions } from "../network"
44
import { Flag } from "../../flag/flag"
5+
import { normalizeBasePath } from "../../util/base-path"
56

67
export const ServeCommand = cmd({
78
command: "serve",
@@ -13,7 +14,9 @@ export const ServeCommand = cmd({
1314
}
1415
const opts = await resolveNetworkOptions(args)
1516
const server = Server.listen(opts)
16-
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
17+
const basePath = normalizeBasePath(opts.basePath)
18+
const pathSuffix = basePath ? `${basePath}/` : ""
19+
console.log(`opencode server listening on http://${server.hostname}:${server.port}${pathSuffix}`)
1720
await new Promise(() => {})
1821
await server.stop()
1922
},

packages/opencode/src/cli/cmd/tui/worker.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,17 @@ export const rpc = {
116116
body,
117117
}
118118
},
119-
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
119+
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[]; basePath?: string }) {
120120
if (server) await server.stop(true)
121-
server = Server.listen(input)
122-
return { url: server.url.toString() }
121+
try {
122+
server = Server.listen(input)
123+
return {
124+
url: Server.url().toString(),
125+
}
126+
} catch (e) {
127+
console.error(e)
128+
throw e
129+
}
123130
},
124131
async checkUpgrade(input: { directory: string }) {
125132
await Instance.provide({

packages/opencode/src/cli/cmd/web.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network"
55
import { Flag } from "../../flag/flag"
66
import open from "open"
77
import { networkInterfaces } from "os"
8+
import { normalizeBasePath } from "../../util/base-path"
89

910
function getNetworkIPs() {
1011
const nets = networkInterfaces()
@@ -38,13 +39,16 @@ export const WebCommand = cmd({
3839
}
3940
const opts = await resolveNetworkOptions(args)
4041
const server = Server.listen(opts)
42+
const basePath = normalizeBasePath(opts.basePath)
43+
const pathSuffix = basePath ? `${basePath}/` : ""
44+
4145
UI.empty()
4246
UI.println(UI.logo(" "))
4347
UI.empty()
4448

4549
if (opts.hostname === "0.0.0.0") {
4650
// Show localhost for local access
47-
const localhostUrl = `http://localhost:${server.port}`
51+
const localhostUrl = `http://localhost:${server.port}${pathSuffix}`
4852
UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl)
4953

5054
// Show network IPs for remote access
@@ -54,7 +58,7 @@ export const WebCommand = cmd({
5458
UI.println(
5559
UI.Style.TEXT_INFO_BOLD + " Network access: ",
5660
UI.Style.TEXT_NORMAL,
57-
`http://${ip}:${server.port}`,
61+
`http://${ip}:${server.port}${pathSuffix}`,
5862
)
5963
}
6064
}
@@ -63,11 +67,18 @@ export const WebCommand = cmd({
6367
UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local")
6468
}
6569

70+
if (basePath) {
71+
UI.println(UI.Style.TEXT_INFO_BOLD + " Base path: ", UI.Style.TEXT_NORMAL, basePath)
72+
}
73+
6674
// Open localhost in browser
6775
open(localhostUrl.toString()).catch(() => {})
6876
} else {
69-
const displayUrl = server.url.toString()
77+
const displayUrl = Server.url().toString()
7078
UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl)
79+
if (basePath) {
80+
UI.println(UI.Style.TEXT_INFO_BOLD + " Base path: ", UI.Style.TEXT_NORMAL, basePath)
81+
}
7182
open(displayUrl).catch(() => {})
7283
}
7384

packages/opencode/src/cli/network.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ const options = {
1212
describe: "hostname to listen on",
1313
default: "127.0.0.1",
1414
},
15+
"base-path": {
16+
type: "string" as const,
17+
describe: "base path prefix for all routes (e.g., /my-prefix/)",
18+
default: "/",
19+
},
1520
mdns: {
1621
type: "boolean" as const,
1722
describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
@@ -35,6 +40,7 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
3540
const config = await Config.global()
3641
const portExplicitlySet = process.argv.includes("--port")
3742
const hostnameExplicitlySet = process.argv.includes("--hostname")
43+
const basePathExplicitlySet = process.argv.includes("--base-path")
3844
const mdnsExplicitlySet = process.argv.includes("--mdns")
3945
const corsExplicitlySet = process.argv.includes("--cors")
4046

@@ -49,5 +55,13 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
4955
const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
5056
const cors = [...configCors, ...argsCors]
5157

52-
return { hostname, port, mdns, cors }
58+
// Resolve base path: CLI arg > env var > config > default
59+
const envBasePath = process.env.OPENCODE_BASE_PATH
60+
const basePath = basePathExplicitlySet
61+
? args["base-path"]
62+
: envBasePath
63+
? envBasePath
64+
: (config?.server?.basePath ?? args["base-path"])
65+
66+
return { hostname, port, mdns, cors, basePath }
5367
}

packages/opencode/src/config/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,10 @@ export namespace Config {
800800
.object({
801801
port: z.number().int().positive().optional().describe("Port to listen on"),
802802
hostname: z.string().optional().describe("Hostname to listen on"),
803+
basePath: z
804+
.string()
805+
.optional()
806+
.describe("Base path prefix for all routes (e.g., /my-prefix/)"),
803807
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
804808
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
805809
})

packages/opencode/src/server/server.ts

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { BusEvent } from "@/bus/bus-event"
22
import { Bus } from "@/bus"
33
import { GlobalBus } from "@/bus/global"
44
import { Log } from "../util/log"
5+
import { rewriteHtmlForBasePath, rewriteJsForBasePath, rewriteCssForBasePath } from "../util/base-path"
56
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
67
import { Hono } from "hono"
78
import { cors } from "hono/cors"
@@ -63,10 +64,19 @@ export namespace Server {
6364
const log = Log.create({ service: "server" })
6465

6566
let _url: URL | undefined
67+
let _basePath: string = ""
6668
let _corsWhitelist: string[] = []
6769

6870
export function url(): URL {
69-
return _url ?? new URL("http://localhost:4096")
71+
const base = _url ?? new URL("http://localhost:4096")
72+
if (_basePath) {
73+
return new URL(_basePath + "/", base)
74+
}
75+
return base
76+
}
77+
78+
export function basePath(): string {
79+
return _basePath
7080
}
7181

7282
export const Event = {
@@ -152,14 +162,24 @@ export namespace Server {
152162
description: "Health information",
153163
content: {
154164
"application/json": {
155-
schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })),
165+
schema: resolver(
166+
z.object({
167+
healthy: z.literal(true),
168+
version: z.string(),
169+
basePath: z.string().optional(),
170+
}),
171+
),
156172
},
157173
},
158174
},
159175
},
160176
}),
161177
async (c) => {
162-
return c.json({ healthy: true, version: Installation.VERSION })
178+
return c.json({
179+
healthy: true,
180+
version: Installation.VERSION,
181+
basePath: _basePath || undefined,
182+
})
163183
},
164184
)
165185
.get(
@@ -2833,14 +2853,52 @@ export namespace Server {
28332853
},
28342854
)
28352855
.all("/*", async (c) => {
2836-
const path = c.req.path
2856+
// Strip basePath from the request path before proxying
2857+
let path = c.req.path
2858+
if (_basePath && path.startsWith(_basePath)) {
2859+
path = path.slice(_basePath.length) || "/"
2860+
}
2861+
28372862
const response = await proxy(`https://app.opencode.ai${path}`, {
28382863
...c.req,
28392864
headers: {
28402865
...c.req.raw.headers,
28412866
host: "app.opencode.ai",
28422867
},
28432868
})
2869+
2870+
// Rewrite content for basePath support
2871+
const contentType = response.headers.get("content-type") || ""
2872+
2873+
if (_basePath && contentType.includes("text/html")) {
2874+
const html = rewriteHtmlForBasePath(await response.text(), _basePath)
2875+
return new Response(html, {
2876+
status: response.status,
2877+
statusText: response.statusText,
2878+
headers: response.headers,
2879+
})
2880+
}
2881+
2882+
if (_basePath && (contentType.includes("javascript") || path.endsWith(".js"))) {
2883+
const js = rewriteJsForBasePath(await response.text(), _basePath)
2884+
return new Response(js, {
2885+
status: response.status,
2886+
statusText: response.statusText,
2887+
headers: response.headers,
2888+
})
2889+
}
2890+
2891+
if (_basePath && (contentType.includes("text/css") || path.endsWith(".css"))) {
2892+
const css = rewriteCssForBasePath(await response.text(), _basePath)
2893+
return new Response(css, {
2894+
status: response.status,
2895+
statusText: response.statusText,
2896+
headers: response.headers,
2897+
})
2898+
}
2899+
2900+
// Set CSP header only when not rewriting content (no basePath)
2901+
// When basePath is set, we inject inline scripts which would violate CSP
28442902
response.headers.set(
28452903
"Content-Security-Policy",
28462904
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'",
@@ -2864,13 +2922,35 @@ export namespace Server {
28642922
return result
28652923
}
28662924

2867-
export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
2925+
export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[]; basePath?: string }) {
28682926
_corsWhitelist = opts.cors ?? []
28692927

2928+
// Normalize basePath: ensure leading slash, remove trailing slash
2929+
const rawBasePath = opts.basePath ?? "/"
2930+
_basePath =
2931+
rawBasePath === "/"
2932+
? ""
2933+
: (rawBasePath.startsWith("/") ? rawBasePath : `/${rawBasePath}`).replace(/\/+$/, "")
2934+
2935+
// Create wrapper app for base path routing
2936+
const baseApp = new Hono()
2937+
const mainApp = App()
2938+
2939+
if (_basePath) {
2940+
// Mount the main app under the base path
2941+
baseApp.route(_basePath, mainApp)
2942+
2943+
// Also mount at root level to support reverse proxies that strip the basePath
2944+
// before forwarding requests (e.g., some Kubernetes ingress configurations)
2945+
baseApp.route("/", mainApp)
2946+
}
2947+
2948+
const appToServe = _basePath ? baseApp : mainApp
2949+
28702950
const args = {
28712951
hostname: opts.hostname,
28722952
idleTimeout: 0,
2873-
fetch: App().fetch,
2953+
fetch: appToServe.fetch,
28742954
websocket: websocket,
28752955
} as const
28762956
const tryServe = (port: number) => {

0 commit comments

Comments
 (0)