Skip to content
Draft
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
17 changes: 16 additions & 1 deletion packages/opencode/src/server/html-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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:; " +
Expand Down
211 changes: 90 additions & 121 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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[] = []
Expand Down Expand Up @@ -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() {
Expand All @@ -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<string> {
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<Response>,
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 = {
Expand Down
20 changes: 6 additions & 14 deletions packages/opencode/test/server/rootpath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
24 changes: 24 additions & 0 deletions packages/web/src/content/docs/server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down