Skip to content
Closed
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
11 changes: 6 additions & 5 deletions packages/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,19 @@
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
/>
<title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
<link rel="shortcut icon" href="/favicon-v3.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="icon" type="image/png" href="favicon-96x96-v3.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="favicon-v3.svg" />
<link rel="shortcut icon" href="favicon-v3.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon-v3.png" />
<link rel="manifest" href="site.webmanifest" />
<meta name="theme-color" content="#fafafa" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
<script id="oc-base-path" type="application/json">/</script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
<noscript>You need to enable JavaScript to run this app.</noscript>
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ export function AppInterface(props: {
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
basePath?: string
}) {
// The visual new layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
Expand All @@ -492,6 +493,7 @@ export function AppInterface(props: {
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
<Dynamic
component={props.router ?? Router}
base={props.basePath || undefined}
root={(routerProps) => (
<TabsProvider>
<NotificationProvider>
Expand Down
25 changes: 24 additions & 1 deletion packages/app/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,33 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
throw new Error(getRootNotFoundError())
}

/**
* Read the runtime base path injected by the server into the <script
* id="oc-base-path" type="application/json"> tag. This allows the frontend to
* know its mount point (e.g. "/opencode/") without a rebuild — the server
* rewrites the tag content on every response.
*
* Returns "" when the app is served from "/" (the common case), so that
* concatenation with origin yields a clean URL.
*/
const getBasePath = () => {
const el = document.getElementById("oc-base-path")
if (!el?.textContent) return ""
try {
const raw = JSON.parse(el.textContent) as string
// Normalize: ensure leading slash, no trailing slash, or empty for root
if (raw === "/" || !raw) return ""
return `/${raw.replace(/^\/+|\/+$/g, "")}`
} catch {
return ""
}
}

const getCurrentUrl = () => {
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 location.origin + getBasePath()
}

const getDefaultUrl = () => {
Expand Down Expand Up @@ -173,6 +195,7 @@ if (root instanceof HTMLElement) {
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
disableHealthCheck
basePath={getBasePath() || undefined}
/>
</AppBaseProviders>
</PlatformProvider>
Expand Down
9 changes: 5 additions & 4 deletions packages/opencode/src/cli/cmd/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,14 @@ export const WebCommand = effectCmd({
}
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts))
const basePath = opts.basePath?.replace(/\/+$/, "") || ""
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}${basePath}`
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}${basePath}`,
)
}
}
Expand All @@ -67,14 +68,14 @@ export const WebCommand = effectCmd({
UI.println(
UI.Style.TEXT_INFO_BOLD + " mDNS: ",
UI.Style.TEXT_NORMAL,
`${opts.mdnsDomain}:${server.port}`,
`${opts.mdnsDomain}:${server.port}${basePath}`,
)
}

// Open localhost in browser
open(localhostUrl).catch(() => {})
} else {
const displayUrl = server.url.toString()
const displayUrl = server.url.toString() + basePath
UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl)
open(displayUrl).catch(() => {})
}
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/src/cli/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ const options = {
describe: "additional domains to allow for CORS",
default: [] as string[],
},
"base-path": {
type: "string" as const,
describe: "base path for the web UI (e.g., /opencode/)",
default: "/",
},
}

export type NetworkOptions = InferredOptionTypes<typeof options>
Expand Down Expand Up @@ -75,6 +80,7 @@ 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 basePath = args["base-path"] ?? "/"

return { hostname, port, mdns, mdnsDomain, cors }
return { hostname, port, mdns, mdnsDomain, cors, basePath }
}
29 changes: 18 additions & 11 deletions packages/opencode/src/server/routes/instance/httpapi/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,16 +191,22 @@ const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effe
Layer.provide(authOnlyRouterLayer),
)

