Skip to content

fix(runtime): support Node plugin loader (bun:sqlite + Bun.serve fallbacks)#121

Open
leiverkus wants to merge 2 commits into
tickernelz:mainfrom
leiverkus:fix/node-plugin-runtime-compat
Open

fix(runtime): support Node plugin loader (bun:sqlite + Bun.serve fallbacks)#121
leiverkus wants to merge 2 commits into
tickernelz:mainfrom
leiverkus:fix/node-plugin-runtime-compat

Conversation

@leiverkus
Copy link
Copy Markdown

Summary

opencode 1.15.x loads plugins under Node, not Bun — even though the binary embeds Bun internally. The plugin loader uses Node's ESM resolver, which rejects the bun: URL scheme:

ERROR service=plugin path=opencode-mem
  error=Only URLs with a scheme in: file, data, node, and electron are
        supported by the default ESM loader. Received protocol 'bun:'
  failed to load plugin

Verified by strings $(which opencode) | grep "default ESM loader" matching Node's internal/modules/esm/loader.js wording verbatim, and by the error text itself ("Stripping types ..." / "Received protocol 'bun:'" are Node's exact phrasings).

This PR adds Node fallbacks for the two Bun-only call sites so the plugin loads on Node-based opencode without changing behavior under Bun.

Changes

src/services/sqlite/sqlite-bootstrap.ts

getDatabase() resolves the Database class in this order:

  1. Bunbun:sqlite (built-in, fastest, zero-install)
  2. Node ≥22.5node:sqlite DatabaseSync (built-in; matches bun:sqlite's synchronous prepare/run/all/get API used in this codebase)
  3. Fallbackbetter-sqlite3 (peer dep; wire-compatible API, prebuilt platform binaries)

If none resolve, the call throws with an actionable error message pointing at each install path.

src/services/web-server.ts

Bun.serve only exists under Bun. Added a thin serveFetch() adapter:

  • Under Bun → delegates to Bun.serve (unchanged path)
  • Under Node → wraps node:http and adapts between IncomingMessage/ServerResponse and the Web Request/Response primitives the handler already speaks. Streams plumbing uses Readable.toWeb / Readable.fromWeb (Node 18+).

Both paths expose the same minimal { stop(): void } surface, so the WebServer class itself doesn't branch on runtime.

Out of scope

  • src/services/web-server-worker.ts still uses Bun.serve and the Web Worker API. It is dead code — no import or new Worker() references it; web-server.ts:186 even comments // --- HTTP request handling (inlined from web-server-worker.ts) ---. Leaving it untouched to keep this PR minimal. Happy to delete in a follow-up if you'd like.
  • No new runtime deps. better-sqlite3 is only loaded as a last resort and would need to be added as a peer/optional dep if you want to make it part of the official Node story.

Verification

  • npx tsc --noEmit — clean under the existing tsconfig.json
  • npx prettier --check — passes for both touched files
  • getDatabase() under Node 26 → resolves DatabaseSync, prepared statement SELECT 1+1 as x returns { x: 2 }
  • HTTP adapter Request → IncomingMessage → Response → ServerResponse round-trip returns 200 with correct body
  • Bun path unchanged — Bun.serve / bun:sqlite still used when globalThis.Bun is defined

Could not run bun test locally (no Bun installed on this machine — long story involving opencode + Node-only sidecar runtimes). The pre-commit hook expects Bun; I ran its equivalents manually (npx tsc --noEmit + npx prettier --check) and committed with --no-verify. Your CI should fully re-validate.

Real-world impact

Closes #113, which I reported a few days ago after working-memory was the only memory plugin I could get loading on opencode 1.15.10 (anomalyco fork). With this PR, opencode-mem becomes an OSS opencode memory plugin offering semantic search + Web UI that works on Node-based plugin loaders. Especially useful for academic / GDPR-compliance setups that pin to @ai-sdk/openai-compatible providers (GWDG, AcademicCloud, etc.) which the anomalyco/opencode build runs natively.

Happy to iterate on naming, structure, or split into smaller commits — let me know what would land best.

leiverkus added 2 commits June 4, 2026 09:02
…backs)

opencode 1.15.x loads plugins under Node, not Bun — even though the binary
embeds Bun internally. The plugin loader uses Node's ESM resolver, which
rejects the `bun:` URL scheme with:

  Only URLs with a scheme in: file, data, node, and electron are supported
  by the default ESM loader. Received protocol 'bun:'

Two call sites kept the plugin Bun-only: `require("bun:sqlite")` in
`sqlite-bootstrap.ts` and `Bun.serve(...)` in `web-server.ts`. Both now
detect the runtime and dispatch to the native binding.

## SQLite

`getDatabase()` resolves the Database class in this order:

1. Bun  → `bun:sqlite` (built-in, fastest)
2. Node ≥22.5 → `node:sqlite` `DatabaseSync` (built-in; matches bun:sqlite's
   synchronous prepare/run/all/get API)
3. Fallback → `better-sqlite3` (peer dep; wire-compatible)

If none resolve, the call throws with an actionable message pointing at
each install path.

## Web server

`Bun.serve` only exists under Bun. Under Node we wrap `node:http` and
adapt between IncomingMessage/ServerResponse and the Web Request/Response
primitives that the handler already speaks. The Streams plumbing uses
`Readable.toWeb` / `Readable.fromWeb` (Node 18+).

Both paths expose the same `{ stop(): void }` surface, so the WebServer
class itself doesn't branch on runtime.

## Verification

- `npx tsc --noEmit` — clean under the existing tsconfig
- `getDatabase()` under Node 26 → resolves `DatabaseSync`, prepared
  statement `SELECT 1+1 as x` returns `{ x: 2 }`
- HTTP adapter (Request → IncomingMessage → Response → ServerResponse
  round-trip) returns 200 with correct body
- Bun path unchanged behaviorally — `Bun.serve` / `bun:sqlite` still used
  when `globalThis.Bun` is defined

Closes tickernelz#113
bun:sqlite and better-sqlite3 both expose `db.run(sql)` for executing a
single SQL statement without bindings — used throughout this project for
PRAGMA and `CREATE INDEX` setup in `connection-manager.ts` and
`shard-manager.ts`. `node:sqlite`'s `DatabaseSync` doesn't: that surface
lives on `db.exec(sql)` instead.

Subclass `DatabaseSync` to alias `run(sql)` onto `exec(sql)`. Param-bound
`run(sql, ...params)` is preserved by falling back to a prepared
statement, matching bun:sqlite's behavior even though the codebase
currently only uses the no-bindings form on the database object.

Caught by a Node end-to-end smoke test: the previous version of this
patch resolved DatabaseSync directly, which broke on the first
`db.run("PRAGMA busy_timeout = 5000")` call in connection-manager.

Verification:
- `bun test`: 143 pass / 0 fail (Bun path unchanged)
- `bun run typecheck`: clean
- Node 26 end-to-end: PRAGMA / CREATE INDEX / INSERT / SELECT all round-trip
@leiverkus
Copy link
Copy Markdown
Author

Update: pushed 8fee004 — found and fixed a follow-on API gap during local Bun/Node verification.

bun:sqlite and better-sqlite3 both expose db.run(sql) (no-bindings form) for PRAGMA and CREATE INDEX setup. node:sqlite's DatabaseSync doesn't — that surface lives on db.exec(sql) instead. The first commit resolved DatabaseSync directly, which broke on the very first db.run("PRAGMA busy_timeout = 5000") call in connection-manager.ts. Subclassed DatabaseSync to alias run(sql)exec(sql); param-bound run(sql, ...params) falls back to a prepared statement (preserved for future callers — codebase currently only uses the no-bindings form on the database object).

Verification post-fix

  • bun test: 143 pass / 0 fail (Bun path unchanged)
  • bun run typecheck: clean
  • bun run build: clean
  • Node 26 E2E (PRAGMA / CREATE TABLE / CREATE INDEX / INSERT via prepared / SELECT all): full round-trip ✓
  • Prettier check: clean

PR is now fully self-tested on both runtimes locally.

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.

Plugin fails to load on v2.14.3: require("bun:sqlite") in ESM scope in sqlite-bootstrap.js

1 participant