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
3 changes: 2 additions & 1 deletion packages/opencode/src/cli/cmd/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const ServeCommand = cmd({
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
const displayUrl = opts.rootPath ? new URL(opts.rootPath, `http://${server.hostname}:${server.port}`).toString() : `http://${server.hostname}:${server.port}`
console.log(`opencode server listening on ${displayUrl}`)
await new Promise(() => {})
await server.stop()
},
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 @@ -44,17 +44,18 @@ export const WebCommand = cmd({

if (opts.hostname === "0.0.0.0") {
// Show localhost for local access
const localhostUrl = `http://localhost:${server.port}`
UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl)
const baseUrl = opts.rootPath ? new URL(opts.rootPath, `http://localhost:${server.port}`).toString() : `http://localhost:${server.port}`
UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, baseUrl)

// Show network IPs for remote access
const networkIPs = getNetworkIPs()
if (networkIPs.length > 0) {
for (const ip of networkIPs) {
const networkUrl = opts.rootPath ? new URL(opts.rootPath, `http://${ip}:${server.port}`).toString() : `http://${ip}:${server.port}`
UI.println(
UI.Style.TEXT_INFO_BOLD + " Network access: ",
UI.Style.TEXT_NORMAL,
`http://${ip}:${server.port}`,
networkUrl,
)
}
}
Expand All @@ -68,7 +69,7 @@ export const WebCommand = cmd({
}

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

export type NetworkOptions = InferredOptionTypes<typeof options>
Expand All @@ -37,6 +42,7 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns")
const corsExplicitlySet = process.argv.includes("--cors")
const rootPathExplicitlySet = process.argv.includes("--root-path")

const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
Expand All @@ -48,6 +54,11 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const configCors = config?.server?.cors ?? []
const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
const cors = [...configCors, ...argsCors]
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}')`)
}

return { hostname, port, mdns, cors }
return { hostname, port, mdns, cors, rootPath }
}
1 change: 1 addition & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,7 @@ export namespace Config {
hostname: z.string().optional().describe("Hostname to listen on"),
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
rootPath: z.string().optional().describe("Base path for reverse proxy"),
})
.strict()
.meta({
Expand Down
10 changes: 7 additions & 3 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,13 +563,17 @@ export namespace Server {
return result
}

export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[]; rootPath?: string }) {
_corsWhitelist = opts.cors ?? []

// When rootPath is provided (for reverse proxy support), wrap the main app with a base path prefix.
// Hono's basePath() automatically prefixes all routes, including WebSocket upgrades.
const baseApp = opts.rootPath ? new Hono().basePath(opts.rootPath).route("/", App()) : App()

const args = {
hostname: opts.hostname,
idleTimeout: 0,
fetch: App().fetch,
fetch: baseApp.fetch,
websocket: websocket,
} as const
const tryServe = (port: number) => {
Expand All @@ -582,7 +586,7 @@ export namespace Server {
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)

_url = server.url
_url = opts.rootPath ? new URL(opts.rootPath, server.url) : server.url

const shouldPublishMDNS =
opts.mdns &&
Expand Down
67 changes: 67 additions & 0 deletions packages/opencode/test/server/rootpath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, test } from "bun:test"
import { Server } from "../../src/server/server"

describe("rootPath support", () => {
test("server accepts rootPath option", () => {
// Test that listen function accepts rootPath parameter
const listenFn = Server.listen
expect(listenFn).toBeDefined()

// This will test that the function signature is correct
// We can't actually start the server in tests, but we can verify the types
})

test("URL construction with rootPath", () => {
// Test URL construction logic
const testCases = [
{ rootPath: "", expected: "http://localhost:4096" },
{ rootPath: "/proxy", expected: "http://localhost:4096/proxy" },
{ rootPath: "/jupyter/proxy/opencode", expected: "http://192.168.1.100:4096/jupyter/proxy/opencode" },
]

for (const { rootPath, expected } of testCases) {
const hostname = expected.includes("192.168") ? "192.168.1.100" : "localhost"
const port = 4096

const url = rootPath
? new URL(rootPath, `http://${hostname}:${port}`).toString()
: `http://${hostname}:${port}`

expect(url).toBe(expected)
}
})

test("rootPath validation", () => {
// Test that rootPath must start with /
const invalidPaths = ["proxy", "test/path", "no-slash"]
const validPaths = ["/proxy", "/test/path", "/jupyter/proxy/opencode"]

for (const path of invalidPaths) {
if (path && !path.startsWith("/")) {
// This should throw an error
expect(path.startsWith("/")).toBe(false)
}
}

for (const path of validPaths) {
expect(path.startsWith("/")).toBe(true)
}
})

test("server URL with rootPath", () => {
// Simulate server.url construction
const serverUrl = new URL("http://localhost:4096")

// Test with rootPath
const rootPath = "/proxy"
const finalUrl = rootPath ? new URL(rootPath, serverUrl) : serverUrl

expect(finalUrl.toString()).toBe("http://localhost:4096/proxy")

// Test without rootPath
const noRootPath = ""
const finalUrl2 = noRootPath ? new URL(noRootPath, serverUrl) : serverUrl

expect(finalUrl2.toString()).toBe("http://localhost:4096/")
})
})
2 changes: 2 additions & 0 deletions packages/web/src/content/docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ This starts an HTTP server that provides API access to opencode functionality wi
| `--hostname` | Hostname to listen on |
| `--mdns` | Enable mDNS discovery |
| `--cors` | Additional browser origin(s) to allow CORS |
| `--root-path` | Base path for reverse proxy |

---

Expand Down Expand Up @@ -464,6 +465,7 @@ This starts an HTTP server and opens a web browser to access OpenCode through a
| `--hostname` | Hostname to listen on |
| `--mdns` | Enable mDNS discovery |
| `--cors` | Additional browser origin(s) to allow CORS |
| `--root-path` | Base path for reverse proxy |

---

Expand Down
4 changes: 3 additions & 1 deletion packages/web/src/content/docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ You can configure server settings for the `opencode serve` and `opencode web` co
"port": 4096,
"hostname": "0.0.0.0",
"mdns": true,
"cors": ["http://localhost:5173"]
"cors": ["http://localhost:5173"],
"rootPath": "/proxy"
}
}
```
Expand All @@ -201,6 +202,7 @@ Available options:
- `hostname` - Hostname to listen on. When `mdns` is enabled and no hostname is set, defaults to `0.0.0.0`.
- `mdns` - Enable mDNS service discovery. This allows other devices on the network to discover your OpenCode server.
- `cors` - Additional origins to allow for CORS when using the HTTP server from a browser-based client. Values must be full origins (scheme + host + optional port), eg `https://app.example.com`.
- `rootPath` - Base path for reverse proxy. All routes will be prefixed with this path.

[Learn more about the server here](/docs/server).

Expand Down
9 changes: 8 additions & 1 deletion packages/web/src/content/docs/server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The `opencode serve` command runs a headless HTTP server that exposes an OpenAPI
### Usage

```bash
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>] [--root-path <path>]
```

#### Options
Expand All @@ -24,13 +24,20 @@ opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
| `--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) |

`--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:

```bash
opencode serve --root-path /jupyter/proxy/opencode
```

---

### Authentication
Expand Down