const uiRoute = HttpRouter.use((router) =>
Effect.gen(function* () {
const fs = yield* FSUtil.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))
const uiRoute = (basePath: string) =>
HttpRouter.use((router) =>
Effect.gen(function* () {
const fs = yield* FSUtil.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 Down Expand Up @@ -270,6 +276,7 @@ const app = LayerNode.group([

export function createRoutes(
corsOptions?: CorsOptions,
basePath: string = "/",
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
const locationServiceMapV2 = buildLocationServiceMap()

Expand All @@ -280,7 +287,7 @@ export function createRoutes(
instanceRoutes,
serverRoutes,
docRoute,
uiRoute,
uiRoute(basePath),
).pipe(
Layer.provide([
errorLayer,
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type ListenOptions = CorsOptions & {
hostname: string
mdns?: boolean
mdnsDomain?: string
basePath?: string
}
type ListenerState = {
scope: Scope.Scope
Expand Down Expand Up @@ -98,7 +99,7 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unkno
)

function listenerLayer(opts: ListenOptions, port: number) {
return HttpRouter.serve(HttpApiApp.createRoutes(opts), {
return HttpRouter.serve(HttpApiApp.createRoutes(opts, opts.basePath ?? "/"), {
middleware: disposeMiddleware,
disableLogger: true,
disableListenLog: true,
Expand Down
31 changes: 25 additions & 6 deletions packages/opencode/src/server/shared/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,28 @@ function notFound() {
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
}

function embeddedUIResponse(file: string, body: Uint8Array) {
/**
* Inject the runtime base path into the HTML so the client can read it before
* the JS bundle executes. The `oc-base-path` script tag in index.html holds a
* JSON-encoded path (defaulting to "/"); here we replace its content with the
* configured base path so the frontend knows its mount point without needing a
* rebuild.
*/
function injectBasePath(html: string, basePath: string): string {
const normalized = basePath === "/" ? "/" : `/${basePath.replace(/^\/+|\/+$/g, "")}/`
return html.replace(
/(<script\s+id="oc-base-path"[^>]*>)([\s\S]*?)(<\/script>)/i,
`$1${JSON.stringify(normalized)}$3`,
)
}

function embeddedUIResponse(file: string, body: Uint8Array, basePath: string) {
const mime = FSUtil.mimeType(file)
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) {
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
const html = injectBasePath(new TextDecoder().decode(body), basePath)
headers.set("content-security-policy", cspForHtml(html))
return HttpServerResponse.text(html, { headers })
}
return HttpServerResponse.raw(body, { headers })
}
Expand All @@ -65,25 +82,27 @@ export function serveEmbeddedUIEffect(
requestPath: string,
fs: FSUtil.Interface,
embeddedWebUI: Record<string, string>,
basePath: string = "/",
) {
const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!file) return Effect.succeed(notFound())

return fs.readFile(file).pipe(
Effect.map((body) => embeddedUIResponse(file, body)),
Effect.map((body) => embeddedUIResponse(file, body, basePath)),
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())),
)
}

export function serveUIEffect(
request: HttpServerRequest.HttpServerRequest,
services: { fs: FSUtil.Interface; client: HttpClient.HttpClient; disableEmbeddedWebUi: boolean },
services: { fs: FSUtil.Interface; client: HttpClient.HttpClient; disableEmbeddedWebUi: boolean; basePath?: string },
) {
const basePath = services.basePath ?? "/"
return Effect.gen(function* () {
const embeddedWebUI = yield* Effect.promise(() => embeddedUI(services.disableEmbeddedWebUi))
const path = new URL(request.url, "http://localhost").pathname

if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI)
if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI, basePath)

const response = yield* services.client.execute(
HttpClientRequest.make(request.method)(upstreamURL(path), {
Expand All @@ -94,7 +113,7 @@ export function serveUIEffect(
const headers = proxyResponseHeaders(response.headers)

if (response.headers["content-type"]?.includes("text/html")) {
const body = yield* response.text
const body = injectBasePath(yield* response.text, basePath)
headers.set("Content-Security-Policy", cspForHtml(body))
return HttpServerResponse.text(body, { status: response.status, headers })
}
Expand Down
Loading