refactor(server): replace Bun serve with Hono node adapters#18335
refactor(server): replace Bun serve with Hono node adapters#18335
Conversation
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Formatters now determine their executable location when enabled rather than using hardcoded paths. This ensures formatters work correctly regardless of how the tool was installed or where executables are located on the system.
…r in CI by disabling Bun cache when network interception is detected
…xecutables to work while keeping them disabled on Windows CI where symlink permissions are restricted
…links when symlink permissions are restricted
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
Plugins no longer receive shell access or server URL to prevent unauthorized execution and limit plugin sandbox surface area.
…erver - Enables graceful server shutdown for workspace management - Removes unsupported serverUrl getter that threw errors in plugin context
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps - Fixed database connection cleanup to prevent resource leaks between sessions - Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
…s APIs for OAuth servers and retry logic
# Conflicts: # .opencode/tool/github-pr-search.ts # .opencode/tool/github-triage.ts
| import { ProjectRoutes } from "./routes/project" | ||
| import { SessionRoutes } from "./routes/session" | ||
| import { PtyRoutes } from "./routes/pty" | ||
| // import { PtyRoutes } from "./routes/pty" |
There was a problem hiding this comment.
The PTY import and its route registration (line 568) are both commented out, completely removing the /pty WebSocket endpoint from the server. Any client that relies on connecting to PTY sessions via WebSocket will receive a 404. The route file (routes/pty.ts) was updated to use upgradeWebSocket but is never mounted.
If PTY support is intentionally deferred to a follow-up PR, this should be explicitly documented (e.g. a TODO comment with a tracking issue). If unintentional, both lines should be uncommented:
import { PtyRoutes } from "./routes/pty"and on line 568:
.route("/pty", PtyRoutes(ws.upgradeWebSocket))| export function Listen(opts: { hostname: string; port: number }) { | ||
| return Bun.serve({ | ||
| hostname: opts.hostname, | ||
| port: opts.port, | ||
| const server = createAdaptorServer({ | ||
| fetch: App().fetch, | ||
| }) | ||
| server.listen(opts.port, opts.hostname) | ||
| return { | ||
| hostname: opts.hostname, | ||
| port: opts.port, | ||
| stop() { | ||
| return new Promise<void>((resolve, reject) => { | ||
| server.close((err) => { | ||
| if (err) { | ||
| reject(err) | ||
| return | ||
| } | ||
| resolve() | ||
| }) | ||
| }) | ||
| }, | ||
| } | ||
| } |
There was a problem hiding this comment.
Listen returns before the server is ready and silently swallows bind errors
server.listen() is asynchronous — the server is not actually bound to the port when Listen returns. Unlike the analogous Server.listen() in server.ts (which wraps the call in a Promise and resolves on the "listening" event), this implementation returns immediately, creating a race condition for callers. Additionally, any "error" event (e.g. EADDRINUSE) is silently ignored.
The function should await the "listening" event and reject on "error", matching the pattern in the main server:
export function Listen(opts: { hostname: string; port: number }) {
return new Promise<{ hostname: string; port: number; stop: () => Promise<void> }>((resolve, reject) => {
const server = createAdaptorServer({ fetch: App().fetch })
const cleanup = () => { server.off("error", fail); server.off("listening", ready) }
const fail = (err: Error) => { cleanup(); reject(err) }
const ready = () => {
cleanup()
resolve({
hostname: opts.hostname,
port: opts.port,
stop() {
return new Promise<void>((res, rej) => server.close((e) => e ? rej(e) : res()))
},
})
}
server.once("error", fail)
server.once("listening", ready)
server.listen(opts.port, opts.hostname)
})
}| server.listen(port, opts.hostname) | ||
| }) | ||
|
|
||
| const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port) |
There was a problem hiding this comment.
Unexpected port fallback when
port === 0
When opts.port === 0 (meaning "let the OS pick a free port"), the code first attempts to bind on port 4096 and only falls back to port 0 if that fails. A caller requesting port 0 expects an OS-assigned ephemeral port, not a specific port attempt. This can silently change the server's port to 4096 in environments where that port happens to be free, making the behaviour non-deterministic and hard to reason about.
| const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port) | |
| const server = opts.port === 0 ? await start(0) : await start(opts.port) |
| const shouldPublishMDNS = | ||
| opts.mdns && | ||
| server.port && | ||
| addr.port && | ||
| opts.hostname !== "127.0.0.1" && | ||
| opts.hostname !== "localhost" && | ||
| opts.hostname !== "::1" |
There was a problem hiding this comment.
Multi-word variable name violates naming convention
Per the project style guide, single-word identifiers are strongly preferred. shouldPublishMDNS is a four-word compound. A shorter name like publishMDNS communicates the same intent:
| const shouldPublishMDNS = | |
| opts.mdns && | |
| server.port && | |
| addr.port && | |
| opts.hostname !== "127.0.0.1" && | |
| opts.hostname !== "localhost" && | |
| opts.hostname !== "::1" | |
| const publishMDNS = | |
| opts.mdns && | |
| addr.port && | |
| opts.hostname !== "127.0.0.1" && | |
| opts.hostname !== "localhost" && | |
| opts.hostname !== "::1" | |
| if (publishMDNS) { | |
| MDNS.publish(addr.port, opts.mdnsDomain) | |
| } else if (opts.mdns) { |
The same naming convention applies to rawWorkspaceID on line 206 (rawID or wsID) and localhostUrl in web.ts line 47 (localUrl).
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
|
status of this is it needs PTY fixes |
Summary
Bun.servewith Hono's node server adapter (@hono/node-server) and WebSocket adapter (@hono/node-ws) to decouple the HTTP server from the Bun runtimestop()method for clean workspace server shutdownContext
This is the server layer portion remaining from the
opencode-2-0branch after extracting SQLite abstraction, portable process, OAuth, node entry point, and other changes into separate PRs (#18308, #18318, #18320, #18324, #18327, #18328).Confidence Score: 2/5
Important Files Changed
Comments Outside Diff (1)
packages/opencode/src/cli/cmd/serve.ts, line 21-22 (link)server.stop()is unreachable dead codeawait new Promise(() => {})creates a promise that never settles, soawait server.stop()on the next line can never be reached. The same pattern exists inweb.tsat lines 78–79. In practice this means the graceful shutdown path is never exercised — if the process exits (SIGINT, SIGTERM, etc.) the server is killed without going throughstop(). Consider removing the dead call or wiring up signal handling:The same fix is needed in
web.ts.Last reviewed commit: "chore: extract misc ..."
Context used: