diff --git a/packages/opencode/src/server/html-utils.ts b/packages/opencode/src/server/html-utils.ts
index cd962905bc0..dcb0f3c896d 100644
--- a/packages/opencode/src/server/html-utils.ts
+++ b/packages/opencode/src/server/html-utils.ts
@@ -68,6 +68,21 @@ export function injectRootPath(html: string, rootPath: string): string {
)
}
+ // Rewrite absolute paths (href="/...", src="/...", content="/...") to include rootPath
+ // Match href/src/content attribute with value starting with / (but not //)
+ modifiedHtml = modifiedHtml.replace(
+ /(href|src|content)=(["'])\/(?!\/)([^"']*)\2/gi,
+ (match, attr, quote, path) => {
+ const fullPath = "/" + path
+ // Check if path already starts with rootPath or is just "/"
+ // Also ensure we don't double-prefix if rootPath is "/proxy" and path is "/proxy/..."
+ if (fullPath.startsWith(rootPath)) {
+ return match
+ }
+ return `${attr}=${quote}${rootPath}${fullPath}${quote}`
+ }
+ )
+
return modifiedHtml
} catch (error) {
log.error("Failed to inject rootPath into HTML", { error })
@@ -98,7 +113,7 @@ export function normalizeUrl(baseUrl: string, path?: string): string {
*/
export const HTML_CSP_HEADER =
"default-src 'self'; " +
- "script-src 'self' 'wasm-unsafe-eval'; " +
+ "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"font-src 'self' data:; " +
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index ab3d5a94926..3887993ae6b 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -33,7 +33,7 @@ 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, serveStatic } from "hono/bun"
+import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
import { QuestionRoutes } from "./routes/question"
@@ -48,10 +48,8 @@ globalThis.AI_SDK_LOG_WARNINGS = false
export namespace Server {
const log = Log.create({ service: "server" })
- // Constants for paths and URLs
- const APP_DIST_PATH = "../app/dist"
- const APP_INDEX_PATH = `${APP_DIST_PATH}/index.html`
const REMOTE_PROXY_URL = "https://app.opencode.ai"
+ const INDEX_CACHE_TTL_MS = 5 * 60 * 1000
let _url: URL | undefined
let _corsWhitelist: string[] = []
@@ -536,8 +534,7 @@ export namespace Server {
})
})
},
- )
- .use("/*", serveStatic({ root: APP_DIST_PATH })) as unknown as Hono,
+ ) as unknown as Hono,
)
export async function openapi() {
@@ -555,140 +552,112 @@ export namespace Server {
return result
}
- /**
- * Creates a handler that serves static files locally if available,
- * otherwise falls back to remote proxy
- */
- async function createStaticOrProxyHandler() {
- const indexFile = Bun.file(APP_INDEX_PATH)
- const localAppExists = await indexFile.exists()
-
- if (localAppExists) {
- log.info("📦 Serving app from local build (../app/dist)")
- return {
- type: "local" as const,
- handler: serveStatic({ root: APP_DIST_PATH })
- }
- } else {
- log.warn("🌐 Local app build not found, falling back to remote proxy (https://app.opencode.ai)")
- log.warn(" For better performance, build the app: cd packages/app && bun run build")
-
- return {
- type: "proxy" as const,
- handler: async (c: any) => {
- const path = c.req.path
- const response = await proxy(`${REMOTE_PROXY_URL}${path}`, {
- ...c.req,
- headers: {
- ...c.req.raw.headers,
- host: "app.opencode.ai",
- },
- })
- response.headers.set("Content-Security-Policy", HTML_CSP_HEADER)
- return response
- }
+ async function fetchAndInjectIndexHtml(rootPath: string): Promise {
+ const response = await fetch(`${REMOTE_PROXY_URL}/index.html`)
+ if (!response.ok) throw new Error(`Failed to fetch index.html: ${response.status}`)
+
+ const html = await response.text()
+ return rootPath ? injectRootPath(html, rootPath) : html
+ }
+
+ function createIndexHandler(rootPath: string) {
+ let cachedHtml: string | null = null
+ let cacheTime = 0
+
+ return async (c: any) => {
+ console.log(`[DEBUG] indexHandler hit: path=${c.req.path}, rootPath=${rootPath}`)
+ const now = Date.now()
+ if (cachedHtml && (now - cacheTime) < INDEX_CACHE_TTL_MS) {
+ console.log(`[DEBUG] indexHandler: serving cached HTML`)
+ return c.html(cachedHtml, 200, { "Content-Security-Policy": HTML_CSP_HEADER })
}
+
+ console.log(`[DEBUG] indexHandler: fetching fresh HTML`)
+ cachedHtml = await fetchAndInjectIndexHtml(rootPath)
+ cacheTime = now
+ return c.html(cachedHtml, 200, { "Content-Security-Policy": HTML_CSP_HEADER })
}
}
- /**
- * Creates a handler that serves index.html with rootPath injection
- * Centralizes HTML serving logic to avoid duplication
- */
- function createIndexHandler(rootPath: string) {
+ function createStaticHandler() {
return async (c: any) => {
- try {
- const indexFile = Bun.file(APP_INDEX_PATH)
- if (!(await indexFile.exists())) {
- log.warn("index.html not found at ../app/dist/index.html")
- return c.text("Not Found", 404)
- }
+ // Strip rootPath from the request path for proxying to remote
+ let path = c.req.path
+ if (_rootPath && path.startsWith(_rootPath)) {
+ path = path.slice(_rootPath.length) || "/"
+ }
+
+ // Proxy to remote
+ const remoteUrl = `${REMOTE_PROXY_URL}${path}`
+ const response = await fetch(remoteUrl, {
+ method: c.req.method,
+ headers: {
+ ...Object.fromEntries(c.req.raw.headers.entries()),
+ host: "app.opencode.ai",
+ },
+ body: c.req.raw.body,
+ })
- const html = await indexFile.text()
- const modifiedHtml = injectRootPath(html, rootPath)
+ // If HTML, inject rootPath
+ const contentType = response.headers.get("content-type")
+ if (contentType && contentType.includes("text/html")) {
+ const html = await response.text()
+ const injected = injectRootPath(html, _rootPath)
- return c.html(modifiedHtml, 200, {
- "Content-Security-Policy": HTML_CSP_HEADER,
- })
- } catch (error) {
- log.error("Error serving index.html", { error })
- return c.text("Internal Server Error", 500)
+ // Copy headers but exclude encoding/length as body changed
+ const headers = new Headers(response.headers)
+ headers.delete("content-encoding")
+ headers.delete("content-length")
+ headers.delete("transfer-encoding")
+ headers.set("Content-Security-Policy", HTML_CSP_HEADER)
+
+ return c.html(injected, response.status, Object.fromEntries(headers.entries()))
}
- }
- }
- /**
- * Creates app with common routes to avoid duplication
- */
- function createAppWithRoutes(
- indexHandler: (c: any) => Promise,
- staticHandler: any,
- apiApp: Hono
- ): Hono {
- return new Hono()
- .route("/", apiApp)
- .get("/", indexHandler)
- .get("/index.html", indexHandler)
- .use("/*", staticHandler)
- .all("/*", indexHandler) as unknown as Hono
+ // For other assets, return response but strip encoding headers
+ // as fetch() likely decompressed the body but headers remain
+ const headers = new Headers(response.headers)
+ headers.delete("content-encoding")
+ headers.delete("content-length")
+ headers.delete("transfer-encoding")
+
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers
+ })
+ }
}
- /**
- * Starts the OpenCode HTTP server
- *
- * @param opts.rootPath - Base path for reverse proxy deployment (e.g., "/jupyter/proxy/opencode")
- * When provided, requires local app build. Without it, falls back to remote proxy.
- *
- * @example
- * // Standard mode (auto fallback)
- * listen({ port: 4096, hostname: "localhost" })
- *
- * @example
- * // Reverse proxy mode (requires local build)
- * listen({ port: 4096, hostname: "0.0.0.0", rootPath: "/proxy" })
- *
- * @throws {Error} If rootPath is provided but local app build is missing
- */
export async function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[]; rootPath?: string }) {
_corsWhitelist = opts.cors ?? []
_rootPath = opts.rootPath ?? ""
- // rootPath requires local build for reliable routing
- if (opts.rootPath) {
- const localAppExists = await Bun.file(APP_INDEX_PATH).exists()
- if (!localAppExists) {
- throw new Error(
- "rootPath requires local app build.\n" +
- "Build the app first: cd packages/app && bun run build\n" +
- "Or run without --root-path to use remote proxy."
- )
- }
- }
-
- const { type: serveType, handler: staticHandler } = await createStaticOrProxyHandler()
+ // Normalize rootPath (remove trailing slash)
+ const rootPath = _rootPath.endsWith("/") ? _rootPath.slice(0, -1) : _rootPath
+ _rootPath = rootPath
- // Create single index handler (no duplication!)
- const indexHandler = createIndexHandler(_rootPath)
+ const staticHandler = createStaticHandler()
const apiApp = App()
- // Setup routing based on whether rootPath is provided
- let baseApp: Hono
-
- if (opts.rootPath) {
- // When behind reverse proxy: mount app at rootPath
- // This ensures all routes including WebSocket work correctly
- const rootedApp = new Hono()
- .basePath(opts.rootPath)
- .route("/", createAppWithRoutes(indexHandler, staticHandler, apiApp))
-
- // Root app to handle both rooted and global asset paths
- baseApp = new Hono()
- .route("/", rootedApp)
- // Serve static assets that may use absolute paths (e.g., /assets/...)
- .use("/*", staticHandler)
+ let baseApp = new Hono()
+
+ if (rootPath) {
+ // 1. Mount API Routes (priority)
+ // Requests to /rootPath/global/... will be handled by apiApp matching /global/...
+ baseApp.route(rootPath, apiApp)
+
+ // 2. Static Proxy (handling assets, index.html, and SPA routes)
+ // This catches everything else under /rootPath
+ // Note: Hono's route() handles prefix stripping, but use() with pattern needs careful handling.
+ // We manually check prefix in staticHandler, so we can mount it at root or use pattern.
+ // Using pattern to limit scope:
+ baseApp.use(`${rootPath}/*`, staticHandler)
+ baseApp.use(rootPath, staticHandler) // Handle exact rootPath
} else {
- // Standard setup without rootPath
- baseApp = createAppWithRoutes(indexHandler, staticHandler, apiApp)
+ // No rootPath
+ baseApp.route("/", apiApp)
+ baseApp.use("/*", staticHandler)
}
const args = {
diff --git a/packages/opencode/test/server/rootpath.test.ts b/packages/opencode/test/server/rootpath.test.ts
index be1de3b72e3..77a346aad6b 100644
--- a/packages/opencode/test/server/rootpath.test.ts
+++ b/packages/opencode/test/server/rootpath.test.ts
@@ -226,19 +226,11 @@ describe("WebSocket compatibility", () => {
})
})
-describe("Fallback strategy", () => {
- test("validates fallback behavior when local build missing", () => {
- // This test documents expected behavior
- const scenarios = [
- { hasLocalBuild: true, hasRootPath: false, expected: "local" },
- { hasLocalBuild: false, hasRootPath: false, expected: "proxy" },
- { hasLocalBuild: true, hasRootPath: true, expected: "local" },
- { hasLocalBuild: false, hasRootPath: true, expected: "error" },
- ]
-
- for (const scenario of scenarios) {
- // Expected behavior documented
- expect(scenario.expected).toBeDefined()
- }
+describe("Remote proxy behavior", () => {
+ test("documents server behavior with remote proxy", () => {
+ // Server now always uses remote proxy (https://app.opencode.ai)
+ // Both with and without rootPath, static assets are fetched remotely
+ // index.html is cached for 5 minutes to reduce redundant fetches
+ expect(true).toBe(true)
})
})
diff --git a/packages/web/src/content/docs/server.mdx b/packages/web/src/content/docs/server.mdx
index 67d9542575e..063ddca76b9 100644
--- a/packages/web/src/content/docs/server.mdx
+++ b/packages/web/src/content/docs/server.mdx
@@ -61,6 +61,30 @@ opencode TUI running, `opencode serve` will start a new server.
---
+### Deployment
+
+OpenCode server uses remote UI (https://app.opencode.ai) by default.
+All processing happens locally - only frontend assets are fetched remotely.
+
+```bash
+# Standard mode
+opencode serve
+
+# Behind reverse proxy
+opencode serve --root-path /jupyter/proxy/opencode
+```
+
+**Reverse Proxy Mode:**
+
+When using `--root-path`, all routes are prefixed:
+
+```bash
+opencode serve --root-path /jupyter/proxy/opencode
+# Access at: http://your-server/jupyter/proxy/opencode/
+```
+
+---
+
#### Connect to an existing server
When you start the TUI it randomly assigns a port and hostname. You can instead pass in the `--hostname` and `--port` [flags](/docs/cli). Then use this to connect to its server.