From 6c80e2662c441df425789ff4aa14d7cf1f2216f9 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 9 Mar 2026 16:36:39 -0400 Subject: [PATCH] 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. --- bun.lock | 18 ++- packages/opencode/BUN_SHELL_MIGRATION_PLAN.md | 136 ------------------ packages/opencode/package.json | 2 + packages/opencode/script/build-node.ts | 8 ++ packages/opencode/src/node.ts | 1 + packages/opencode/src/server/routes/pty.ts | 4 +- packages/opencode/src/server/server.ts | 96 +++++++++---- packages/opencode/src/session/message-v2.ts | 12 +- packages/opencode/src/session/prompt.ts | 18 +-- packages/opencode/src/util/process.ts | 3 + 10 files changed, 116 insertions(+), 182 deletions(-) delete mode 100644 packages/opencode/BUN_SHELL_MIGRATION_PLAN.md create mode 100755 packages/opencode/script/build-node.ts create mode 100644 packages/opencode/src/node.ts diff --git a/bun.lock b/bun.lock index 3662baec44..fc29aedc8b 100644 --- a/bun.lock +++ b/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/types@0.1.2", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="], - "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + + "@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="], "@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="], @@ -2110,6 +2120,8 @@ "@types/scheduler": ["@types/scheduler@0.26.0", "", {}, "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA=="], + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], "@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="], @@ -5028,6 +5040,8 @@ "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "@hono/node-ws/ws": ["ws@8.19.0", "", { "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": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -5076,6 +5090,8 @@ "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "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": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], diff --git a/packages/opencode/BUN_SHELL_MIGRATION_PLAN.md b/packages/opencode/BUN_SHELL_MIGRATION_PLAN.md deleted file mode 100644 index 6cb21ac8f6..0000000000 --- a/packages/opencode/BUN_SHELL_MIGRATION_PLAN.md +++ /dev/null @@ -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. 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/script/build-node.ts b/packages/opencode/script/build-node.ts new file mode 100755 index 0000000000..75fd0964f1 --- /dev/null +++ b/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", +}) diff --git a/packages/opencode/src/node.ts b/packages/opencode/src/node.ts new file mode 100644 index 0000000000..b0e653d6cc --- /dev/null +++ b/packages/opencode/src/node.ts @@ -0,0 +1 @@ +export { Server } from "./server/server" diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/routes/pty.ts index 368c9612bf..b67c66537b 100644 --- a/packages/opencode/src/server/routes/pty.ts +++ b/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") diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e353198af7..53326fa127 100644 --- a/packages/opencode/src/server/server.ts +++ b/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) => { - try { - return Bun.serve({ ...args, port }) - } catch { - return undefined - } + const start = async (port: number) => { + const server = createAdaptorServer(App()) + ws.injectWebSocket(server) + await new Promise((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 = 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 server = await (async () => { + if (opts.port !== 0) return start(opts.port) + try { + return await start(4096) + } catch { + 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 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 | undefined + return { + hostname: opts.hostname, + port: addr.port, + url, + stop(close?: boolean) { + closing ??= new Promise((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 } } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 5b4e7bdbc0..eeb567f29e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/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 }, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7698b78bab..52b9d1b6ed 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/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 diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts index 473ee27dc9..1f254a72b9 100644 --- a/packages/opencode/src/util/process.ts +++ b/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 { @@ -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,