From 2724335b2818733cae2dfa2822263f979f28f5f6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 9 Mar 2026 17:57:00 -0400 Subject: [PATCH] refactor(server): replace Bun serve with Hono node adapters --- packages/opencode/package.json | 2 + packages/opencode/src/cli/cmd/acp.ts | 2 +- packages/opencode/src/cli/cmd/serve.ts | 2 +- packages/opencode/src/cli/cmd/tui/worker.ts | 7 +- packages/opencode/src/cli/cmd/web.ts | 2 +- packages/opencode/src/pty/index.ts | 31 +++--- packages/opencode/src/server/routes/pty.ts | 11 +- packages/opencode/src/server/server.ts | 113 ++++++++++++++------ 8 files changed, 110 insertions(+), 60 deletions(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 4e4f46b0c6..beeb1de82c 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -81,6 +81,8 @@ "@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/opencode-gitlab-auth": "1.3.3", "@hono/standard-validator": "0.1.5", + "@hono/node-server": "1.19.11", + "@hono/node-ws": "1.3.0", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", "@octokit/graphql": "9.0.2", diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 99a9a81ab9..2fb9038b0f 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -23,7 +23,7 @@ export const AcpCommand = cmd({ process.env.OPENCODE_CLIENT = "acp" await bootstrap(process.cwd(), async () => { const opts = await resolveNetworkOptions(args) - const server = Server.listen(opts) + const server = await Server.listen(opts) const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index ab51fe8c3e..73e7a18a70 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -15,7 +15,7 @@ export const ServeCommand = cmd({ console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } const opts = await resolveNetworkOptions(args) - const server = Server.listen(opts) + const server = await Server.listen(opts) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) await new Promise(() => {}) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 408350c520..511182fe85 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -8,7 +8,6 @@ import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" import { GlobalBus } from "@/bus/global" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" -import type { BunWebSocketData } from "hono/bun" import { Flag } from "@/flag/flag" import { setTimeout as sleep } from "node:timers/promises" @@ -38,7 +37,7 @@ GlobalBus.on("event", (event) => { Rpc.emit("global.event", event) }) -let server: Bun.Server | undefined +let server: Awaited> | undefined const eventStream = { abort: undefined as AbortController | undefined, @@ -120,7 +119,7 @@ export const rpc = { }, async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { if (server) await server.stop(true) - server = Server.listen(input) + server = await Server.listen(input) return { url: server.url.toString() } }, async checkUpgrade(input: { directory: string }) { @@ -143,7 +142,7 @@ export const rpc = { Log.Default.info("worker shutting down") if (eventStream.abort) eventStream.abort.abort() await Instance.disposeAll() - if (server) server.stop(true) + if (server) await server.stop(true) }, } diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 0fe056f21f..e656c83d9a 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -37,7 +37,7 @@ export const WebCommand = cmd({ UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } const opts = await resolveNetworkOptions(args) - const server = Server.listen(opts) + const server = await Server.listen(opts) UI.empty() UI.println(UI.logo(" ")) UI.empty() diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 1c27d5b847..791062aaff 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -23,6 +23,8 @@ export namespace Pty { close: (code?: number, reason?: string) => void } + const key = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws) + // WebSocket control frame: 0x00 + UTF-8 JSON. const meta = (cursor: number) => { const json = JSON.stringify({ cursor }) @@ -97,9 +99,9 @@ export namespace Pty { try { session.process.kill() } catch {} - for (const [key, ws] of session.subscribers.entries()) { + for (const [id, ws] of session.subscribers.entries()) { try { - if (ws.data === key) ws.close() + if (key(ws) === id) ws.close() } catch { // ignore } @@ -170,21 +172,21 @@ export namespace Pty { ptyProcess.onData((chunk) => { session.cursor += chunk.length - for (const [key, ws] of session.subscribers.entries()) { + for (const [id, ws] of session.subscribers.entries()) { if (ws.readyState !== 1) { - session.subscribers.delete(key) + session.subscribers.delete(id) continue } - if (ws.data !== key) { - session.subscribers.delete(key) + if (key(ws) !== id) { + session.subscribers.delete(id) continue } try { ws.send(chunk) } catch { - session.subscribers.delete(key) + session.subscribers.delete(id) } } @@ -226,9 +228,9 @@ export namespace Pty { try { session.process.kill() } catch {} - for (const [key, ws] of session.subscribers.entries()) { + for (const [id, ws] of session.subscribers.entries()) { try { - if (ws.data === key) ws.close() + if (key(ws) === id) ws.close() } catch { // ignore } @@ -259,16 +261,13 @@ export namespace Pty { } log.info("client connected to session", { id }) - // Use ws.data as the unique key for this connection lifecycle. - // If ws.data is undefined, fallback to ws object. - const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws + const sub = key(ws) - // Optionally cleanup if the key somehow exists - session.subscribers.delete(connectionKey) - session.subscribers.set(connectionKey, ws) + session.subscribers.delete(sub) + session.subscribers.set(sub, ws) const cleanup = () => { - session.subscribers.delete(connectionKey) + session.subscribers.delete(sub) } const start = session.bufferCursor diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/routes/pty.ts index 368c9612bf..4e74fc9277 100644 --- a/packages/opencode/src/server/routes/pty.ts +++ b/packages/opencode/src/server/routes/pty.ts @@ -1,14 +1,13 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" -import { upgradeWebSocket } from "hono/bun" +import type { UpgradeWebSocket } from "hono/ws" import z from "zod" import { Pty } from "@/pty" import { NotFoundError } from "../../storage/db" import { errors } from "../error" -import { lazy } from "../../util/lazy" -export const PtyRoutes = lazy(() => - new Hono() +export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { + return new Hono() .get( "/", describeRoute({ @@ -196,5 +195,5 @@ export const PtyRoutes = lazy(() => }, } }), - ), -) + ) +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 3d435c8c99..601dc85a8f 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -34,7 +34,8 @@ import { ProviderRoutes } from "./routes/provider" import { InstanceBootstrap } from "../project/bootstrap" import { NotFoundError } from "../storage/db" import type { ContentfulStatusCode } from "hono/utils/http-status" -import { websocket } from "hono/bun" +import { createAdaptorServer, type ServerType } from "@hono/node-server" +import { createNodeWebSocket } from "@hono/node-ws" import { HTTPException } from "hono/http-exception" import { errors } from "./error" import { Filesystem } from "@/util/filesystem" @@ -48,13 +49,20 @@ import { lazy } from "@/util/lazy" globalThis.AI_SDK_LOG_WARNINGS = false export namespace Server { - const log = Log.create({ service: "server" }) + export type Listener = { + hostname: string + port: number + url: URL + stop: (close?: boolean) => Promise + } - export const Default = lazy(() => createApp({})) + export const Default = lazy(() => create({}).app) - export const createApp = (opts: { cors?: string[] }): Hono => { + function create(opts: { cors?: string[] }) { + const log = Log.create({ service: "server" }) const app = new Hono() - return app + const ws = createNodeWebSocket({ app }) + const route = app .onError((err, c) => { log.error("failed", { error: err, @@ -239,7 +247,6 @@ export namespace Server { ), ) .route("/project", ProjectRoutes()) - .route("/pty", PtyRoutes()) .route("/config", ConfigRoutes()) .route("/experimental", ExperimentalRoutes()) .route("/session", SessionRoutes()) @@ -552,6 +559,7 @@ export namespace Server { }) }, ) + .route("/pty", PtyRoutes(ws.upgradeWebSocket)) .all("/*", async (c) => { const path = c.req.path @@ -568,6 +576,11 @@ export namespace Server { ) return response }) + + return { + app: route as Hono, + ws, + } } export async function openapi() { @@ -585,48 +598,86 @@ export namespace Server { return result } - export function listen(opts: { + export async function listen(opts: { port: number hostname: string mdns?: boolean mdnsDomain?: string cors?: string[] - }) { - const app = createApp(opts) - const args = { - hostname: opts.hostname, - idleTimeout: 0, - fetch: app.fetch, - websocket: websocket, - } as const - const tryServe = (port: number) => { - try { - return Bun.serve({ ...args, port }) - } catch { - return undefined - } + }): Promise { + const log = Log.create({ service: "server" }) + const built = create({ + ...opts, + }) + const start = (port: number) => + new Promise((resolve, reject) => { + const server = createAdaptorServer({ fetch: built.app.fetch }) + built.ws.injectWebSocket(server) + const fail = (err: Error) => { + cleanup() + reject(err) + } + const ready = () => { + cleanup() + resolve(server) + } + const cleanup = () => { + server.off("error", fail) + server.off("listening", ready) + } + server.once("error", fail) + server.once("listening", ready) + server.listen(port, opts.hostname) + }) + + const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port) + const addr = server.address() + if (!addr || typeof addr === "string") { + throw new Error(`Failed to resolve server address for port ${opts.port}`) } - 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}`) + + const url = new URL("http://localhost") + url.hostname = opts.hostname + url.port = String(addr.port) const shouldPublishMDNS = opts.mdns && - server.port && + addr.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" if (shouldPublishMDNS) { - MDNS.publish(server.port!, opts.mdnsDomain) + MDNS.publish(addr.port, opts.mdnsDomain) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } - const originalStop = server.stop.bind(server) - server.stop = async (closeActiveConnections?: boolean) => { - if (shouldPublishMDNS) MDNS.unpublish() - return originalStop(closeActiveConnections) + let closing: Promise | undefined + return { + hostname: opts.hostname, + port: addr.port, + url, + stop(close?: boolean) { + closing ??= new Promise((resolve, reject) => { + if (shouldPublishMDNS) MDNS.unpublish() + server.close((err) => { + if (err) { + reject(err) + return + } + resolve() + }) + if (close) { + if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") { + server.closeAllConnections() + } + if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") { + server.closeIdleConnections() + } + } + }) + return closing + }, } - - return server } }