fix: run on Node runtime (Desktop sidecar) + module-format export - #36
fix: run on Node runtime (Desktop sidecar) + module-format export#36intellectronica wants to merge 4 commits into
Conversation
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
…ps prepare for git deps)
- 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
ephraimduncan
left a comment
There was a problem hiding this comment.
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.
| "scripts": { | ||
| "build": "tsc -p tsconfig.json && node scripts/copy-runtime.mjs", | ||
| "test": "bun test/smoke.ts", | ||
| "prepare": "bun run build", |
There was a problem hiding this comment.
"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.
| 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) |
There was a problem hiding this comment.
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.
| 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 () => { |
There was a problem hiding this comment.
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.
| method: "POST", | ||
| headers: { "content-type": "application/json" }, | ||
| body: JSON.stringify({ | ||
| model: "cursor-grok-4.5-high", |
There was a problem hiding this comment.
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.
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:
ERR_MODULE_NOT_FOUND—dist/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.Bun.*APIs undefined — the proxy usedBun.serve,Bun.spawnandBun.sleep. In the Node sidecar these throw, so even when the plugin loaded, the proxy never started and requests failed withTypeError: Invalid URL(nobaseURLwas ever set).{ id, server }module format; the bare function default export is silently ignored there.Changes
proxy.ts: replaceBun.servewithnode:http createServerplus a small adapter that preserves the existing fetch-styleResponsehandler (SSE streaming included).proxy.ts: spawn the h2-bridge withchild_process.spawn(process.execPath, ...)instead ofBun.spawn, so the bridge runs under whichever runtime loaded the plugin (Bun on CLI, Node in Desktop).auth.ts:Bun.sleep→setTimeout..jsextensions (tsconfigmodule: NodeNext) sodist/loads in Node ESM.index.ts: export the plugin as{ id, server }module format (namedCursorAuthPluginexport kept for compat).package.json: addprepare: bun run buildso installing from a git branch buildsdist.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.mjsin plain Node:Also verified a chat completion through the proxy via
curlunder Node: HTTP 200 with a full response.No behavior change on the CLI/Bun path — verified the plugin still works there.