Преглед изворни кода

core: make server runtime-agnostic by migrating from Bun to Node.js HTTP/WebSocket APIs

This enables running the opencode server on standard Node.js runtimes without requiring Bun-specific APIs. Users can now deploy the server in more environments including standard Node.js containers and cloud platforms that don't support Bun.
Dax Raad пре 1 месец
родитељ
комит
6c80e2662c

+ 17 - 1
bun.lock

@@ -324,6 +324,8 @@
         "@clack/prompts": "1.0.0-alpha.1",
         "@gitlab/gitlab-ai-provider": "3.6.0",
         "@gitlab/opencode-gitlab-auth": "1.3.3",
+        "@hono/node-server": "1.19.11",
+        "@hono/node-ws": "1.3.0",
         "@hono/standard-validator": "0.1.5",
         "@hono/zod-validator": "catalog:",
         "@modelcontextprotocol/sdk": "1.25.2",
@@ -366,6 +368,7 @@
         "opentui-spinner": "0.0.6",
         "partial-json": "0.1.7",
         "remeda": "catalog:",
+        "semver": "^7.6.3",
         "solid-js": "catalog:",
         "strip-ansi": "7.1.2",
         "tree-sitter-bash": "0.25.0",
@@ -395,6 +398,7 @@
         "@types/babel__core": "7.20.5",
         "@types/bun": "catalog:",
         "@types/mime-types": "3.0.1",
+        "@types/semver": "^7.5.8",
         "@types/turndown": "5.0.5",
         "@types/which": "3.0.4",
         "@types/yargs": "17.0.33",
