Procházet zdrojové kódy

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]>
Dax před 1 týdnem
rodič
revize
535343bf56

+ 38 - 1
bun.lock

@@ -329,8 +329,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",
@@ -1144,6 +1149,10 @@
 
     "@gar/promise-retry": ["@gar/[email protected]", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
 
+    "@gitlab/gitlab-ai-provider": ["@gitlab/[email protected]", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
+
+    "@gitlab/opencode-gitlab-auth": ["@gitlab/[email protected]", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
+
     "@graphql-typed-document-node/core": ["@graphql-typed-document-node/[email protected]", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
 
     "@happy-dom/global-registrator": ["@happy-dom/[email protected]", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
@@ -1156,7 +1165,9 @@
 
     "@hey-api/types": ["@hey-api/[email protected]", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="],
 
-    "@hono/node-server": ["@hono/[email protected]", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
+    "@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=="],
 
@@ -1348,6 +1359,20 @@
 
     "@lukeed/ms": ["@lukeed/[email protected]", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
 
+    "@lydell/node-pty": ["@lydell/[email protected]", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10", "@lydell/node-pty-darwin-x64": "1.2.0-beta.10", "@lydell/node-pty-linux-arm64": "1.2.0-beta.10", "@lydell/node-pty-linux-x64": "1.2.0-beta.10", "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", "@lydell/node-pty-win32-x64": "1.2.0-beta.10" } }, "sha512-Fv+A3+MZVA8qhkBIZsM1E6dCdHNMyXXz22mAYiMWd03LlyK///F3OH6CKPX9mj4id7LUlxpr45yPzyBVy9aDPw=="],
+
+    "@lydell/node-pty-darwin-arm64": ["@lydell/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-C+eqDyRNHRYvx7RaHj6VVCx6nCpRBPuuxhTcc3JH3GuBMoxTsYeY4GkWH2XOktrgbAq1BG8e/Y8bu/wNQreCEw=="],
+
+    "@lydell/node-pty-darwin-x64": ["@lydell/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZoIK6HtJO5BiT4ELm683U4dyHtt8b7wNgq3NJqYAQwSXrcPv576Z8vY3BIulVxfcFkht/SPLKou9TtdFXdNpg=="],
+
+    "@lydell/node-pty-linux-arm64": ["@lydell/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-0cKX2iMyXFNBE4fGtGK6B7IkdXcDMZajyEDoGMOgQQs/DDtoI5tSPcBcqNY9VitVrsRQA8+gFt6eKYU9Ye/lUA=="],
+
+    "@lydell/node-pty-linux-x64": ["@lydell/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-J9HnxvSzEeMH748+Ul1VrmCLWMo7iCVJy9EGijRR62+YO/Yk5GaCydUTZ+KzlH0/X5aTrgt5cfiof4vx45tRRg=="],
+
+    "@lydell/node-pty-win32-arm64": ["@lydell/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-PlDJpJX/pnKyy6OmADKzhf+INZDDnzTBGaI0LT4laVNc6NblZNqUSkCMjLFWbeakeuQp0VG37M49WQSN9FDfeA=="],
+
+    "@lydell/node-pty-win32-x64": ["@lydell/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-ExFgWrzyldNAMi45U9PLIOu+g/RatP+f0c/dZxaooifME6yLW32BoHveH26/TtoAjZyJrc2iL0u48pgnR1fzmg=="],
+
     "@malept/cross-spawn-promise": ["@malept/[email protected]", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="],
 
     "@malept/flatpak-bundler": ["@malept/[email protected]", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="],
@@ -5194,10 +5219,18 @@
 
     "@fastify/proxy-addr/ipaddr.js": ["[email protected]", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
 
+    "@gitlab/gitlab-ai-provider/openai": ["[email protected]", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="],
+
+    "@gitlab/gitlab-ai-provider/zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
+    "@gitlab/opencode-gitlab-auth/open": ["[email protected]", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
+
     "@hey-api/openapi-ts/open": ["[email protected]", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
 
     "@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-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
+
     "@hono/zod-validator/zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
 
     "@jimp/core/mime": ["[email protected]", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
@@ -5250,6 +5283,8 @@
 
     "@mdx-js/mdx/source-map": ["[email protected]", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
 
+    "@modelcontextprotocol/sdk/@hono/node-server": ["@hono/[email protected]", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
+
     "@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/hono": ["[email protected]", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
@@ -6084,6 +6119,8 @@
 
     "@expressive-code/plugin-shiki/shiki/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
 
+    "@gitlab/opencode-gitlab-auth/open/wsl-utils": ["[email protected]", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
+
     "@jsx-email/cli/esbuild/@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="],
 
     "@jsx-email/cli/esbuild/@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="],

+ 2 - 0
package.json

@@ -12,6 +12,7 @@
     "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
     "dev:storybook": "bun --cwd packages/storybook storybook",
     "typecheck": "bun turbo typecheck",
+    "postinstall": "bun run --cwd packages/opencode fix-node-pty",
     "prepare": "husky",
     "random": "echo 'Random script'",
     "hello": "echo 'Hello World!'",
@@ -103,6 +104,7 @@
   },
   "trustedDependencies": [
     "esbuild",
+    "node-pty",
     "protobufjs",
     "tree-sitter",
     "tree-sitter-bash",

+ 3 - 3
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`)

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

+ 15 - 0
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),

+ 28 - 0
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"}`)
+  }
+}

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

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

+ 2 - 1
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) {

+ 13 - 17
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

+ 26 - 0
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)
+    },
+  }
+}

+ 27 - 0
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)
+    },
+  }
+}

+ 25 - 0
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
+}

+ 4 - 3
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())

+ 65 - 62
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,
+    })
+  }
 }

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

+ 91 - 50
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
   }
 }