Parcourir la source

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

Dax Raad il y a 5 mois
Parent
commit
2724335b28

+ 2 - 0
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",

+ 1 - 1
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}`,

+ 1 - 1
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(() => {})

+ 3 - 4
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<BunWebSocketData> | undefined
+let server: Awaited<ReturnType<typeof Server.listen>> | 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)
   },
 }
 

+ 1 - 1
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()

+ 15 - 16
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

+ 5 - 6
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(() =>
           },
         }
       }),
-    ),
-)
+    )
+}

+ 82 - 31
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<void>
+  }
 
-  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<Listener> {
+    const log = Log.create({ service: "server" })
+    const built = create({
+      ...opts,
+    })
+    const start = (port: number) =>
+      new Promise<ServerType>((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<void> | 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
   }
 }