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.
This commit is contained in:
Dax Raad 2026-03-09 16:36:39 -04:00
commit 6c80e2662c
10 changed files with 113 additions and 179 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<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 = 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<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
}
}

View file

@ -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 },

View file

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

View file

@ -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,