diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index e63f10ba80c..3b23216cc35 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -118,8 +118,9 @@ export const rpc = { }, async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { if (server) await server.stop(true) - server = Server.listen(input) - return { url: server.url.toString() } + const nextServer = Server.listen(input) + server = nextServer + return { url: nextServer.url.toString() } }, async checkUpgrade(input: { directory: string }) { await Instance.provide({ diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index fe5731d0713..8aaa8811826 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", }, + basePath: { + type: "string" as const, + describe: "base path prefix for all routes (e.g., /opencode)", + default: "", + }, mdns: { type: "boolean" as const, describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)", @@ -35,6 +40,13 @@ 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.some( + (arg) => + arg === "--base-path" || + arg.startsWith("--base-path=") || + arg === "--basePath" || + arg.startsWith("--basePath="), + ) const mdnsExplicitlySet = process.argv.includes("--mdns") const corsExplicitlySet = process.argv.includes("--cors") @@ -45,9 +57,21 @@ export async function resolveNetworkOptions(args: NetworkOptions) { : mdns && !config?.server?.hostname ? "0.0.0.0" : (config?.server?.hostname ?? args.hostname) + const basePath = normalizeBasePath(basePathExplicitlySet ? args.basePath : config?.server?.basePath) const configCors = config?.server?.cors ?? [] const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : [] const cors = [...configCors, ...argsCors] - return { hostname, port, mdns, cors } + return { hostname, port, mdns, cors, basePath } +} + +export function normalizeBasePath(path: string | undefined): string { + if (!path) return "" + let normalized = path.trim() + if (!normalized) return "" + if (!normalized.startsWith("/")) normalized = "/" + normalized + while (normalized.length > 1 && normalized.endsWith("/")) { + normalized = normalized.slice(0, -1) + } + return normalized === "/" ? "" : normalized } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ddb3af4b0a8..85730408102 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -802,6 +802,7 @@ 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., /opencode)"), 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 28dec7f4043..bec2b3c9085 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -29,17 +29,17 @@ import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" import { ExperimentalRoutes } from "./routes/experimental" import { ProviderRoutes } from "./routes/provider" -import { lazy } from "../util/lazy" import { InstanceBootstrap } from "../project/bootstrap" import { Storage } from "../storage/storage" import type { ContentfulStatusCode } from "hono/utils/http-status" -import { websocket } from "hono/bun" +import { websocket, type BunWebSocketData } from "hono/bun" import { HTTPException } from "hono/http-exception" import { errors } from "./error" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" import { MDNS } from "./mdns" +import { normalizeBasePath } from "../cli/network" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -49,9 +49,14 @@ export namespace Server { let _url: URL | undefined let _corsWhitelist: string[] = [] + let _basePath = "" export function url(): URL { - return _url ?? new URL("http://localhost:4096") + const baseUrl = _url ?? new URL("http://localhost:4096") + if (!_basePath) return baseUrl + const result = new URL(baseUrl.toString()) + result.pathname = _basePath + return result } export const Event = { @@ -59,11 +64,15 @@ export namespace Server { Disposed: BusEvent.define("global.disposed", z.object({})), } - const app = new Hono() - export const App: () => Hono = lazy( - () => + export const App = (basePath = ""): Hono => buildApp(normalizeBasePath(basePath)) + + function buildApp(basePath: string) { + const root = new Hono() + const app = basePath ? root.basePath(basePath) : root + const router = app as Hono + return ( // TODO: Break server.ts into smaller route files to fix type inference - app + router .onError((err, c) => { log.error("failed", { error: err, @@ -503,25 +512,53 @@ export namespace Server { }, ) .all("/*", async (c) => { - const path = c.req.path - const response = await proxy(`https://app.opencode.ai${path}`, { + const rawPath = c.req.path + const requestPath = + basePath && (rawPath === basePath || rawPath.startsWith(basePath + "/")) + ? rawPath.slice(basePath.length) || "/" + : rawPath + + // Fallback to proxying from app.opencode.ai + const response = await proxy(`https://app.opencode.ai${requestPath}`, { ...c.req, headers: { ...c.req.raw.headers, host: "app.opencode.ai", }, }) - 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'", - ) + const contentType = response.headers.get("content-type") ?? "" + const hasBody = response.status !== 204 && response.status !== 304 + if (basePath && hasBody && contentType.includes("text/html")) { + let html = await response.text() + const nonce = globalThis.crypto.randomUUID().replace(/-/g, "") + const script = `` + let modifiedHtml = html.replace(/
]*)>/i, `${script}`) + if (modifiedHtml === html) { + modifiedHtml = `${script}${html}` + } + const headers = new Headers(response.headers) + headers.set("Content-Security-Policy", buildCsp(nonce)) + return new Response(modifiedHtml, { status: response.status, headers }) + } + if (basePath && hasBody && contentType.includes("javascript")) { + const js = await response.text() + const rewritten = js.replace( + "S(B7,{root:", + "S(B7,{base:window.__OPENCODE_BASE_PATH__||void 0,root:", + ) + const headers = new Headers(response.headers) + headers.set("Content-Security-Policy", buildCsp()) + return new Response(rewritten, { status: response.status, headers }) + } + response.headers.set("Content-Security-Policy", buildCsp()) return response - }) as unknown as Hono, - ) + }) as unknown as Hono + ) + } export async function openapi() { // Cast to break excessive type recursion from long route chains - const result = await generateSpecs(App() as Hono, { + const result = await generateSpecs(App(_basePath) as Hono, { documentation: { info: { title: "opencode", @@ -534,23 +571,41 @@ 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 ?? [] + _basePath = normalizeBasePath(opts.basePath) - const args = { + const app = App(_basePath) + const fetch = (req: Request): Response | Promise