From 447d4baa30aeb0042ae657f3f160d9a8cda7cdb2 Mon Sep 17 00:00:00 2001 From: yikZero Date: Wed, 26 Aug 2026 08:07:49 +0800 Subject: [PATCH] fix: support Node runtime in OpenCode Desktop --- README.md | 4 +- package.json | 3 +- src/bridge-pool.ts | 18 ++--- src/cursor-rpc.ts | 11 +-- src/proxy.ts | 22 +++--- src/runtime.ts | 176 ++++++++++++++++++++++++++++++++++++++++++ test/node-runtime.mjs | 71 +++++++++++++++++ 7 files changed, 276 insertions(+), 29 deletions(-) create mode 100644 src/runtime.ts create mode 100644 test/node-runtime.mjs diff --git a/README.md b/README.md index 87b04e6..e6da0a8 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Model listings map Cursor’s variant catalog into OpenCode-native choices (`Opu - [OpenCode](https://opencode.ai) - Active Cursor subscription -- Bun (plugin runtime) · Node.js ≥ 18 (HTTP/2 bridge) +- Bun or Node.js ≥ 18 (including OpenCode Desktop's Electron runtime) ## Development @@ -120,6 +120,8 @@ Model listings map Cursor’s variant catalog into OpenCode-native choices (`Opu bun install bun run build bun run test +# Node/Electron runtime regression only +bun run test:node ``` Optional knobs: `OPENCODE_CURSOR_PRE_OUTPUT_STALL_TIMEOUT_MS`, `OPENCODE_CURSOR_POST_TOOL_PRE_OUTPUT_STALL_TIMEOUT_MS`, `OPENCODE_CURSOR_TOOL_DEBOUNCE_MS`. diff --git a/package.json b/package.json index 7afabf7..362eae1 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ ], "scripts": { "build": "tsc -p tsconfig.json && node scripts/copy-runtime.mjs", - "test": "bun test/smoke.ts", + "test": "bun test/smoke.ts && bun run test:node", + "test:node": "bun run build && node test/node-runtime.mjs", "prepublishOnly": "npm run build" }, "repository": { diff --git a/src/bridge-pool.ts b/src/bridge-pool.ts index 5b4f21d..0703cde 100644 --- a/src/bridge-pool.ts +++ b/src/bridge-pool.ts @@ -9,9 +9,12 @@ * across requests: after a stream completes, the worker returns to * the idle pool. Dead workers are replaced automatically. */ -import { resolve as pathResolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnNodeBridge, type RuntimeProcess } from "./runtime.js"; -const PERSISTENT_BRIDGE_PATH = pathResolve(import.meta.dir, "h2-bridge-persistent.mjs"); +const PERSISTENT_BRIDGE_PATH = fileURLToPath( + new URL("./h2-bridge-persistent.mjs", import.meta.url), +); // --- Typed message protocol constants --- const IN_NEW_REQUEST = 0x00; @@ -42,18 +45,14 @@ interface WorkerCallbacks { } interface PersistentWorker { - proc: ReturnType; + proc: RuntimeProcess; cbs: WorkerCallbacks; /** True while the child process is still running. */ alive: boolean; } function spawnWorker(): PersistentWorker { - const proc = Bun.spawn(["node", PERSISTENT_BRIDGE_PATH], { - stdin: "pipe", - stdout: "pipe", - stderr: "ignore", - }); + const proc = spawnNodeBridge(PERSISTENT_BRIDGE_PATH); const worker: PersistentWorker = { proc, @@ -116,8 +115,7 @@ function spawnWorker(): PersistentWorker { function workerSend(worker: PersistentWorker, type: number, payload: Uint8Array): void { if (!worker.alive) return; try { - const stdin = worker.proc.stdin as import("bun").FileSink; - stdin.write(encodeTyped(type, payload)); + worker.proc.stdin.write(encodeTyped(type, payload)); } catch { // stdin closed — worker is dying } diff --git a/src/cursor-rpc.ts b/src/cursor-rpc.ts index 65debf5..47ba173 100644 --- a/src/cursor-rpc.ts +++ b/src/cursor-rpc.ts @@ -1,8 +1,9 @@ -import { resolve as pathResolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnNodeBridge } from "./runtime.js"; export const CURSOR_API_URL = process.env.CURSOR_API_URL ?? "https://api2.cursor.sh"; -export const BRIDGE_PATH = pathResolve(import.meta.dir, "h2-bridge.mjs"); +export const BRIDGE_PATH = fileURLToPath(new URL("./h2-bridge.mjs", import.meta.url)); function lpEncode(data: Uint8Array): Buffer { const buffer = Buffer.alloc(4 + data.length); @@ -22,11 +23,7 @@ interface CursorUnaryRpcOptions { } function spawnBridge(options: CursorUnaryRpcOptions) { - const proc = Bun.spawn(["node", BRIDGE_PATH], { - stdin: "pipe", - stdout: "pipe", - stderr: "ignore", - }); + const proc = spawnNodeBridge(BRIDGE_PATH); proc.stdin.write( lpEncode( new TextEncoder().encode( diff --git a/src/proxy.ts b/src/proxy.ts index a2d29ec..4cb9a8a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -124,6 +124,12 @@ import { literalCursorModelSelection, type CursorModelSelection, } from "./model-selection.js"; +import { + serveFetch, + spawnNodeBridge, + type FetchServer, + type RuntimeProcess, +} from "./runtime.js"; // Re-export pure helpers so existing tests can import from ./proxy export { @@ -528,7 +534,7 @@ interface SpawnBridgeOptions { } function spawnBridge(options: SpawnBridgeOptions): { - proc: ReturnType; + proc: RuntimeProcess; write: (data: Uint8Array) => void; end: () => void; kill: () => void; @@ -537,11 +543,7 @@ function spawnBridge(options: SpawnBridgeOptions): { /** True while the bridge subprocess is still running. */ get alive(): boolean; } { - const proc = Bun.spawn(["node", BRIDGE_PATH], { - stdin: "pipe", - stdout: "pipe", - stderr: "ignore", - }); + const proc = spawnNodeBridge(BRIDGE_PATH); const config = JSON.stringify({ accessToken: options.accessToken, @@ -614,7 +616,7 @@ function spawnBridge(options: SpawnBridgeOptions): { }; } -let proxyServer: ReturnType | undefined; +let proxyServer: FetchServer | undefined; let proxyPort: number | undefined; let proxyAccessTokenProvider: (() => Promise) | undefined; let proxyModels: Array<{ id: string; name: string }> = []; @@ -622,7 +624,7 @@ const DEFAULT_MODEL_ID = "default"; /** * Optional pinned listen port from OPENCODE_CURSOR_PROXY_PORT. - * When unset (the default), Bun binds an ephemeral OS port (0). + * When unset (the default), the runtime binds an ephemeral OS port (0). */ function preferredProxyPort(): number { const raw = process.env.OPENCODE_CURSOR_PROXY_PORT; @@ -676,7 +678,7 @@ export async function startProxy( } const listenPort = preferredProxyPort(); - proxyServer = Bun.serve({ + proxyServer = await serveFetch({ port: listenPort, idleTimeout: 255, // max — Cursor responses can take 30s+ async fetch(req) { @@ -3135,4 +3137,4 @@ function handleToolResultResume( false, abortSignal, ); -} \ No newline at end of file +} diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..6e6fcf0 --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,176 @@ +import { spawn } from "node:child_process"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +export interface RuntimeProcess { + stdin: { + write(data: Uint8Array): unknown; + end(): unknown; + }; + stdout: ReadableStream; + exited: Promise; + kill(): unknown; +} + +/** + * Start a Node-only bridge from either Bun or Node/Electron. + * + * Bun deliberately delegates to an external Node executable because its + * node:http2 implementation is not suitable for Cursor's streaming bridge. + * Electron can run its bundled executable as Node via ELECTRON_RUN_AS_NODE, + * so desktop hosts do not need a shell-visible `node` binary. + */ +export function spawnNodeBridge(scriptPath: string): RuntimeProcess { + if (typeof Bun !== "undefined") { + return Bun.spawn(["node", scriptPath], { + stdin: "pipe", + stdout: "pipe", + stderr: "ignore", + }) as RuntimeProcess; + } + + const electron = Boolean(process.versions.electron); + const child = spawn(process.execPath, [scriptPath], { + env: electron + ? { ...process.env, ELECTRON_RUN_AS_NODE: "1" } + : process.env, + stdio: ["pipe", "pipe", "ignore"], + }); + const exited = new Promise((resolve) => { + child.once("close", (code) => resolve(code ?? 1)); + child.once("error", () => resolve(1)); + }); + + return { + stdin: child.stdin, + stdout: Readable.toWeb(child.stdout) as unknown as ReadableStream, + exited, + kill: () => child.kill(), + }; +} + +export interface FetchServer { + readonly port: number; + stop(): void; +} + +interface FetchServerOptions { + port: number; + idleTimeout?: number; + fetch(request: Request): Response | Promise; +} + +/** Serve a Fetch-style handler in both Bun CLI and Node/Electron hosts. */ +export async function serveFetch(options: FetchServerOptions): Promise { + if (typeof Bun !== "undefined") { + const server = Bun.serve({ + port: options.port, + idleTimeout: options.idleTimeout ?? 255, + fetch: options.fetch, + }); + if (!server.port) { + server.stop(); + throw new Error("Failed to bind proxy to a port"); + } + return { + port: server.port, + stop: () => server.stop(), + }; + } + + const server = createServer((incoming, outgoing) => { + void handleNodeRequest(incoming, outgoing, options.fetch); + }); + server.keepAliveTimeout = (options.idleTimeout ?? 5) * 1_000; + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(options.port, "localhost"); + }); + + const address = server.address() as AddressInfo | null; + if (!address?.port) { + server.close(); + throw new Error("Failed to bind proxy to a port"); + } + + return { + port: address.port, + stop() { + server.close(); + server.closeAllConnections?.(); + }, + }; +} + +async function handleNodeRequest( + incoming: IncomingMessage, + outgoing: ServerResponse, + handler: FetchServerOptions["fetch"], +): Promise { + const abortController = new AbortController(); + incoming.once("aborted", () => abortController.abort()); + outgoing.once("close", () => { + if (!outgoing.writableEnded) abortController.abort(); + }); + + try { + const headers = new Headers(); + for (let i = 0; i < incoming.rawHeaders.length; i += 2) { + headers.append(incoming.rawHeaders[i]!, incoming.rawHeaders[i + 1]!); + } + + const method = incoming.method ?? "GET"; + const init: RequestInit & { duplex?: "half" } = { + method, + headers, + signal: abortController.signal, + }; + if (method !== "GET" && method !== "HEAD") { + init.body = Readable.toWeb(incoming) as unknown as ReadableStream; + init.duplex = "half"; + } + + const host = incoming.headers.host ?? "localhost"; + const request = new Request( + new URL(incoming.url ?? "/", `http://${host}`), + init, + ); + const response = await handler(request); + + outgoing.statusCode = response.status; + if (response.statusText) outgoing.statusMessage = response.statusText; + response.headers.forEach((value, name) => outgoing.setHeader(name, value)); + + if (!response.body || method === "HEAD") { + outgoing.end(); + return; + } + + await pipeline( + Readable.fromWeb( + response.body as unknown as Parameters[0], + ), + outgoing, + ); + } catch (error) { + if (abortController.signal.aborted) return; + if (outgoing.headersSent) { + outgoing.destroy(error instanceof Error ? error : undefined); + return; + } + outgoing.writeHead(500, { "Content-Type": "text/plain" }); + outgoing.end("Internal Server Error"); + } +} diff --git a/test/node-runtime.mjs b/test/node-runtime.mjs new file mode 100644 index 0000000..42b388d --- /dev/null +++ b/test/node-runtime.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import http2 from "node:http2"; +import { once } from "node:events"; + +const proxy = await import("../dist/proxy.js"); +const backend = http2.createServer(); +let receivedPath; +let receivedBody = Buffer.alloc(0); + +backend.on("stream", (stream, headers) => { + receivedPath = headers[":path"]; + stream.respond({ ":status": 200, "content-type": "application/proto" }); + stream.on("data", (chunk) => { + receivedBody = Buffer.concat([receivedBody, Buffer.from(chunk)]); + }); + stream.on("end", () => stream.end(Buffer.from([9, 8, 7]))); +}); + +backend.listen(0, "127.0.0.1"); +await once(backend, "listening"); +const address = backend.address(); +assert(address && typeof address !== "string"); + +try { + const port = await proxy.startProxy( + async () => "test-token", + [{ id: "default", name: "Auto" }], + ); + const modelsResponse = await fetch(`http://localhost:${port}/v1/models`); + assert.equal(modelsResponse.status, 200); + assert.deepEqual(await modelsResponse.json(), { + object: "list", + data: [ + { + id: "default", + object: "model", + created: 0, + owned_by: "cursor", + }, + ], + }); + + const invalidResponse = await fetch( + `http://localhost:${port}/v1/chat/completions`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "default", messages: [] }), + }, + ); + assert.equal(invalidResponse.status, 400); + + const rpcResponse = await proxy.callCursorUnaryRpc({ + accessToken: "test-token", + rpcPath: "/test.Unary/Call", + requestBody: new Uint8Array([1, 2, 3]), + url: `http://127.0.0.1:${address.port}`, + }); + assert.equal(rpcResponse.timedOut, false); + assert.equal(rpcResponse.exitCode, 0); + assert.deepEqual([...rpcResponse.body], [9, 8, 7]); + assert.equal(receivedPath, "/test.Unary/Call"); + assert.deepEqual([...receivedBody], [1, 2, 3]); + + console.log("✓ Node runtime proxy and HTTP/2 bridge passed"); +} finally { + proxy.stopProxy(); + await new Promise((resolve, reject) => { + backend.close((error) => (error ? reject(error) : resolve())); + }); +}