@@ -423,8 +427,12 @@
     },
     "packages/script": {
       "name": "@opencode-ai/script",
+      "dependencies": {
+        "semver": "^7.6.3",
+      },
       "devDependencies": {
         "@types/bun": "catalog:",
+        "@types/semver": "^7.5.8",
       },
     },
     "packages/sdk/js": {
@@ -1104,7 +1112,9 @@
 
     "@hey-api/types": ["@hey-api/[email protected]", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="],
 
-    "@hono/node-server": ["@hono/[email protected]", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
+    "@hono/node-server": ["@hono/[email protected]", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
+
+    "@hono/node-ws": ["@hono/[email protected]", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="],
 
     "@hono/standard-validator": ["@hono/[email protected]", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
 
@@ -2110,6 +2120,8 @@
 
     "@types/scheduler": ["@types/[email protected]", "", {}, "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA=="],
 
+    "@types/semver": ["@types/[email protected]", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
+
     "@types/send": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
 
     "@types/serve-static": ["@types/[email protected]", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
@@ -5028,6 +5040,8 @@
 
     "@hey-api/openapi-ts/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
 
+    "@hono/node-ws/ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
+
     "@hono/zod-validator/zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
 
     "@jimp/plugin-blit/zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -5076,6 +5090,8 @@
 
     "@mdx-js/mdx/source-map": ["[email protected]", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
 
+    "@modelcontextprotocol/sdk/@hono/node-server": ["@hono/[email protected]", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
+
     "@modelcontextprotocol/sdk/express": ["[email protected]", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
 
     "@modelcontextprotocol/sdk/jose": ["[email protected]", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],

+ 0 - 136
packages/opencode/BUN_SHELL_MIGRATION_PLAN.md

@@ -1,136 +0,0 @@
-# Bun shell migration plan
-
-Practical phased replacement of Bun `$` calls.
-
-## Goal
-
-Replace runtime Bun shell template-tag usage in `packages/opencode/src` with a unified `Process` API in `util/process.ts`.
-
-Keep behavior stable while improving safety, testability, and observability.
-
-Current baseline from audit:
-
-- 143 runtime command invocations across 17 files
-- 84 are git commands
-- Largest hotspots:
-  - `src/cli/cmd/github.ts` (33)
-  - `src/worktree/index.ts` (22)
-  - `src/lsp/server.ts` (21)
-  - `src/installation/index.ts` (20)
-  - `src/snapshot/index.ts` (18)
-
-## Decisions
-
-- Extend `src/util/process.ts` (do not create a separate exec module).
-- Proceed with phased migration for both git and non-git paths.
-- Keep plugin `$` compatibility in 1.x and remove in 2.0.
-
-## Non-goals
-
-- Do not remove plugin `$` compatibility in this effort.
-- Do not redesign command semantics beyond what is needed to preserve behavior.
-
-## Constraints
-
-- Keep migration phased, not big-bang.
-- Minimize behavioral drift.
-- Keep these explicit shell-only exceptions:
-  - `src/session/prompt.ts` raw command execution
-  - worktree start scripts in `src/worktree/index.ts`
-
-## Process API proposal (`src/util/process.ts`)
-
-Add higher-level wrappers on top of current spawn support.
-
-Core methods:
-
-- `Process.run(cmd, opts)`
-- `Process.text(cmd, opts)`
-- `Process.lines(cmd, opts)`
-- `Process.status(cmd, opts)`
-- `Process.shell(command, opts)` for intentional shell execution
-
-Git helpers:
-
-- `Process.git(args, opts)`
-- `Process.gitText(args, opts)`
-
-Shared options:
-
-- `cwd`, `env`, `stdin`, `stdout`, `stderr`, `abort`, `timeout`, `kill`
-- `allowFailure` / non-throw mode
-- optional redaction + trace metadata
-
-Standard result shape:
-
-- `code`, `stdout`, `stderr`, `duration_ms`, `cmd`
-- helpers like `text()` and `arrayBuffer()` where useful
-
-## Phased rollout
-
-### Phase 0: Foundation
-
-- Implement Process wrappers in `src/util/process.ts`.
-- Refactor `src/util/git.ts` to use Process only.
-- Add tests for exit handling, timeout, abort, and output capture.
-
-### Phase 1: High-impact hotspots
-
-Migrate these first:
-
-- `src/cli/cmd/github.ts`
-- `src/worktree/index.ts`
-- `src/lsp/server.ts`
-- `src/installation/index.ts`
-- `src/snapshot/index.ts`
-
-Within each file, migrate git paths first where applicable.
-
-### Phase 2: Remaining git-heavy files
-
-Migrate git-centric call sites to `Process.git*` helpers:
-
-- `src/file/index.ts`
-- `src/project/vcs.ts`
-- `src/file/watcher.ts`
-- `src/storage/storage.ts`
-- `src/cli/cmd/pr.ts`
-
-### Phase 3: Remaining non-git files
-
-Migrate residual non-git usages:
-
-- `src/cli/cmd/tui/util/clipboard.ts`
-- `src/util/archive.ts`
-- `src/file/ripgrep.ts`
-- `src/tool/bash.ts`
-- `src/cli/cmd/uninstall.ts`
-
-### Phase 4: Stabilize
-
-- Remove dead wrappers and one-off patterns.
-- Keep plugin `$` compatibility isolated and documented as temporary.
-- Create linked 2.0 task for plugin `$` removal.
-
-## Validation strategy
-
-- Unit tests for new `Process` methods and options.
-- Integration tests on hotspot modules.
-- Smoke tests for install, snapshot, worktree, and GitHub flows.
-- Regression checks for output parsing behavior.
-
-## Risk mitigation
-
-- File-by-file PRs with small diffs.
-- Preserve behavior first, simplify second.
-- Keep shell-only exceptions explicit and documented.
-- Add consistent error shaping and logging at Process layer.
-
-## Definition of done
-
-- Runtime Bun `$` usage in `packages/opencode/src` is removed except:
-  - approved shell-only exceptions
-  - temporary plugin compatibility path (1.x)
-- Git paths use `Process.git*` consistently.
-- CI and targeted smoke tests pass.
-- 2.0 issue exists for plugin `$` removal.

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

+ 8 - 0
packages/opencode/script/build-node.ts

@@ -0,0 +1,8 @@
+#!/usr/bin/env bun
+
+Bun.build({
+  entrypoints: ["./src/node.ts"],
+  target: "node",
+  outdir: "./dist",
+  format: "esm",
+})

+ 1 - 0
packages/opencode/src/node.ts

@@ -0,0 +1 @@
+export { Server } from "./server/server"

+ 2 - 2
packages/opencode/src/server/routes/pty.ts

@@ -1,11 +1,11 @@
 import { Hono } from "hono"
 import { describeRoute, validator, resolver } from "hono-openapi"
-import { upgradeWebSocket } from "hono/bun"
 import z from "zod"
 import { Pty } from "@/pty"
 import { NotFoundError } from "../../storage/db"
 import { errors } from "../error"
 import { lazy } from "../../util/lazy"
+import { Server } from "../server"
 
 export const PtyRoutes = lazy(() =>
   new Hono()
@@ -149,7 +149,7 @@ export const PtyRoutes = lazy(() =>
         },
       }),
       validator("param", z.object({ ptyID: z.string() })),
-      upgradeWebSocket((c) => {
+      Server.upgradeWebSocket((c) => {
         const id = c.req.param("ptyID")
         const cursor = (() => {
           const value = c.req.query("cursor")

+ 67 - 23
packages/opencode/src/server/server.ts

@@ -35,7 +35,8 @@ import { lazy } from "../util/lazy"
 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 } 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"
@@ -58,6 +59,8 @@ export namespace Server {
   }
 
   const app = new Hono()
+  const ws = createNodeWebSocket({ app })
+  export const upgradeWebSocket = ws.upgradeWebSocket
   export const App: () => Hono = lazy(
     () =>
       // TODO: Break server.ts into smaller route files to fix type inference
@@ -246,7 +249,7 @@ export namespace Server {
           ),
         )
         .route("/project", ProjectRoutes())
-        .route("/pty", PtyRoutes())
+        // .route("/pty", PtyRoutes())
         .route("/config", ConfigRoutes())
         .route("/experimental", ExperimentalRoutes())
         .route("/session", SessionRoutes())
@@ -594,7 +597,7 @@ export namespace Server {
     return result
   }
 
-  export function listen(opts: {
+  export async function listen(opts: {
     port: number
     hostname: string
     mdns?: boolean
@@ -603,42 +606,83 @@ export namespace Server {
   }) {
     _corsWhitelist = opts.cors ?? []
 
-    const args = {
-      hostname: opts.hostname,
-      idleTimeout: 0,
-      fetch: App().fetch,
-      websocket: websocket,
-    } as const
-    const tryServe = (port: number) => {
+    const start = async (port: number) => {
+      const server = createAdaptorServer(App())
+      ws.injectWebSocket(server)
+      await new Promise<void>((resolve, reject) => {
+        const fail = (err: Error) => {
+          cleanup()
+          reject(err)
+        }
+        const ready = () => {
+          cleanup()
+          resolve()
+        }
+        const cleanup = () => {
+          server.off("error", fail)
+          server.off("listening", ready)
+        }
+        server.once("error", fail)
+        server.once("listening", ready)
+        server.listen(port, opts.hostname)
+      })
+      return server
+    }
+
+    const server = await (async () => {
+      if (opts.port !== 0) return start(opts.port)
       try {
-        return Bun.serve({ ...args, port })
+        return await start(4096)
       } catch {
-        return undefined
+        return start(0)
       }
+    })()
+
+    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}`)
 
-    _url = server.url
+    const url = new URL("http://localhost")
+    url.hostname = opts.hostname
+    url.port = String(addr.port)
+    _url = url
 
     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<void>((resolve, reject) => {
+          if (shouldPublishMDNS) MDNS.unpublish()
+          server.close((err?: Error) => {
+            if (err) {
+              reject(err)
+              return
+            }
+            resolve()
+          })
+          if (close) {
+            const node = server as { closeAllConnections?: () => void; closeIdleConnections?: () => void }
+            node.closeAllConnections?.()
+            node.closeIdleConnections?.()
+          }
+        })
+        return closing
+      },
     }
-
-    return server
   }
 }

+ 4 - 8
packages/opencode/src/session/message-v2.ts

@@ -8,12 +8,8 @@ import { Snapshot } from "@/snapshot"
 import { fn } from "@/util/fn"
 import { Database, eq, desc, inArray } from "@/storage/db"
 import { MessageTable, PartTable } from "./session.sql"
-import { ProviderTransform } from "@/provider/transform"
-import { STATUS_CODES } from "http"
-import { Storage } from "@/storage/storage"
 import { ProviderError } from "@/provider/error"
 import { iife } from "@/util/iife"
-import { type SystemError } from "bun"
 import type { Provider } from "@/provider/provider"
 
 export namespace MessageV2 {
@@ -843,15 +839,15 @@ export namespace MessageV2 {
           },
           { cause: e },
         ).toObject()
-      case (e as SystemError)?.code === "ECONNRESET":
+      case (e as any)?.code === "ECONNRESET":
         return new MessageV2.APIError(
           {
             message: "Connection reset by server",
             isRetryable: true,
             metadata: {
-              code: (e as SystemError).code ?? "",
-              syscall: (e as SystemError).syscall ?? "",
-              message: (e as SystemError).message ?? "",
+              code: (e as any).code ?? "",
+              syscall: (e as any).syscall ?? "",
+              message: (e as any).message ?? "",
             },
           },
           { cause: e },

+ 9 - 9
packages/opencode/src/session/prompt.ts

@@ -29,9 +29,11 @@ import { ReadTool } from "../tool/read"
 import { FileTime } from "../file/time"
 import { Flag } from "../flag/flag"
 import { ulid } from "ulid"
+import { Process } from "../util/process"
 import { spawn } from "child_process"
+
 import { Command } from "../command"
-import { $ } from "bun"
+
 import { pathToFileURL, fileURLToPath } from "url"
 import { ConfigMarkdown } from "../config/markdown"
 import { SessionSummary } from "./summary"
@@ -1778,15 +1780,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
       template = template + "\n\n" + input.arguments
     }
 
-    const shell = ConfigMarkdown.shell(template)
-    if (shell.length > 0) {
+    const shellMatches = ConfigMarkdown.shell(template)
+    if (shellMatches.length > 0) {
+      const sh = Shell.preferred()
       const results = await Promise.all(
-        shell.map(async ([, cmd]) => {
-          try {
-            return await $`${{ raw: cmd }}`.quiet().nothrow().text()
-          } catch (error) {
-            return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
-          }
+        shellMatches.map(async ([, cmd]) => {
+          const out = await Process.text([cmd], { shell: sh, nothrow: true })
+          return out.text
         }),
       )
       let index = 0

+ 3 - 0
packages/opencode/src/util/process.ts

@@ -13,6 +13,7 @@ export namespace Process {
     abort?: AbortSignal
     kill?: NodeJS.Signals | number
     timeout?: number
+    shell?: string | boolean
   }
 
   export interface RunOptions extends Omit<Options, "stdout" | "stderr"> {
@@ -59,6 +60,7 @@ export namespace Process {
     const proc = launch(cmd[0], cmd.slice(1), {
       cwd: opts.cwd,
       env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
+      shell: opts.shell,
       stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
     })
 
@@ -108,6 +110,7 @@ export namespace Process {
     const proc = spawn(cmd, {
       cwd: opts.cwd,
       env: opts.env,
+      shell: opts.shell,
       stdin: opts.stdin,
       abort: opts.abort,
       kill: opts.kill,