From 535343bf567af41cdecf0f130e6c75e3bae16cd6 Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 6 Apr 2026 13:24:55 -0400 Subject: [PATCH] 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 <10430890+Hona@users.noreply.github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Brendan Allan --- bun.lock | 39 +++++- package.json | 2 + packages/app/script/e2e-local.ts | 6 +- packages/opencode/package.json | 11 ++ packages/opencode/script/build-node.ts | 15 +++ packages/opencode/script/fix-node-pty.ts | 28 ++++ packages/opencode/src/cli/cmd/acp.ts | 2 +- packages/opencode/src/cli/cmd/serve.ts | 2 +- packages/opencode/src/cli/cmd/web.ts | 2 +- packages/opencode/src/plugin/index.ts | 3 +- packages/opencode/src/pty/index.ts | 30 ++--- packages/opencode/src/pty/pty.bun.ts | 26 ++++ packages/opencode/src/pty/pty.node.ts | 27 ++++ packages/opencode/src/pty/pty.ts | 25 ++++ packages/opencode/src/server/instance.ts | 7 +- packages/opencode/src/server/router.ts | 135 +++++++++---------- packages/opencode/src/server/routes/pty.ts | 13 +- packages/opencode/src/server/server.ts | 143 +++++++++++++-------- 18 files changed, 364 insertions(+), 152 deletions(-) mode change 100644 => 100755 packages/opencode/script/build-node.ts create mode 100755 packages/opencode/script/fix-node-pty.ts create mode 100644 packages/opencode/src/pty/pty.bun.ts create mode 100644 packages/opencode/src/pty/pty.node.ts create mode 100644 packages/opencode/src/pty/pty.ts diff --git a/bun.lock b/bun.lock index 0971a2cc66..7fb683433e 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/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], + "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "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/opencode-gitlab-auth@1.3.3", "", { "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/core@3.2.0", "", { "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/global-registrator@20.0.11", "", { "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/types@0.1.2", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="], - "@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="], + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + + "@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="], "@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="], @@ -1348,6 +1359,20 @@ "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], + "@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.10", "", { "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/node-pty-darwin-arm64@1.2.0-beta.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-C+eqDyRNHRYvx7RaHj6VVCx6nCpRBPuuxhTcc3JH3GuBMoxTsYeY4GkWH2XOktrgbAq1BG8e/Y8bu/wNQreCEw=="], + + "@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.2.0-beta.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZoIK6HtJO5BiT4ELm683U4dyHtt8b7wNgq3NJqYAQwSXrcPv576Z8vY3BIulVxfcFkht/SPLKou9TtdFXdNpg=="], + + "@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.2.0-beta.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-0cKX2iMyXFNBE4fGtGK6B7IkdXcDMZajyEDoGMOgQQs/DDtoI5tSPcBcqNY9VitVrsRQA8+gFt6eKYU9Ye/lUA=="], + + "@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.2.0-beta.10", "", { "os": "linux", "cpu": "x64" }, "sha512-J9HnxvSzEeMH748+Ul1VrmCLWMo7iCVJy9EGijRR62+YO/Yk5GaCydUTZ+KzlH0/X5aTrgt5cfiof4vx45tRRg=="], + + "@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.2.0-beta.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-PlDJpJX/pnKyy6OmADKzhf+INZDDnzTBGaI0LT4laVNc6NblZNqUSkCMjLFWbeakeuQp0VG37M49WQSN9FDfeA=="], + + "@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.2.0-beta.10", "", { "os": "win32", "cpu": "x64" }, "sha512-ExFgWrzyldNAMi45U9PLIOu+g/RatP+f0c/dZxaooifME6yLW32BoHveH26/TtoAjZyJrc2iL0u48pgnR1fzmg=="], + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "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": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], + "@gitlab/gitlab-ai-provider/openai": ["openai@6.33.0", "", { "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": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@gitlab/opencode-gitlab-auth/open": ["open@10.2.0", "", { "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": ["open@11.0.0", "", { "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": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "@hono/node-ws/ws": ["ws@8.20.0", "", { "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": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], @@ -5250,6 +5283,8 @@ "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="], + "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "@modelcontextprotocol/sdk/hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="], @@ -6084,6 +6119,8 @@ "@expressive-code/plugin-shiki/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + "@gitlab/opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "@jsx-email/cli/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], "@jsx-email/cli/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], diff --git a/package.json b/package.json index fc73a94d26..e4a2b5844f 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 70442d0d76..4f0f795a3a 100644 --- a/packages/app/script/e2e-local.ts +++ b/packages/app/script/e2e-local.ts @@ -87,7 +87,7 @@ const runnerEnv = { let seed: ReturnType | undefined let runner: ReturnType | undefined -let server: { stop: () => Promise | void } | undefined +let server: { stop: (close?: boolean) => Promise | void } | undefined let inst: { Instance: { disposeAll: () => Promise | 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 8cf7d76f22..cc80842177 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 old mode 100644 new mode 100755 index fc515a67a6..0e1d5bcf47 --- 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/node-pty@1.2.0-beta.10` + 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 0000000000..93641adbdf --- /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 99a9a81ab9..2fb9038b0f 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 ab51fe8c3e..73e7a18a70 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 0fe056f21f..e656c83d9a 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 df69c8eba7..d84d1cc7b6 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 a97f3373d1..0321b9800b 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 0000000000..1f8ce8e454 --- /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 0000000000..b45c5bf509 --- /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 0000000000..fbd1710e52 --- /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 +} + +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 0186bf4672..7cc7886b04 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 f64180892e..b239c62728 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", + }, + }) + } + + // 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 + + 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") + + 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, }) } - - 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) - }, - }) - } - - // 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) - } - - 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, - }) } diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/routes/pty.ts index de79801e28..c333f4dd69 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 ec245ed59f..3822da71eb 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 + } + 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 - } - } - 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}`) + }): Promise { + const built = create(opts) + const start = (port: number) => + new Promise((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 shouldPublishMDNS = + 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 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 | 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 } }