Skip to content
1 change: 1 addition & 0 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ export function AppInterface(props: {
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
base={((window as any).__OPENCODE_BASE_PATH__ || import.meta.env.BASE_URL).replace(/\/$/, "") || undefined}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
Expand Down
8 changes: 6 additions & 2 deletions packages/app/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -12,6 +13,10 @@ interface ImportMeta {
readonly env: ImportMetaEnv
}

interface Window {
__OPENCODE_BASE_PATH__?: string
}

export declare module "solid-js" {
namespace JSX {
interface Directives {
Expand Down
1 change: 1 addition & 0 deletions packages/app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}),
Expand Down
7 changes: 4 additions & 3 deletions packages/opencode/src/cli/cmd/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}`,
)
}
}
Expand All @@ -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}`,
)
}

Expand Down
20 changes: 19 additions & 1 deletion packages/opencode/src/cli/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof options>
Expand Down Expand Up @@ -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 }
}
3 changes: 3 additions & 0 deletions packages/opencode/src/config/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Server>

Expand Down
30 changes: 16 additions & 14 deletions packages/opencode/src/server/routes/instance/httpapi/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,16 +163,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
Expand All @@ -182,15 +184,15 @@ type RouteRequirements =
| HttpRouter.Request<"GlobalRequires", never>

export function createRoutes(
corsOptions?: CorsOptions,
options?: CorsOptions & { basePath?: string },
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
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,
Expand Down Expand Up @@ -236,7 +238,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),
)
Expand Down
50 changes: 44 additions & 6 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -23,6 +24,7 @@ export type Listener = {
hostname: string
port: number
url: URL
basePath: string
stop: (close?: boolean) => Promise<void>
}

Expand All @@ -36,14 +38,16 @@ type ListenOptions = CorsOptions & {
hostname: string
mdns?: boolean
mdnsDomain?: string
basePath?: string
}
type ListenerState = {
scope: Scope.Scope
server: Context.Service.Shape<typeof HttpServer.HttpServer>
http: ListenerServer
websockets: WebSocketTracker.Interface
}
type EffectListener = Omit<Listener, "stop"> & {
type EffectListener = Omit<Listener, "stop" | "basePath"> & {
basePath: string
stop: (close?: boolean) => Effect.Effect<void>
}

Expand Down Expand Up @@ -78,15 +82,17 @@ export async function listen(opts: ListenOptions): Promise<Listener> {
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<EffectListener, unknown> = 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)
Expand All @@ -95,6 +101,7 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unkno
hostname: opts.hostname,
port: address.port,
url: listenerUrl,
basePath,
stop: yield* makeStop(state, unpublishMdns),
}
},
Expand All @@ -107,7 +114,7 @@ function listenerLayer(opts: ListenOptions, port: number) {
disableListenLog: true,
}).pipe(
Layer.provideMerge(WebSocketTracker.layer),
Layer.provideMerge(serverLayer({ port, hostname: opts.hostname })),
Layer.provideMerge(serverLayer({ port, hostname: opts.hostname, basePath: opts.basePath })),
// Install a fresh `ConfigProvider` per listener so `Config.string(...)`
// reads reflect the current `process.env`. Effect's default
// `ConfigProvider` snapshots `process.env` on first read and caches the
Expand Down Expand Up @@ -148,10 +155,11 @@ function tcpAddress(state: ListenerState) {
})
}

function makeURL(hostname: string, port: number) {
function makeURL(hostname: string, port: number, basePath = "") {
const result = new URL("http://localhost")
result.hostname = hostname
result.port = String(port)
if (basePath) result.pathname = basePath
return result
}

Expand Down Expand Up @@ -188,8 +196,38 @@ function forceClose(state: ListenerState) {
return Effect.all([state.http.closeAll, state.websockets.closeAll], { concurrency: "unbounded", discard: true })
}

function serverLayer(opts: { port: number; hostname: string }) {
function serverLayer(opts: { port: number; hostname: string; basePath?: string }) {
const server = createServer()
const bp = opts.basePath ?? ""

if (bp) {
const originalEmit = server.emit.bind(server)
server.emit = ((event: string, ...args: unknown[]) => {
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
Expand Down
Loading
Loading