Skip to content

refactor(server): replace Bun serve with Hono node adapters#18335

Open
thdxr wants to merge 91 commits intodevfrom
refactor/hono-server
Open

refactor(server): replace Bun serve with Hono node adapters#18335
thdxr wants to merge 91 commits intodevfrom
refactor/hono-server

Conversation

@thdxr
Copy link
Member

@thdxr thdxr commented Mar 20, 2026

Summary

  • Replaces Bun.serve with Hono's node server adapter (@hono/node-server) and WebSocket adapter (@hono/node-ws) to decouple the HTTP server from the Bun runtime
  • Updates PTY route handling to use Hono-compatible WebSocket upgrade flow
  • Adds structured server info return with a stop() method for clean workspace server shutdown
  • Bundles database migrations into the node build and auto-starts the server on port 1338

Context

This is the server layer portion remaining from the opencode-2-0 branch 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

  • Not safe to merge — PTY WebSocket routes are disabled, WorkspaceServer.Listen() has an async race condition, and server.stop() is dead code in CLI command handlers.
  • Three distinct P1 issues: a silent feature regression (PTY routes commented out), a lifecycle race in WorkspaceServer.Listen(), and unreachable shutdown logic in both serve.ts and web.ts. The port-0 fallback is also behaviorally incorrect. Together these represent meaningful functional regressions introduced by the refactor.
  • packages/opencode/src/server/server.ts and packages/opencode/src/control-plane/workspace-server/server.ts require the most attention.

Important Files Changed

Filename Overview
packages/opencode/src/server/server.ts Core server refactored from Bun.serve to @hono/node-server; PTY routes are commented out (regression), port-0 fallback logic is non-standard, and multi-word variable names violate the style guide.
packages/opencode/src/control-plane/workspace-server/server.ts Migrated to @hono/node-server, but Listen() returns before the server is ready and silently swallows bind errors — unlike the main Server.listen() which properly awaits the "listening" event.
packages/opencode/src/cli/cmd/serve.ts Uses the new Server.listen() API correctly, but server.stop() after await new Promise(() => {}) is dead code that can never be reached.
packages/opencode/src/cli/cmd/web.ts Same dead-code stop() pattern as serve.ts; otherwise cleanly uses the new Listener API.
packages/opencode/src/server/routes/pty.ts Updated to accept upgradeWebSocket from @hono/node-ws and correctly adapts the raw WebSocket to the Pty.Socket interface; the file itself is correct but is never mounted because the route registration in server.ts is commented out.
packages/opencode/src/pty/index.ts No functional changes; subscriber keying logic remains unchanged and works correctly for the Bun-style socket objects it currently handles.
packages/opencode/src/cli/cmd/acp.ts Minor update to use the new Server.listen() return type; logic is unchanged and correct.
packages/opencode/package.json Adds @hono/node-server and @hono/node-ws as runtime dependencies at pinned versions; bun.lock updated accordingly.

Comments Outside Diff (1)

  1. packages/opencode/src/cli/cmd/serve.ts, line 21-22 (link)

    P1 server.stop() is unreachable dead code

    await new Promise(() => {}) creates a promise that never settles, so await server.stop() on the next line can never be reached. The same pattern exists in web.ts at 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 through stop(). Consider removing the dead call or wiring up signal handling:

    await new Promise<void>((resolve) => {
      process.once("SIGINT", resolve)
      process.once("SIGTERM", resolve)
    })
    await server.stop(true)

    The same fix is needed in web.ts.

Last reviewed commit: "chore: extract misc ..."

Greptile also left 4 inline comments on this PR.

Context used:

  • Context used - packages/opencode/AGENTS.md (source)

thdxr and others added 30 commits March 10, 2026 16:02
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
@thdxr thdxr added the beta label Mar 20, 2026
import { ProjectRoutes } from "./routes/project"
import { SessionRoutes } from "./routes/session"
import { PtyRoutes } from "./routes/pty"
// import { PtyRoutes } from "./routes/pty"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 PTY routes silently disabled

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))

Comment on lines 59 to 79
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()
})
})
},
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
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)

Comment on lines 636 to 641
const shouldPublishMDNS =
opts.mdns &&
server.port &&
addr.port &&
opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
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!

@thdxr thdxr removed the beta label Mar 20, 2026
@thdxr
Copy link
Member Author

thdxr commented Mar 20, 2026

status of this is it needs PTY fixes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants