Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,27 @@ If you're interested in contributing to OpenCode, please read our [contributing

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.

### Running Behind a Reverse Proxy

OpenCode supports running behind a reverse proxy with a base path prefix:

```bash
# CLI flag
opencode web --base-path /my-prefix/

# Environment variable
OPENCODE_BASE_PATH=/my-prefix/ opencode web

# Config file (opencode.json)
{
"server": {
"basePath": "/my-prefix/"
}
}
```

This is useful for deploying behind a reverse proxy with path-based routing (e.g., Kubernetes Ingress, nginx, traefik).

### FAQ

#### How is this different from Claude Code?
Expand Down
8 changes: 7 additions & 1 deletion packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string }
__OPENCODE_BASE_PATH__?: string
}
}

Expand Down Expand Up @@ -66,13 +67,17 @@ function ServerKey(props: ParentProps) {
}

export function AppInterface(props: { defaultUrl?: string }) {
const basePath = window.__OPENCODE_BASE_PATH__ || ""

const defaultServerUrl = () => {
if (props.defaultUrl) return props.defaultUrl
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 window.location.origin
// Support for reverse proxy with base path
// The server injects window.__OPENCODE_BASE_PATH__ when serving under a base path
return window.location.origin + basePath
}

return (
Expand All @@ -81,6 +86,7 @@ export function AppInterface(props: { defaultUrl?: string }) {
<GlobalSDKProvider>
<GlobalSyncProvider>
<Router
base={basePath}
root={(props) => (
<PermissionProvider>
<LayoutProvider>
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode/src/cli/cmd/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Server } from "../../server/server"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "../../flag/flag"
import { normalizeBasePath } from "../../util/base-path"

export const ServeCommand = cmd({
command: "serve",
Expand All @@ -13,7 +14,9 @@ export const ServeCommand = cmd({
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
const basePath = normalizeBasePath(opts.basePath)
const pathSuffix = basePath ? `${basePath}/` : ""
console.log(`opencode server listening on http://${server.hostname}:${server.port}${pathSuffix}`)
await new Promise(() => {})
await server.stop()
},
Expand Down
13 changes: 10 additions & 3 deletions packages/opencode/src/cli/cmd/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,17 @@ export const rpc = {
body,
}
},
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[]; basePath?: string }) {
if (server) await server.stop(true)
server = Server.listen(input)
return { url: server.url.toString() }
try {
server = Server.listen(input)
return {
url: Server.url().toString(),
}
} catch (e) {
console.error(e)
throw e
}
},
async checkUpgrade(input: { directory: string }) {
await Instance.provide({
Expand Down
17 changes: 14 additions & 3 deletions packages/opencode/src/cli/cmd/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "../../flag/flag"
import open from "open"
import { networkInterfaces } from "os"
import { normalizeBasePath } from "../../util/base-path"

function getNetworkIPs() {
const nets = networkInterfaces()
Expand Down Expand Up @@ -38,13 +39,16 @@ export const WebCommand = cmd({
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const basePath = normalizeBasePath(opts.basePath)
const pathSuffix = basePath ? `${basePath}/` : ""

UI.empty()
UI.println(UI.logo(" "))
UI.empty()

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

// Show network IPs for remote access
Expand All @@ -54,7 +58,7 @@ export const WebCommand = cmd({
UI.println(
UI.Style.TEXT_INFO_BOLD + " Network access: ",
UI.Style.TEXT_NORMAL,
`http://${ip}:${server.port}`,
`http://${ip}:${server.port}${pathSuffix}`,
)
}
}
Expand All @@ -63,11 +67,18 @@ export const WebCommand = cmd({
UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local")
}

if (basePath) {
UI.println(UI.Style.TEXT_INFO_BOLD + " Base path: ", UI.Style.TEXT_NORMAL, basePath)
}

// Open localhost in browser
open(localhostUrl.toString()).catch(() => {})
} else {
const displayUrl = server.url.toString()
const displayUrl = Server.url().toString()
UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl)
if (basePath) {
UI.println(UI.Style.TEXT_INFO_BOLD + " Base path: ", UI.Style.TEXT_NORMAL, basePath)
}
open(displayUrl).catch(() => {})
}

Expand Down
16 changes: 15 additions & 1 deletion packages/opencode/src/cli/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ const options = {
describe: "hostname to listen on",
default: "127.0.0.1",
},
"base-path": {
type: "string" as const,
describe: "base path prefix for all routes (e.g., /my-prefix/)",
default: "/",
},
mdns: {
type: "boolean" as const,
describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
Expand All @@ -35,6 +40,7 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const config = await Config.global()
const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname")
const basePathExplicitlySet = process.argv.includes("--base-path")
const mdnsExplicitlySet = process.argv.includes("--mdns")
const corsExplicitlySet = process.argv.includes("--cors")

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

return { hostname, port, mdns, cors }
// Resolve base path: CLI arg > env var > config > default
const envBasePath = process.env.OPENCODE_BASE_PATH
const basePath = basePathExplicitlySet
? args["base-path"]
: envBasePath
? envBasePath
: (config?.server?.basePath ?? args["base-path"])

return { hostname, port, mdns, cors, basePath }
}
4 changes: 4 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,10 @@ export namespace Config {
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
hostname: z.string().optional().describe("Hostname to listen on"),
basePath: z
.string()
.optional()
.describe("Base path prefix for all routes (e.g., /my-prefix/)"),
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
})
Expand Down
92 changes: 86 additions & 6 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Log } from "../util/log"
import { rewriteHtmlForBasePath, rewriteJsForBasePath, rewriteCssForBasePath } from "../util/base-path"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono"
import { cors } from "hono/cors"
Expand Down Expand Up @@ -63,10 +64,19 @@ export namespace Server {
const log = Log.create({ service: "server" })

let _url: URL | undefined
let _basePath: string = ""
let _corsWhitelist: string[] = []

export function url(): URL {
return _url ?? new URL("http://localhost:4096")
const base = _url ?? new URL("http://localhost:4096")
if (_basePath) {
return new URL(_basePath + "/", base)
}
return base
}

export function basePath(): string {
return _basePath
}

export const Event = {
Expand Down Expand Up @@ -152,14 +162,24 @@ export namespace Server {
description: "Health information",
content: {
"application/json": {
schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })),
schema: resolver(
z.object({
healthy: z.literal(true),
version: z.string(),
basePath: z.string().optional(),
}),
),
},
},
},
},
}),
async (c) => {
return c.json({ healthy: true, version: Installation.VERSION })
return c.json({
healthy: true,
version: Installation.VERSION,
basePath: _basePath || undefined,
})
},
)
.get(
Expand Down Expand Up @@ -2829,14 +2849,52 @@ export namespace Server {
},
)
.all("/*", async (c) => {
const path = c.req.path
// Strip basePath from the request path before proxying
let path = c.req.path
if (_basePath && path.startsWith(_basePath)) {
path = path.slice(_basePath.length) || "/"
}

const response = await proxy(`https://app.opencode.ai${path}`, {
...c.req,
headers: {
...c.req.raw.headers,
host: "app.opencode.ai",
},
})

// Rewrite content for basePath support
const contentType = response.headers.get("content-type") || ""

if (_basePath && contentType.includes("text/html")) {
const html = rewriteHtmlForBasePath(await response.text(), _basePath)
return new Response(html, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
})
}

if (_basePath && (contentType.includes("javascript") || path.endsWith(".js"))) {
const js = rewriteJsForBasePath(await response.text(), _basePath)
return new Response(js, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
})
}

if (_basePath && (contentType.includes("text/css") || path.endsWith(".css"))) {
const css = rewriteCssForBasePath(await response.text(), _basePath)
return new Response(css, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
})
}

// Set CSP header only when not rewriting content (no basePath)
// When basePath is set, we inject inline scripts which would violate CSP
response.headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'",
Expand All @@ -2860,13 +2918,35 @@ export namespace Server {
return result
}

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

// Normalize basePath: ensure leading slash, remove trailing slash
const rawBasePath = opts.basePath ?? "/"
_basePath =
rawBasePath === "/"
? ""
: (rawBasePath.startsWith("/") ? rawBasePath : `/${rawBasePath}`).replace(/\/+$/, "")

// Create wrapper app for base path routing
const baseApp = new Hono()
const mainApp = App()

if (_basePath) {
// Mount the main app under the base path
baseApp.route(_basePath, mainApp)

// Also mount at root level to support reverse proxies that strip the basePath
// before forwarding requests (e.g., some Kubernetes ingress configurations)
baseApp.route("/", mainApp)
}

const appToServe = _basePath ? baseApp : mainApp

const args = {
hostname: opts.hostname,
idleTimeout: 0,
fetch: App().fetch,
fetch: appToServe.fetch,
websocket: websocket,
} as const
const tryServe = (port: number) => {
Expand Down
Loading