summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorDax <[email protected]>2026-04-06 13:24:55 -0400
committerGitHub <[email protected]>2026-04-06 13:24:55 -0400
commit535343bf567af41cdecf0f130e6c75e3bae16cd6 (patch)
treea2c1b05e0ece64f3825bdeab96f5874dc06dca8d /packages
parent4394e42615386d5246bf8a4ac9d2357f1242c687 (diff)
downloadopencode-535343bf567af41cdecf0f130e6c75e3bae16cd6.tar.gz
opencode-535343bf567af41cdecf0f130e6c75e3bae16cd6.zip
refactor(server): replace Bun serve with Hono node adapters (#18335)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Luke Parker <[email protected]> Co-authored-by: Adam <[email protected]> Co-authored-by: Brendan Allan <[email protected]>
Diffstat (limited to 'packages')
-rw-r--r--packages/app/script/e2e-local.ts6
-rw-r--r--packages/opencode/package.json11
-rwxr-xr-x[-rw-r--r--]packages/opencode/script/build-node.ts15
-rwxr-xr-xpackages/opencode/script/fix-node-pty.ts28
-rw-r--r--packages/opencode/src/cli/cmd/acp.ts2
-rw-r--r--packages/opencode/src/cli/cmd/serve.ts2
-rw-r--r--packages/opencode/src/cli/cmd/web.ts2
-rw-r--r--packages/opencode/src/plugin/index.ts3
-rw-r--r--packages/opencode/src/pty/index.ts30
-rw-r--r--packages/opencode/src/pty/pty.bun.ts26
-rw-r--r--packages/opencode/src/pty/pty.node.ts27
-rw-r--r--packages/opencode/src/pty/pty.ts25
-rw-r--r--packages/opencode/src/server/instance.ts7
-rw-r--r--packages/opencode/src/server/router.ts127
-rw-r--r--packages/opencode/src/server/routes/pty.ts13
-rw-r--r--packages/opencode/src/server/server.ts141
16 files changed, 319 insertions, 146 deletions
diff --git a/packages/app/script/e2e-local.ts b/packages/app/script/e2e-local.ts
index 70442d0d7..4f0f795a3 100644
--- a/packages/app/script/e2e-local.ts
+++ b/packages/app/script/e2e-local.ts
@@ -87,7 +87,7 @@ const runnerEnv = {
let seed: ReturnType<typeof Bun.spawn> | undefined
let runner: ReturnType<typeof Bun.spawn> | undefined
-let server: { stop: () => Promise<void> | void } | undefined
+let server: { stop: (close?: boolean) => Promise<void> | void } | undefined
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
let cleaned = false
@@ -100,7 +100,7 @@ const cleanup = async () => {
const jobs = [
inst?.Instance.disposeAll(),
- server?.stop(),
+ typeof server?.stop === "function" ? server.stop() : undefined,
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
].filter(Boolean)
await Promise.allSettled(jobs)
@@ -158,7 +158,7 @@ try {
const servermod = await import("../../opencode/src/server/server")
inst = await import("../../opencode/src/project/instance")
- server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
+ server = await servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 8cf7d76f2..cc8084217 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -11,6 +11,7 @@
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"build": "bun run script/build.ts",
+ "fix-node-pty": "bun run script/fix-node-pty.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
@@ -33,6 +34,11 @@
"bun": "./src/storage/db.bun.ts",
"node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts"
+ },
+ "#pty": {
+ "bun": "./src/pty/pty.bun.ts",
+ "node": "./src/pty/pty.node.ts",
+ "default": "./src/pty/pty.bun.ts"
}
},
"devDependencies": {
@@ -94,8 +100,13 @@
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
+ "@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:",
+ "@lydell/node-pty": "1.2.0-beta.10",
"@modelcontextprotocol/sdk": "1.27.1",
"@npmcli/arborist": "9.4.0",
"@octokit/graphql": "9.0.2",
diff --git a/packages/opencode/script/build-node.ts b/packages/opencode/script/build-node.ts
index fc515a67a..0e1d5bcf4 100644..100755
--- a/packages/opencode/script/build-node.ts
+++ b/packages/opencode/script/build-node.ts
@@ -1,5 +1,6 @@
#!/usr/bin/env bun
+import { $ } from "bun"
import { Script } from "@opencode-ai/script"
import fs from "fs"
import path from "path"
@@ -8,6 +9,15 @@ import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
+const root = path.resolve(dir, "../..")
+
+function linker(): "hoisted" | "isolated" {
+ // jsonc-parser is only declared in packages/opencode, so its install location
+ // tells us whether Bun used a hoisted or isolated workspace layout.
+ if (fs.existsSync(path.join(dir, "node_modules", "jsonc-parser"))) return "isolated"
+ if (fs.existsSync(path.join(root, "node_modules", "jsonc-parser"))) return "hoisted"
+ throw new Error("Could not detect Bun linker from jsonc-parser")
+}
process.chdir(dir)
@@ -41,11 +51,16 @@ const migrations = await Promise.all(
)
console.log(`Loaded ${migrations.length} migrations`)
+const link = linker()
+
+await $`bun install --linker=${link} --os="*" --cpu="*" @lydell/[email protected]`
+
await Bun.build({
target: "node",
entrypoints: ["./src/node.ts"],
outdir: "./dist",
format: "esm",
+ sourcemap: "linked",
external: ["jsonc-parser"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
diff --git a/packages/opencode/script/fix-node-pty.ts b/packages/opencode/script/fix-node-pty.ts
new file mode 100755
index 000000000..93641adbd
--- /dev/null
+++ b/packages/opencode/script/fix-node-pty.ts
@@ -0,0 +1,28 @@
+#!/usr/bin/env bun
+
+import fs from "fs/promises"
+import path from "path"
+import { fileURLToPath } from "url"
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+const dir = path.resolve(__dirname, "..")
+
+if (process.platform !== "win32") {
+ const root = path.join(dir, "node_modules", "node-pty", "prebuilds")
+ const dirs = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
+ const files = dirs.filter((x) => x.isDirectory()).map((x) => path.join(root, x.name, "spawn-helper"))
+ const result = await Promise.all(
+ files.map(async (file) => {
+ const stat = await fs.stat(file).catch(() => undefined)
+ if (!stat) return
+ if ((stat.mode & 0o111) === 0o111) return
+ await fs.chmod(file, stat.mode | 0o755)
+ return file
+ }),
+ )
+ const fixed = result.filter(Boolean)
+ if (fixed.length) {
+ console.log(`fixed node-pty permissions for ${fixed.length} helper${fixed.length === 1 ? "" : "s"}`)
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts
index 99a9a81ab..2fb9038b0 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 ab51fe8c3..73e7a18a7 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/web.ts b/packages/opencode/src/cli/cmd/web.ts
index 0fe056f21..e656c83d9 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/plugin/index.ts b/packages/opencode/src/plugin/index.ts
index df69c8eba..d84d1cc7b 100644
--- a/packages/opencode/src/plugin/index.ts
+++ b/packages/opencode/src/plugin/index.ts
@@ -130,7 +130,8 @@ export namespace Plugin {
get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096")
},
- $: Bun.$,
+ // @ts-expect-error
+ $: typeof Bun === "undefined" ? undefined : Bun.$,
}
for (const plugin of INTERNAL_PLUGINS) {
diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts
index a97f3373d..0321b9800 100644
--- a/packages/opencode/src/pty/index.ts
+++ b/packages/opencode/src/pty/index.ts
@@ -3,7 +3,7 @@ import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Instance } from "@/project/instance"
-import { type IPty } from "bun-pty"
+import type { Proc } from "#pty"
import z from "zod"
import { Log } from "../util/log"
import { lazy } from "@opencode-ai/util/lazy"
@@ -26,9 +26,11 @@ export namespace Pty {
close: (code?: number, reason?: string) => void
}
+ const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
+
type Active = {
info: Info
- process: IPty
+ process: Proc
buffer: string
bufferCursor: number
cursor: number
@@ -50,10 +52,7 @@ export namespace Pty {
return out
}
- const pty = lazy(async () => {
- const { spawn } = await import("bun-pty")
- return spawn
- })
+ const pty = lazy(() => import("#pty"))
export const Info = z
.object({
@@ -124,9 +123,9 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
- for (const [key, ws] of session.subscribers.entries()) {
+ for (const [sub, ws] of session.subscribers.entries()) {
try {
- if (ws.data === key) ws.close()
+ if (sock(ws) === sub) ws.close()
} catch {}
}
session.subscribers.clear()
@@ -198,7 +197,7 @@ export namespace Pty {
}
log.info("creating session", { id, cmd: command, args, cwd })
- const spawn = yield* Effect.promise(() => pty())
+ const { spawn } = yield* Effect.promise(() => pty())
const proc = yield* Effect.sync(() =>
spawn(command, args, {
name: "xterm-256color",
@@ -234,7 +233,7 @@ export namespace Pty {
session.subscribers.delete(key)
continue
}
- if (ws.data !== key) {
+ if (sock(ws) !== key) {
session.subscribers.delete(key)
continue
}
@@ -304,15 +303,12 @@ 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 key = ws.data && typeof ws.data === "object" ? ws.data : ws
- // Optionally cleanup if the key somehow exists
- session.subscribers.delete(key)
- session.subscribers.set(key, ws)
+ const sub = sock(ws)
+ session.subscribers.delete(sub)
+ session.subscribers.set(sub, ws)
const cleanup = () => {
- session.subscribers.delete(key)
+ session.subscribers.delete(sub)
}
const start = session.bufferCursor
diff --git a/packages/opencode/src/pty/pty.bun.ts b/packages/opencode/src/pty/pty.bun.ts
new file mode 100644
index 000000000..1f8ce8e45
--- /dev/null
+++ b/packages/opencode/src/pty/pty.bun.ts
@@ -0,0 +1,26 @@
+import { spawn as create } from "bun-pty"
+import type { Opts, Proc } from "./pty"
+
+export type { Disp, Exit, Opts, Proc } from "./pty"
+
+export function spawn(file: string, args: string[], opts: Opts): Proc {
+ const pty = create(file, args, opts)
+ return {
+ pid: pty.pid,
+ onData(listener) {
+ return pty.onData(listener)
+ },
+ onExit(listener) {
+ return pty.onExit(listener)
+ },
+ write(data) {
+ pty.write(data)
+ },
+ resize(cols, rows) {
+ pty.resize(cols, rows)
+ },
+ kill(signal) {
+ pty.kill(signal)
+ },
+ }
+}
diff --git a/packages/opencode/src/pty/pty.node.ts b/packages/opencode/src/pty/pty.node.ts
new file mode 100644
index 000000000..b45c5bf50
--- /dev/null
+++ b/packages/opencode/src/pty/pty.node.ts
@@ -0,0 +1,27 @@
+/** @ts-expect-error */
+import * as pty from "@lydell/node-pty"
+import type { Opts, Proc } from "./pty"
+
+export type { Disp, Exit, Opts, Proc } from "./pty"
+
+export function spawn(file: string, args: string[], opts: Opts): Proc {
+ const proc = pty.spawn(file, args, opts)
+ return {
+ pid: proc.pid,
+ onData(listener) {
+ return proc.onData(listener)
+ },
+ onExit(listener) {
+ return proc.onExit(listener)
+ },
+ write(data) {
+ proc.write(data)
+ },
+ resize(cols, rows) {
+ proc.resize(cols, rows)
+ },
+ kill(signal) {
+ proc.kill(signal)
+ },
+ }
+}
diff --git a/packages/opencode/src/pty/pty.ts b/packages/opencode/src/pty/pty.ts
new file mode 100644
index 000000000..fbd1710e5
--- /dev/null
+++ b/packages/opencode/src/pty/pty.ts
@@ -0,0 +1,25 @@
+export type Disp = {
+ dispose(): void
+}
+
+export type Exit = {
+ exitCode: number
+ signal?: number | string
+}
+
+export type Opts = {
+ name: string
+ cols?: number
+ rows?: number
+ cwd?: string
+ env?: Record<string, string>
+}
+
+export type Proc = {
+ pid: number
+ onData(listener: (data: string) => void): Disp
+ onExit(listener: (event: Exit) => void): Disp
+ write(data: string): void
+ resize(cols: number, rows: number): void
+ kill(signal?: string): void
+}
diff --git a/packages/opencode/src/server/instance.ts b/packages/opencode/src/server/instance.ts
index 0186bf467..7cc7886b0 100644
--- a/packages/opencode/src/server/instance.ts
+++ b/packages/opencode/src/server/instance.ts
@@ -1,6 +1,7 @@
import { describeRoute, resolver, validator } from "hono-openapi"
import { Hono } from "hono"
import { proxy } from "hono/proxy"
+import type { UpgradeWebSocket } from "hono/ws"
import z from "zod"
import { createHash } from "node:crypto"
import { Log } from "../util/log"
@@ -41,11 +42,11 @@ const DEFAULT_CSP =
const csp = (hash = "") =>
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
-export const InstanceRoutes = (app?: Hono) =>
- (app ?? new Hono())
+export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()) =>
+ app
.onError(errorHandler(log))
.route("/project", ProjectRoutes())
- .route("/pty", PtyRoutes())
+ .route("/pty", PtyRoutes(upgrade))
.route("/config", ConfigRoutes())
.route("/experimental", ExperimentalRoutes())
.route("/session", SessionRoutes())
diff --git a/packages/opencode/src/server/router.ts b/packages/opencode/src/server/router.ts
index f64180892..b239c6272 100644
--- a/packages/opencode/src/server/router.ts
+++ b/packages/opencode/src/server/router.ts
@@ -1,4 +1,5 @@
import type { MiddlewareHandler } from "hono"
+import type { UpgradeWebSocket } from "hono/ws"
import { getAdaptor } from "@/control-plane/adaptors"
import { WorkspaceID } from "@/control-plane/schema"
import { Workspace } from "@/control-plane/workspace"
@@ -24,76 +25,78 @@ function local(method: string, path: string) {
return false
}
-const routes = lazy(() => InstanceRoutes())
+export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
+ const routes = lazy(() => InstanceRoutes(upgrade))
-export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c) => {
- const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
- const directory = Filesystem.resolve(
- (() => {
- try {
- return decodeURIComponent(raw)
- } catch {
- return raw
- }
- })(),
- )
+ return async (c) => {
+ const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
+ const directory = Filesystem.resolve(
+ (() => {
+ try {
+ return decodeURIComponent(raw)
+ } catch {
+ return raw
+ }
+ })(),
+ )
- const url = new URL(c.req.url)
- const workspaceParam = url.searchParams.get("workspace")
+ const url = new URL(c.req.url)
+ const workspaceParam = url.searchParams.get("workspace")
- // TODO: If session is being routed, force it to lookup the
- // project/workspace
+ // TODO: If session is being routed, force it to lookup the
+ // project/workspace
- // If no workspace is provided we use the "project" workspace
- if (!workspaceParam) {
- return Instance.provide({
- directory,
- init: InstanceBootstrap,
- async fn() {
- return routes().fetch(c.req.raw, c.env)
- },
- })
- }
+ // If no workspace is provided we use the "project" workspace
+ if (!workspaceParam) {
+ return Instance.provide({
+ directory,
+ init: InstanceBootstrap,
+ async fn() {
+ return routes().fetch(c.req.raw, c.env)
+ },
+ })
+ }
- const workspaceID = WorkspaceID.make(workspaceParam)
- const workspace = await Workspace.get(workspaceID)
- if (!workspace) {
- return new Response(`Workspace not found: ${workspaceID}`, {
- status: 500,
- headers: {
- "content-type": "text/plain; charset=utf-8",
- },
- })
- }
+ const workspaceID = WorkspaceID.make(workspaceParam)
+ const workspace = await Workspace.get(workspaceID)
+ if (!workspace) {
+ return new Response(`Workspace not found: ${workspaceID}`, {
+ status: 500,
+ headers: {
+ "content-type": "text/plain; charset=utf-8",
+ },
+ })
+ }
- // Handle local workspaces directly so we can pass env to `fetch`,
- // necessary for websocket upgrades
- if (workspace.type === "worktree") {
- return Instance.provide({
- directory: workspace.directory!,
- init: InstanceBootstrap,
- async fn() {
- return routes().fetch(c.req.raw, c.env)
- },
- })
- }
+ // Handle local workspaces directly so we can pass env to `fetch`,
+ // necessary for websocket upgrades
+ if (workspace.type === "worktree") {
+ return Instance.provide({
+ directory: workspace.directory!,
+ init: InstanceBootstrap,
+ async fn() {
+ return routes().fetch(c.req.raw, c.env)
+ },
+ })
+ }
- // Remote workspaces
+ // Remote workspaces
- if (local(c.req.method, url.pathname)) {
- // No instance provided because we are serving cached data; there
- // is no instance to work with
- return routes().fetch(c.req.raw, c.env)
- }
+ if (local(c.req.method, url.pathname)) {
+ // No instance provided because we are serving cached data; there
+ // is no instance to work with
+ return routes().fetch(c.req.raw, c.env)
+ }
- const adaptor = await getAdaptor(workspace.type)
- const headers = new Headers(c.req.raw.headers)
- headers.delete("x-opencode-workspace")
+ const adaptor = await getAdaptor(workspace.type)
+ const headers = new Headers(c.req.raw.headers)
+ headers.delete("x-opencode-workspace")
- return adaptor.fetch(workspace, `${url.pathname}${url.search}`, {
- method: c.req.method,
- body: c.req.method === "GET" || c.req.method === "HEAD" ? undefined : await c.req.raw.arrayBuffer(),
- signal: c.req.raw.signal,
- headers,
- })
+ return adaptor.fetch(workspace, `${url.pathname}${url.search}`, {
+ method: c.req.method,
+ body: c.req.method === "GET" || c.req.method === "HEAD" ? undefined : await c.req.raw.arrayBuffer(),
+ signal: c.req.raw.signal,
+ headers,
+ })
+ }
}
diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/routes/pty.ts
index de79801e2..c333f4dd6 100644
--- a/packages/opencode/src/server/routes/pty.ts
+++ b/packages/opencode/src/server/routes/pty.ts
@@ -1,15 +1,14 @@
-import { Hono } from "hono"
+import { Hono, type MiddlewareHandler } 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 { PtyID } from "@/pty/schema"
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({
@@ -207,5 +206,5 @@ export const PtyRoutes = lazy(() =>
},
}
}),
- ),
-)
+ )
+}
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index ec245ed59..3822da71e 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -4,12 +4,14 @@ import { Hono } from "hono"
import { compress } from "hono/compress"
import { cors } from "hono/cors"
import { basicAuth } from "hono/basic-auth"
+import type { UpgradeWebSocket } from "hono/ws"
import z from "zod"
import { Auth } from "../auth"
import { Flag } from "../flag/flag"
import { ProviderID } from "../provider/schema"
+import { createAdaptorServer, type ServerType } from "@hono/node-server"
+import { createNodeWebSocket } from "@hono/node-ws"
import { WorkspaceRouterMiddleware } from "./router"
-import { websocket } from "hono/bun"
import { errors } from "./error"
import { GlobalRoutes } from "./routes/global"
import { MDNS } from "./mdns"
@@ -24,8 +26,14 @@ globalThis.AI_SDK_LOG_WARNINGS = false
initProjectors()
export namespace Server {
- const log = Log.create({ service: "server" })
+ export type Listener = {
+ hostname: string
+ port: number
+ url: URL
+ stop: (close?: boolean) => Promise<void>
+ }
+ const log = Log.create({ service: "server" })
const zipped = compress()
const skipCompress = (path: string, method: string) => {
@@ -34,10 +42,9 @@ export namespace Server {
return false
}
- export const Default = lazy(() => ControlPlaneRoutes())
+ export const Default = lazy(() => create({}).app)
- export const ControlPlaneRoutes = (opts?: { cors?: string[] }): Hono => {
- const app = new Hono()
+ export function ControlPlaneRoutes(upgrade: UpgradeWebSocket, app = new Hono(), opts?: { cors?: string[] }): Hono {
return app
.onError(errorHandler(log))
.use((c, next) => {
@@ -62,9 +69,7 @@ export namespace Server {
path: c.req.path,
})
await next()
- if (!skip) {
- timer.stop()
- }
+ if (!skip) timer.stop()
})
.use(
cors({
@@ -81,15 +86,8 @@ export namespace Server {
)
return input
- // *.opencode.ai (https only, adjust if needed)
- if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
- return input
- }
- if (opts?.cors?.includes(input)) {
- return input
- }
-
- return
+ if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
+ if (opts?.cors?.includes(input)) return input
},
}),
)
@@ -234,11 +232,20 @@ export namespace Server {
return c.json(true)
},
)
- .use(WorkspaceRouterMiddleware)
+ .use(WorkspaceRouterMiddleware(upgrade))
+ }
+
+ function create(opts: { cors?: string[] }) {
+ const app = new Hono()
+ const ws = createNodeWebSocket({ app })
+ return {
+ app: ControlPlaneRoutes(ws.upgradeWebSocket, app, opts),
+ ws,
+ }
}
export function createApp(opts: { cors?: string[] }) {
- return ControlPlaneRoutes(opts)
+ return create(opts).app
}
export async function openapi() {
@@ -246,8 +253,8 @@ export namespace Server {
// hono-openapi can see describeRoute metadata (`.route()` wraps
// handlers when the sub-app has a custom errorHandler, which
// strips the metadata symbol).
- const app = ControlPlaneRoutes()
- InstanceRoutes(app)
+ const { app, ws } = create({})
+ InstanceRoutes(ws.upgradeWebSocket, app)
const result = await generateSpecs(app, {
documentation: {
info: {
@@ -261,52 +268,86 @@ export namespace Server {
return result
}
- /** @deprecated do not use this dumb shit */
export let url: URL
- export function listen(opts: {
+ export async function listen(opts: {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
- }) {
- url = new URL(`http://${opts.hostname}:${opts.port}`)
- const app = ControlPlaneRoutes({ cors: 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
- }
+ }): Promise<Listener> {
+ 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 shouldPublishMDNS =
+ const next = new URL("http://localhost")
+ next.hostname = opts.hostname
+ next.port = String(addr.port)
+ url = next
+
+ const mdns =
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)
+ if (mdns) {
+ 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: next,
+ stop(close?: boolean) {
+ closing ??= new Promise((resolve, reject) => {
+ if (mdns) 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
}
}