Skip to content
Merged
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
5 changes: 4 additions & 1 deletion packages/opencode/src/cli/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const rootPath = rootPathExplicitlySet ? args.rootPath : (config?.server?.rootPath ?? args.rootPath)

if (rootPath && !rootPath.startsWith("/")) {
throw new Error(`rootPath must start with '/' if provided (got: '${rootPath}')`)
throw new Error(
`Invalid rootPath: must start with '/' (got: '${rootPath}')\n` +
`Example: --root-path /jupyter/proxy/opencode`
)
}

return { hostname, port, mdns, cors, rootPath }
Expand Down
16 changes: 13 additions & 3 deletions packages/opencode/src/server/html-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,19 @@ function escapeHtmlAttribute(value: string): string {

/**
* Safely injects rootPath configuration into index.html
* - Prevents XSS by properly escaping values
* - Checks for existing tags to avoid duplication
* - Returns modified HTML or original on any error
*
* Security measures:
* - HTML attribute escaping prevents XSS via DOM attributes
* - JSON.stringify prevents XSS via script injection
* - Duplicate tag detection prevents configuration conflicts
*
* @param html - Original HTML content
* @param rootPath - Base path to inject (e.g., "/proxy")
* @returns Modified HTML with rootPath configuration, or original HTML on error
*
* @example
* const html = await Bun.file("index.html").text()
* const modified = injectRootPath(html, "/jupyter/proxy")
*/
export function injectRootPath(html: string, rootPath: string): string {
if (!rootPath) return html
Expand Down
110 changes: 95 additions & 15 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ 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
let _corsWhitelist: string[] = []
let _rootPath: string = ""
Expand Down Expand Up @@ -532,7 +537,7 @@ export namespace Server {
})
},
)
.use("/*", serveStatic({ root: "../app/dist" })) as unknown as Hono,
.use("/*", serveStatic({ root: APP_DIST_PATH })) as unknown as Hono,
)

export async function openapi() {
Expand All @@ -550,14 +555,50 @@ 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
}
}
}
}

/**
* Creates a handler that serves index.html with rootPath injection
* Centralizes HTML serving logic to avoid duplication
*/
function createIndexHandler(rootPath: string) {
return async (c: any) => {
try {
const indexFile = Bun.file("../app/dist/index.html")
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)
Expand All @@ -576,12 +617,59 @@ export namespace Server {
}
}

export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[]; rootPath?: string }) {
/**
* 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
}

/**
* 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 indexHandler = createIndexHandler(_rootPath)
const apiApp = App()

// Setup routing based on whether rootPath is provided
let baseApp: Hono
Expand All @@ -591,24 +679,16 @@ export namespace Server {
// This ensures all routes including WebSocket work correctly
const rootedApp = new Hono()
.basePath(opts.rootPath)
.route("/", App())
.get("/", indexHandler)
.get("/index.html", indexHandler)
.use("/*", serveStatic({ root: "../app/dist" }))
.all("/*", indexHandler) // SPA fallback

.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("/*", serveStatic({ root: "../app/dist" }))
.use("/*", staticHandler)
} else {
// Standard setup without rootPath
baseApp = App()
.get("/", indexHandler)
.get("/index.html", indexHandler)
.use("/*", serveStatic({ root: "../app/dist" }))
.all("/*", indexHandler) as unknown as Hono
baseApp = createAppWithRoutes(indexHandler, staticHandler, apiApp)
}

const args = {
Expand Down
88 changes: 88 additions & 0 deletions packages/opencode/test/server/rootpath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,91 @@ describe("server URL with rootPath", () => {
expect(finalUrl).toBe("http://localhost:4096/")
})
})

describe("Special character handling", () => {
test("handles URL encoded characters", () => {
const html = '<html><head></head><body><div id="root"></div></body></html>'
const result = injectRootPath(html, "/한글/경로")

// Should properly escape in HTML attributes
expect(result).toContain('data-root-path=')
// Should safely encode in JavaScript
expect(result).toContain('window.__OPENCODE__.rootPath')
})

test("handles spaces and special chars in rootPath", () => {
const html = '<html><head></head><body><div id="root"></div></body></html>'
const paths = ["/path with space", "/path-with-dash", "/path_with_underscore", "/path.with.dot"]

for (const path of paths) {
const result = injectRootPath(html, path)
expect(result).toContain(JSON.stringify(path))
}
})

test("handles paths with query-like characters", () => {
const maliciousPath = "/proxy?token=abc&key=xyz"
const html = '<html><head></head><body><div id="root"></div></body></html>'
const result = injectRootPath(html, maliciousPath)

// Should be safely escaped
expect(result).toContain(JSON.stringify(maliciousPath))
})
})

describe("URL normalization edge cases", () => {
test("handles multiple consecutive slashes", () => {
expect(normalizeUrl("http://localhost:4096", "///proxy///path///")).toBe(
"http://localhost:4096/proxy/path/"
)
})

test("handles mixed slash patterns", () => {
expect(normalizeUrl("http://localhost:4096/", "//proxy/path")).toBe(
"http://localhost:4096/proxy/path"
)
})

test("preserves trailing slash when explicitly provided", () => {
const result = normalizeUrl("http://localhost:4096", "/proxy/")
expect(result.endsWith("/")).toBe(true)
})
})

describe("WebSocket compatibility", () => {
test("WebSocket URL construction with rootPath", () => {
const serverUrl = "http://localhost:4096"
const rootPath = "/jupyter/proxy/opencode"

// WebSocket should use same base path
const wsUrl = new URL(rootPath, serverUrl)
wsUrl.protocol = "ws:"

expect(wsUrl.toString()).toBe("ws://localhost:4096/jupyter/proxy/opencode")
})

test("WebSocket URL without rootPath", () => {
const serverUrl = "http://localhost:4096"
const wsUrl = new URL(serverUrl)
wsUrl.protocol = "ws:"

expect(wsUrl.toString()).toBe("ws://localhost:4096/")
})
})

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()
}
})
})
30 changes: 28 additions & 2 deletions packages/web/src/content/docs/server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,46 @@ opencode serve [--port <number>] [--hostname <string>] [--cors <origin>] [--root
| `--hostname` | Hostname to listen on | `127.0.0.1` |
| `--mdns` | Enable mDNS discovery | `false` |
| `--cors` | Additional browser origins to allow | `[]` |
| `--root-path` | Base path for reverse proxy | (empty) |
| `--root-path` | Base path for reverse proxy | (empty) |

`--cors` can be passed multiple times:

```bash
opencode serve --cors http://localhost:5173 --cors https://app.example.com
```

Use `--root-path` when running behind a reverse proxy:
---

### Deployment Modes

OpenCode server supports two deployment modes:

1. **Standard Mode** (default)
- Serves app from local build if available
- Falls back to remote proxy (https://app.opencode.ai) if local build missing
- Best for development and simple deployments

2. **Reverse Proxy Mode** (with `--root-path`)
- Requires local app build (no fallback)
- All routes prefixed with specified path
- Best for Jupyter, corporate proxies, and multi-tenant environments

```bash
# Standard mode with auto-fallback
opencode serve

# Behind reverse proxy (requires: cd packages/app && bun run build)
opencode serve --root-path /jupyter/proxy/opencode
```

⚠️ **Note**: When using `--root-path`, ensure the app is built first:
```bash
cd packages/app
bun run build
cd ../../packages/opencode
opencode serve --root-path /your/path
```

---

### Authentication
Expand Down