summaryrefslogtreecommitdiffhomepage
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
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]>
-rw-r--r--bun.lock39
-rw-r--r--package.json2
-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
18 files changed, 359 insertions, 147 deletions
diff --git a/bun.lock b/bun.lock
index 0971a2cc6..7fb683433 100644
--- a/bun.lock
+++ b/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=="],
diff --git a/package.json b/package.json
index fc73a94d2..e4a2b5844 100644
--- a/package.json
+++ b/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",
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
}
}