diff --git a/README.md b/README.md index d0ba487402f..e339580ea57 100644 --- a/README.md +++ b/README.md @@ -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? diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index d0678dc5369..4cc7835507e 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -34,6 +34,7 @@ const Loading = () =>
{ 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 ( @@ -81,6 +86,7 @@ export function AppInterface(props: { defaultUrl?: string }) { ( diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index bee2c8f711f..4bc7abbe901 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -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", @@ -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() }, diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index e63f10ba80c..b3a872e7cea 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -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({ diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 2c207ecc2f2..a230fdffe4b 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -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() @@ -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 @@ -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}`, ) } } @@ -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(() => {}) } diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index fe5731d0713..14149ccf082 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -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)", @@ -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") @@ -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 } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index bf4a6035bd8..d4c3887dc29 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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"), }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 52457515b8e..d8d3d2700bc 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -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" @@ -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 = { @@ -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( @@ -2829,7 +2849,12 @@ 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: { @@ -2837,6 +2862,39 @@ export namespace Server { 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'", @@ -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) => { diff --git a/packages/opencode/src/util/base-path.ts b/packages/opencode/src/util/base-path.ts new file mode 100644 index 00000000000..7f8c90c53cd --- /dev/null +++ b/packages/opencode/src/util/base-path.ts @@ -0,0 +1,118 @@ +/** + * Normalizes a base path to ensure consistent format: + * - Returns empty string for root path or undefined + * - Ensures leading slash + * - Removes trailing slashes + */ +export function normalizeBasePath(path?: string): string { + if (!path || path === "/") return "" + + // Ensure leading slash, remove trailing slashes + let normalized = path.startsWith("/") ? path : `/${path}` + normalized = normalized.replace(/\/+$/, "") + + return normalized +} + +/** + * Joins a base path with additional path segments. + * Handles normalization of the base path and proper joining of segments. + */ +export function joinPath(basePath: string, ...segments: string[]): string { + const base = normalizeBasePath(basePath) + const path = segments.join("/").replace(/\/+/g, "/") + return `${base}${path.startsWith("/") ? path : `/${path}`}` +} + +/** + * Generates the JavaScript snippet that wraps history.pushState/replaceState + * to automatically prepend the basePath to URLs. + */ +export function generateBasePathScript(basePath: string): string { + return `` +} + +/** + * Rewrites HTML content to include basePath in asset references. + * - Rewrites href="/...", src="/...", content="/..." to include basePath + * - Injects the basePath script before + * - Does NOT rewrite protocol-relative URLs (//...) + */ +export function rewriteHtmlForBasePath(html: string, basePath: string): string { + if (!basePath) return html + + // Rewrite absolute paths in HTML to include basePath + // Matches href="/...", src="/...", content="/..." but not href="//..." (protocol-relative) + let result = html.replace(/(href|src|content)="\/(?!\/)/g, `$1="${basePath}/`) + + // Inject basePath script before + result = result.replace("", `${generateBasePathScript(basePath)}`) + + return result +} + +/** + * Rewrites JavaScript content to work with basePath. + * - Patches window.location.origin references to include basePath + * - Patches Vite's base path function for dynamic asset loading + * + * Note: The Vite patch is fragile and depends on minified output format. + */ +export function rewriteJsForBasePath(js: string, basePath: string): string { + if (!basePath) return js + + let result = js + + // Replace the pattern where the app determines the server URL + // In minified code it can appear as either: + // :window.location.origin) (inside function call) + // :window.location.origin; (end of ternary expression) + result = result.replace( + /:window\.location\.origin([;)])/g, + `:window.location.origin+(window.__OPENCODE_BASE_PATH__||"")$1`, + ) + + // Patch Vite's base path function to use our basePath instead of "/" + // The function looks like: function(t){return"/"+t} + // This handles all dynamic asset loading + result = result.replace(/function\(t\)\{return"\/"\+t\}/g, `function(t){return"${basePath}/"+t}`) + + return result +} + +/** + * Rewrites CSS content to include basePath in url() references. + * - Rewrites url(/...) to url(/basePath/...) + * - Does NOT rewrite protocol-relative URLs (//...) + */ +export function rewriteCssForBasePath(css: string, basePath: string): string { + if (!basePath) return css + + // Rewrite url(/assets/...) to url(/basePath/assets/...) + return css.replace(/url\(\/(?!\/)/g, `url(${basePath}/`) +} diff --git a/packages/opencode/test/util/base-path.test.ts b/packages/opencode/test/util/base-path.test.ts new file mode 100644 index 00000000000..4f10218f451 --- /dev/null +++ b/packages/opencode/test/util/base-path.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test } from "bun:test" +import { + normalizeBasePath, + joinPath, + generateBasePathScript, + rewriteHtmlForBasePath, + rewriteJsForBasePath, + rewriteCssForBasePath, +} from "../../src/util/base-path" + +describe("util.base-path", () => { + describe("normalizeBasePath", () => { + test("returns empty string for root", () => { + expect(normalizeBasePath("/")).toBe("") + expect(normalizeBasePath(undefined)).toBe("") + }) + + test("returns empty string for empty string", () => { + expect(normalizeBasePath("")).toBe("") + }) + + test("normalizes paths with leading slash", () => { + expect(normalizeBasePath("/prefix")).toBe("/prefix") + expect(normalizeBasePath("/notebook/namespace/name")).toBe("/notebook/namespace/name") + }) + + test("removes trailing slash", () => { + expect(normalizeBasePath("/prefix/")).toBe("/prefix") + expect(normalizeBasePath("/notebook/namespace/name/")).toBe("/notebook/namespace/name") + }) + + test("adds leading slash if missing", () => { + expect(normalizeBasePath("prefix")).toBe("/prefix") + expect(normalizeBasePath("notebook/namespace/name")).toBe("/notebook/namespace/name") + }) + + test("handles path without leading slash and with trailing slash", () => { + expect(normalizeBasePath("prefix/")).toBe("/prefix") + expect(normalizeBasePath("notebook/namespace/name/")).toBe("/notebook/namespace/name") + }) + + test("handles multiple trailing slashes", () => { + expect(normalizeBasePath("/prefix///")).toBe("/prefix") + }) + }) + + describe("joinPath", () => { + test("joins base path with segments", () => { + expect(joinPath("/prefix", "api", "v1")).toBe("/prefix/api/v1") + expect(joinPath("/prefix", "/api", "/v1")).toBe("/prefix/api/v1") + }) + + test("handles empty base path", () => { + expect(joinPath("/", "api", "v1")).toBe("/api/v1") + expect(joinPath("", "api", "v1")).toBe("/api/v1") + }) + + test("normalizes multiple slashes", () => { + expect(joinPath("/prefix", "//api//", "//v1")).toBe("/prefix/api/v1") + }) + + test("handles segments with leading slashes", () => { + expect(joinPath("/prefix", "/session")).toBe("/prefix/session") + }) + + test("handles trailing slash on base path", () => { + expect(joinPath("/prefix/", "api")).toBe("/prefix/api") + }) + }) + + describe("generateBasePathScript", () => { + test("generates script with basePath variable", () => { + const script = generateBasePathScript("/myapp") + expect(script).toContain('window.__OPENCODE_BASE_PATH__="/myapp"') + expect(script).toContain("") + }) + + test("includes history.pushState wrapper", () => { + const script = generateBasePathScript("/myapp") + expect(script).toContain("history.pushState") + expect(script).toContain("origPushState") + }) + + test("includes history.replaceState wrapper", () => { + const script = generateBasePathScript("/myapp") + expect(script).toContain("history.replaceState") + expect(script).toContain("origReplaceState") + }) + }) + + describe("rewriteHtmlForBasePath", () => { + test("returns unchanged HTML when basePath is empty", () => { + const html = 'Link' + expect(rewriteHtmlForBasePath(html, "")).toBe(html) + }) + + test("rewrites href attributes with absolute paths", () => { + const html = 'Link' + const result = rewriteHtmlForBasePath(html, "/myapp") + expect(result).toContain('href="/myapp/page"') + }) + + test("rewrites src attributes with absolute paths", () => { + const html = '' + const result = rewriteHtmlForBasePath(html, "/myapp") + expect(result).toContain('src="/myapp/assets/main.js"') + }) + + test("rewrites content attributes with absolute paths", () => { + const html = '' + const result = rewriteHtmlForBasePath(html, "/myapp") + expect(result).toContain('content="/myapp/image.png"') + }) + + test("does NOT rewrite protocol-relative URLs", () => { + const html = 'Link' + const result = rewriteHtmlForBasePath(html, "/myapp") + expect(result).toContain('href="//cdn.example.com/file.js"') + }) + + test("does NOT rewrite relative paths without leading slash", () => { + const html = 'Link' + const result = rewriteHtmlForBasePath(html, "/myapp") + expect(result).toContain('href="page.html"') + }) + + test("injects basePath script before ", () => { + const html = "Test" + const result = rewriteHtmlForBasePath(html, "/myapp") + expect(result).toContain('window.__OPENCODE_BASE_PATH__="/myapp"') + expect(result).toContain("") + }) + + test("handles multiple attributes in same HTML", () => { + const html = '' + const result = rewriteHtmlForBasePath(html, "/prefix") + expect(result).toContain('href="/prefix/style.css"') + expect(result).toContain('src="/prefix/app.js"') + }) + }) + + describe("rewriteJsForBasePath", () => { + test("returns unchanged JS when basePath is empty", () => { + const js = "const url = window.location.origin)" + expect(rewriteJsForBasePath(js, "")).toBe(js) + }) + + test("patches window.location.origin with closing paren", () => { + const js = "const url = :window.location.origin)" + const result = rewriteJsForBasePath(js, "/myapp") + expect(result).toContain(':window.location.origin+(window.__OPENCODE_BASE_PATH__||""))') + }) + + test("patches window.location.origin with semicolon (ternary ending)", () => { + const js = 'location.hostname.includes("opencode.ai")?"http://localhost:4096":window.location.origin;return' + const result = rewriteJsForBasePath(js, "/myapp") + expect(result).toContain(':window.location.origin+(window.__OPENCODE_BASE_PATH__||"");return') + }) + + test("patches Vite base path function", () => { + const js = 'function(t){return"/"+t}' + const result = rewriteJsForBasePath(js, "/myapp") + expect(result).toBe('function(t){return"/myapp/"+t}') + }) + + test("handles multiple Vite function occurrences", () => { + const js = 'function(t){return"/"+t};function(t){return"/"+t}' + const result = rewriteJsForBasePath(js, "/app") + expect(result).toBe('function(t){return"/app/"+t};function(t){return"/app/"+t}') + }) + + test("does NOT modify unrelated code", () => { + const js = 'const x = 1; function foo() { return "hello"; }' + const result = rewriteJsForBasePath(js, "/myapp") + expect(result).toBe(js) + }) + }) + + describe("rewriteCssForBasePath", () => { + test("returns unchanged CSS when basePath is empty", () => { + const css = "background: url(/assets/bg.png);" + expect(rewriteCssForBasePath(css, "")).toBe(css) + }) + + test("rewrites url() with absolute paths", () => { + const css = "background: url(/assets/image.png);" + const result = rewriteCssForBasePath(css, "/myapp") + expect(result).toBe("background: url(/myapp/assets/image.png);") + }) + + test("rewrites multiple url() occurrences", () => { + const css = "@font-face { src: url(/fonts/a.woff); } .bg { background: url(/img/bg.png); }" + const result = rewriteCssForBasePath(css, "/prefix") + expect(result).toContain("url(/prefix/fonts/a.woff)") + expect(result).toContain("url(/prefix/img/bg.png)") + }) + + test("does NOT rewrite protocol-relative URLs", () => { + const css = "background: url(//cdn.example.com/image.png);" + const result = rewriteCssForBasePath(css, "/myapp") + expect(result).toBe("background: url(//cdn.example.com/image.png);") + }) + + test("does NOT rewrite relative paths", () => { + const css = "background: url(assets/image.png);" + const result = rewriteCssForBasePath(css, "/myapp") + expect(result).toBe("background: url(assets/image.png);") + }) + + test("does NOT rewrite data URIs", () => { + const css = "background: url(data:image/png;base64,abc123);" + const result = rewriteCssForBasePath(css, "/myapp") + expect(result).toBe("background: url(data:image/png;base64,abc123);") + }) + }) +})