Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,16 @@ 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

```bash
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`.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
18 changes: 8 additions & 10 deletions src/bridge-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,18 +45,14 @@ interface WorkerCallbacks {
}

interface PersistentWorker {
proc: ReturnType<typeof Bun.spawn>;
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,
Expand Down Expand Up @@ -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
}
Expand Down
11 changes: 4 additions & 7 deletions src/cursor-rpc.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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(
Expand Down
22 changes: 12 additions & 10 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -528,7 +534,7 @@ interface SpawnBridgeOptions {
}

function spawnBridge(options: SpawnBridgeOptions): {
proc: ReturnType<typeof Bun.spawn>;
proc: RuntimeProcess;
write: (data: Uint8Array) => void;
end: () => void;
kill: () => void;
Expand All @@ -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,
Expand Down Expand Up @@ -614,15 +616,15 @@ function spawnBridge(options: SpawnBridgeOptions): {
};
}

let proxyServer: ReturnType<typeof Bun.serve> | undefined;
let proxyServer: FetchServer | undefined;
let proxyPort: number | undefined;
let proxyAccessTokenProvider: (() => Promise<string>) | undefined;
let proxyModels: Array<{ id: string; name: string }> = [];
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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -3135,4 +3137,4 @@ function handleToolResultResume(
false,
abortSignal,
);
}
}
176 changes: 176 additions & 0 deletions src/runtime.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array>;
exited: Promise<number>;
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<number>((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<Uint8Array>,
exited,
kill: () => child.kill(),
};
}

export interface FetchServer {
readonly port: number;
stop(): void;
}

interface FetchServerOptions {
port: number;
idleTimeout?: number;
fetch(request: Request): Response | Promise<Response>;
}

/** Serve a Fetch-style handler in both Bun CLI and Node/Electron hosts. */
export async function serveFetch(options: FetchServerOptions): Promise<FetchServer> {
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<void>((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<void> {
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<Uint8Array>;
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<typeof Readable.fromWeb>[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");
}
}
Loading