Skip to content

fix: run on Node runtime (Desktop sidecar) + module-format export - #36

Open
intellectronica wants to merge 4 commits into
ephraimduncan:mainfrom
intellectronica:fix/node-compatible-runtime
Open

fix: run on Node runtime (Desktop sidecar) + module-format export#36
intellectronica wants to merge 4 commits into
ephraimduncan:mainfrom
intellectronica:fix/node-compatible-runtime

Conversation

@intellectronica

Copy link
Copy Markdown

Problem

The plugin does not work in the OpenCode Desktop app, which loads plugins in a Node sidecar runtime. Three separate issues break it there:

  1. ERR_MODULE_NOT_FOUNDdist/ used extensionless relative ESM imports (./auth, ./proxy, ...). Bun tolerates these; Node rejects them, so the plugin never even loads in Desktop. This is also why "Cursor doesn't appear" in the Desktop app at all.
  2. Bun.* APIs undefined — the proxy used Bun.serve, Bun.spawn and Bun.sleep. In the Node sidecar these throw, so even when the plugin loaded, the proxy never started and requests failed with TypeError: Invalid URL (no baseURL was ever set).
  3. Legacy default export — newer opencode loaders (CLI + Desktop) require { id, server } module format; the bare function default export is silently ignored there.

Changes

  • proxy.ts: replace Bun.serve with node:http createServer plus a small adapter that preserves the existing fetch-style Response handler (SSE streaming included).
  • proxy.ts: spawn the h2-bridge with child_process.spawn(process.execPath, ...) instead of Bun.spawn, so the bridge runs under whichever runtime loaded the plugin (Bun on CLI, Node in Desktop).
  • auth.ts: Bun.sleepsetTimeout.
  • Emit explicit .js extensions (tsconfig module: NodeNext) so dist/ loads in Node ESM.
  • index.ts: export the plugin as { id, server } module format (named CursorAuthPlugin export kept for compat).
  • package.json: add prepare: bun run build so installing from a git branch builds dist.
  • test/node-smoke.mjs: manual smoke test that runs the plugin in plain Node end-to-end (plugin load → model discovery → proxy → chat completion).

Verification

bun run build && node test/node-smoke.mjs in plain Node:

default export: [ 'id', 'server' ] server type: function
models in config: 13
baseURL from auth.loader: http://localhost:61763/v1
completion status: 200
PASS

Also verified a chat completion through the proxy via curl under Node: HTTP 200 with a full response.

No behavior change on the CLI/Bun path — verified the plugin still works there.

The OpenCode Desktop app loads plugins in a Node sidecar, which broke
the plugin in three ways:

- dist used extensionless relative ESM imports, which Node rejects with
  ERR_MODULE_NOT_FOUND (Bun tolerates them)
- Bun.serve, Bun.spawn and Bun.sleep are undefined in Node
- the bare function default export was silently ignored by newer
  loaders that require the { id, server } module shape

Changes:
- proxy: use node:http createServer instead of Bun.serve, with a small
  adapter that preserves the fetch-style Response handler
- proxy: spawn the h2-bridge via child_process.spawn(process.execPath)
  instead of Bun.spawn so it runs under the same runtime (Bun or Node)
- auth: replace Bun.sleep with setTimeout
- emit explicit .js extensions (module NodeNext) so dist loads in Node
- export { id, server } module format alongside the named plugin fn
- add prepare script so git/branch installs build dist
- add node test/smoke.mjs manual smoke test
@intellectronica
intellectronica marked this pull request as draft August 12, 2026 08:30
- baseURL/api.url: localhost → 127.0.0.1 (avoid IPv6 mismatch with
  the proxy bound to 127.0.0.1)
- merge user-configured model IDs into the proxy model map so whitelist
  stubs like cursor-grok-4.5-high keep a working api.url even when
  GetUsableModels does not list them
The h2-bridge must always run under real Node (Bun http2 is broken).
Spawning via process.execPath under the Bun CLI accidentally ran the
bridge as bun and produced empty Cursor completions.

- CLI (Bun): original Bun.spawn(['node', bridge]) + Bun.serve
- Desktop (Node): child_process.spawn('node', bridge) + node:http
- baseURL stays on 127.0.0.1; config model stubs keep api.url
@intellectronica
intellectronica marked this pull request as ready for review August 12, 2026 09:07

@ephraimduncan ephraimduncan left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NodeNext/portability refactor is sound, but the prepare script and Promise.withResolvers both break the declared plain-Node support before the smoke test can matter.

One finding without a diff anchor: src/proxy.ts:459 and :1838 use Promise.withResolvers(), which requires Node >=22 while engines.node says >=18. These lines predate this PR, but only this PR makes plain Node a supported runtime, so on Node 18/20 the request handler now throws TypeError: Promise.withResolvers is not a function. Please replace with a plain promise constructor or bump engines to >=22.

Comment thread package.json
"scripts": {
"build": "tsc -p tsconfig.json && node scripts/copy-runtime.mjs",
"test": "bun test/smoke.ts",
"prepare": "bun run build",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"prepare": "bun run build" runs on npm installs from a checkout or git URL, and Bun is not a declared dependency while engines.node says >=18, so a Node-only environment dies with bun: command not found before the committed dist/ can be used. Please switch to npm run build (the build script is already tsc + node) or drop prepare since dist/ is committed.

Comment thread test/node-smoke.mjs
const text = await res.text()
console.log("completion status:", res.status)
console.log("body:", text.slice(0, 300))
if (res.status !== 200) process.exit(1)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the test passes on any HTTP 200, and bridge.onClose at src/proxy.ts:1888-1904 resolves with whatever text accumulated, so a bridge that dies instantly yields text: "" with a 200 and the test prints PASS. Asserting the assistant content (node-works) would make the smoke test actually prove the RPC path.

Comment thread test/node-smoke.mjs
console.log("models in config:", Object.keys(cfg.provider.cursor?.models ?? {}).length)

// 2. auth.loader (returns baseURL/apiKey/fetch — what opencode calls at request time)
const getAuth = async () => {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: a missing or malformed auth.json throws an uncaught rejection through hooks.auth.loader(...) instead of the explicit FAIL path at lines 38-41. It still exits nonzero, but catching it and naming the required Cursor OAuth entry would keep failures readable.

Comment thread test/node-smoke.mjs
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "cursor-grok-4.5-high",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the hard-coded cursor-grok-4.5-high ignores the catalog discovered at lines 26-28, so a valid account lacking that model fails the smoke test. Using auto or a discovered model would keep it account-independent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants