From 45d7bc57efcb0b005397937d74a14eaac2c7fa5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 02:12:29 +0000 Subject: [PATCH 1/4] Initial plan From a69407163b3fbf6aea2f6974be6419fba4500052 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 02:15:50 +0000 Subject: [PATCH 2/4] Simplify server.ts by removing local build dependencies and using remote proxy Co-authored-by: DaehoYang <129835752+DaehoYang@users.noreply.github.com> --- packages/opencode/src/server/server.ts | 122 ++++-------------- .../opencode/test/server/rootpath.test.ts | 14 +- packages/web/src/content/docs/server.mdx | 24 ++++ 3 files changed, 57 insertions(+), 103 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index ab3d5a94926..b623ee2cf66 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,9 +48,6 @@ 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" let _url: URL | undefined @@ -537,7 +534,7 @@ export namespace Server { }) }, ) - .use("/*", serveStatic({ root: APP_DIST_PATH })) as unknown as Hono, + ) as unknown as Hono, ) export async function openapi() { @@ -555,65 +552,38 @@ 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() + 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}`) - if (localAppExists) { - log.info("📦 Serving app from local build (../app/dist)") - return { - type: "local" as const, - handler: serveStatic({ root: APP_DIST_PATH }) + const html = await response.text() + return rootPath ? injectRootPath(html, rootPath) : html + } + + function createIndexHandler(rootPath: string) { + let cachedHtml: string | null = null + let cacheTime = 0 + const CACHE_TTL = 5 * 60 * 1000 + + return async (c: any) => { + const now = Date.now() + if (cachedHtml && (now - cacheTime) < CACHE_TTL) { + return c.html(cachedHtml, 200, { "Content-Security-Policy": HTML_CSP_HEADER }) } - } 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 - } - } + 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) - } - - const html = await indexFile.text() - const modifiedHtml = 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) - } + const response = await proxy(`${REMOTE_PROXY_URL}${c.req.path}`, { + ...c.req, + headers: { ...c.req.raw.headers, host: "app.opencode.ai" } + }) + return response } } @@ -633,61 +603,25 @@ export namespace Server { .all("/*", indexHandler) as unknown as Hono } - /** - * 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() - - // Create single index handler (no duplication!) + const staticHandler = createStaticHandler() const indexHandler = createIndexHandler(_rootPath) 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) } else { - // Standard setup without rootPath baseApp = createAppWithRoutes(indexHandler, staticHandler, apiApp) } diff --git a/packages/opencode/test/server/rootpath.test.ts b/packages/opencode/test/server/rootpath.test.ts index be1de3b72e3..34937fe6ae7 100644 --- a/packages/opencode/test/server/rootpath.test.ts +++ b/packages/opencode/test/server/rootpath.test.ts @@ -226,19 +226,15 @@ describe("WebSocket compatibility", () => { }) }) -describe("Fallback strategy", () => { - test("validates fallback behavior when local build missing", () => { - // This test documents expected behavior +describe("Remote proxy behavior", () => { + test("always uses remote proxy regardless of local build", () => { 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" }, + { hasRootPath: false, expected: "proxy" }, + { hasRootPath: true, expected: "proxy" }, ] for (const scenario of scenarios) { - // Expected behavior documented - expect(scenario.expected).toBeDefined() + expect(scenario.expected).toBe("proxy") } }) }) 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. From fb7ff791ab5593616f71152a901cfc84218ed377 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 02:17:04 +0000 Subject: [PATCH 3/4] Address code review feedback: extract cache TTL constant and improve test documentation Co-authored-by: DaehoYang <129835752+DaehoYang@users.noreply.github.com> --- packages/opencode/src/server/server.ts | 4 ++-- packages/opencode/test/server/rootpath.test.ts | 14 +++++--------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index b623ee2cf66..2ab711ba020 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -49,6 +49,7 @@ export namespace Server { const log = Log.create({ service: "server" }) const REMOTE_PROXY_URL = "https://app.opencode.ai" + const INDEX_CACHE_TTL_MS = 5 * 60 * 1000 let _url: URL | undefined let _corsWhitelist: string[] = [] @@ -563,11 +564,10 @@ export namespace Server { function createIndexHandler(rootPath: string) { let cachedHtml: string | null = null let cacheTime = 0 - const CACHE_TTL = 5 * 60 * 1000 return async (c: any) => { const now = Date.now() - if (cachedHtml && (now - cacheTime) < CACHE_TTL) { + if (cachedHtml && (now - cacheTime) < INDEX_CACHE_TTL_MS) { return c.html(cachedHtml, 200, { "Content-Security-Policy": HTML_CSP_HEADER }) } diff --git a/packages/opencode/test/server/rootpath.test.ts b/packages/opencode/test/server/rootpath.test.ts index 34937fe6ae7..77a346aad6b 100644 --- a/packages/opencode/test/server/rootpath.test.ts +++ b/packages/opencode/test/server/rootpath.test.ts @@ -227,14 +227,10 @@ describe("WebSocket compatibility", () => { }) describe("Remote proxy behavior", () => { - test("always uses remote proxy regardless of local build", () => { - const scenarios = [ - { hasRootPath: false, expected: "proxy" }, - { hasRootPath: true, expected: "proxy" }, - ] - - for (const scenario of scenarios) { - expect(scenario.expected).toBe("proxy") - } + 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) }) }) From acd0c157425f543819a1b08ddc5a0aaa206eb95c Mon Sep 17 00:00:00 2001 From: Daeho Yang Date: Tue, 3 Feb 2026 17:11:01 +0900 Subject: [PATCH 4/4] [WIP] failed to resolve --- packages/opencode/src/server/html-utils.ts | 17 +++- packages/opencode/src/server/server.ts | 107 ++++++++++++++------- 2 files changed, 87 insertions(+), 37 deletions(-) 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 2ab711ba020..3887993ae6b 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -534,7 +534,6 @@ export namespace Server { }) }) }, - ) ) as unknown as Hono, ) @@ -556,7 +555,7 @@ export namespace Server { 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 } @@ -564,13 +563,16 @@ export namespace Server { 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 }) @@ -579,50 +581,83 @@ export namespace Server { function createStaticHandler() { return async (c: any) => { - const response = await proxy(`${REMOTE_PROXY_URL}${c.req.path}`, { - ...c.req, - headers: { ...c.req.raw.headers, host: "app.opencode.ai" } + // 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, }) - return response - } - } - /** - * 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 + // 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) + + // 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())) + } + + // 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 + }) + } } export async function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[]; rootPath?: string }) { _corsWhitelist = opts.cors ?? [] _rootPath = opts.rootPath ?? "" - + + // Normalize rootPath (remove trailing slash) + const rootPath = _rootPath.endsWith("/") ? _rootPath.slice(0, -1) : _rootPath + _rootPath = rootPath + const staticHandler = createStaticHandler() - const indexHandler = createIndexHandler(_rootPath) const apiApp = App() - let baseApp: Hono + 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) - if (opts.rootPath) { - const rootedApp = new Hono() - .basePath(opts.rootPath) - .route("/", createAppWithRoutes(indexHandler, staticHandler, apiApp)) - - baseApp = new Hono() - .route("/", rootedApp) - .use("/*", staticHandler) + // 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 { - baseApp = createAppWithRoutes(indexHandler, staticHandler, apiApp) + // No rootPath + baseApp.route("/", apiApp) + baseApp.use("/*", staticHandler) } const args = {