diff --git a/src/services/sqlite/sqlite-bootstrap.ts b/src/services/sqlite/sqlite-bootstrap.ts index 9574f6d..2c52a69 100644 --- a/src/services/sqlite/sqlite-bootstrap.ts +++ b/src/services/sqlite/sqlite-bootstrap.ts @@ -1,9 +1,85 @@ -let Database: typeof import("bun:sqlite").Database; +/** + * SQLite binding bootstrap — works under Bun and Node. + * + * Resolution order: + * 1. Bun runtime → `bun:sqlite` (built-in, fastest, zero-install) + * 2. Node runtime → `node:sqlite` `DatabaseSync` (built-in, Node 22.5+ experimental, + * stable in Node 24+) + * 3. Fallback → `better-sqlite3` (peer dependency, full native binary) + * + * Required because opencode 1.15.x loads plugins under Node, not Bun — `bun:sqlite` + * is a Bun-only built-in and Node's ESM loader rejects the `bun:` URL scheme. + * + * The detection runs once at first call; the resolved Database class is cached. + */ +import { createRequire } from "node:module"; -export function getDatabase(): typeof import("bun:sqlite").Database { - if (!Database) { - const bunSqlite = require("bun:sqlite") as typeof import("bun:sqlite"); - Database = bunSqlite.Database; +// We don't import types from "bun:sqlite" here because that ambient import +// breaks Node-side type-checking when @types/bun is not installed. Callers +// treat the return value as an opaque sqlite-style Database constructor. +type DatabaseCtor = new (filename?: string, options?: unknown) => unknown; + +let Database: DatabaseCtor | undefined; + +const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"; + +export function getDatabase(): DatabaseCtor { + if (Database) return Database; + + const req = createRequire(import.meta.url); + + if (isBun) { + Database = req("bun:sqlite").Database as DatabaseCtor; + return Database; + } + + // Node runtime — try built-in `node:sqlite` first. It exposes `DatabaseSync` + // with the synchronous prepare/all/get/close API surface that matches + // bun:sqlite. One gap: bun:sqlite (and better-sqlite3) expose `db.run(sql)` + // for executing a single SQL statement without bindings — used throughout + // this project for PRAGMA and CREATE INDEX setup. `node:sqlite`'s + // DatabaseSync uses `db.exec(sql)` for that surface, so we subclass to + // alias `db.run(sql)` onto `db.exec(sql)` (param-bound `db.run(sql, ...)` + // is preserved for any future callers, falling back to a prepared statement). + try { + interface NodeStatementSync { + run(...params: unknown[]): unknown; + all(...params: unknown[]): unknown[]; + get(...params: unknown[]): unknown; + } + interface NodeDatabaseSync { + exec(sql: string): unknown; + prepare(sql: string): NodeStatementSync; + close(): void; + } + type NodeDatabaseSyncCtor = new (filename?: string, options?: unknown) => NodeDatabaseSync; + const DatabaseSync = (req("node:sqlite") as { DatabaseSync: NodeDatabaseSyncCtor }) + .DatabaseSync; + class DatabaseSyncCompat extends DatabaseSync { + run(sql: string, ...params: unknown[]): unknown { + if (params.length === 0) { + return this.exec(sql); + } + return this.prepare(sql).run(...params); + } + } + Database = DatabaseSyncCompat as unknown as DatabaseCtor; + return Database; + } catch { + // node:sqlite isn't available (Node < 22.5, or experimental flag not set + // in some embedded runtimes). Fall back to better-sqlite3 — wire-compatible + // API, requires a native postinstall but ships prebuilt binaries for + // common platforms. + try { + const betterSqlite = req("better-sqlite3") as DatabaseCtor; + Database = betterSqlite; + return Database; + } catch (error) { + throw new Error( + "opencode-mem: no SQLite binding available. Install better-sqlite3, " + + "or run on Node ≥22.5 with `--experimental-sqlite`, or use Bun. " + + `Underlying error: ${error instanceof Error ? error.message : String(error)}` + ); + } } - return Database; } diff --git a/src/services/web-server.ts b/src/services/web-server.ts index 5195cd4..cac8f2f 100644 --- a/src/services/web-server.ts +++ b/src/services/web-server.ts @@ -1,4 +1,6 @@ import { readFileSync } from "node:fs"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { Readable } from "node:stream"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { log } from "./logger.js"; @@ -28,6 +30,94 @@ import { handleRefreshProfile, } from "./api-handlers.js"; +/** + * Runtime-portable HTTP server handle. + * + * Under Bun we delegate to `Bun.serve` which is the fastest path on that + * runtime. Under Node we use `node:http` and adapt between IncomingMessage/ + * ServerResponse and the Web `Request`/`Response` primitives used by the + * fetch-style handler. + * + * Both paths expose the same minimal surface — `stop()` and `url` — that the + * rest of this class relies on, so the WebServer class itself does not need + * to branch. + */ +interface PortableServerHandle { + stop(): void; +} + +const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"; + +function serveFetch(opts: { + port: number; + hostname: string; + fetch: (req: Request) => Promise; +}): PortableServerHandle { + if (isBun) { + const bunHandle = ( + globalThis as { Bun: { serve: (opts: unknown) => { stop: () => void } } } + ).Bun.serve({ + port: opts.port, + hostname: opts.hostname, + fetch: opts.fetch, + }); + return { stop: () => bunHandle.stop() }; + } + + // Node path: wrap node:http around the fetch-style handler. The adapter + // converts IncomingMessage → Web Request and Web Response → ServerResponse. + // Bodies stream both directions via the WHATWG Streams ↔ Node Streams + // helpers that ship with Node 18+. + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + try { + const url = `http://${opts.hostname}:${opts.port}${req.url ?? "/"}`; + const method = req.method ?? "GET"; + const hasBody = method !== "GET" && method !== "HEAD"; + const webReq = new Request(url, { + method, + headers: req.headers as Record, + body: hasBody ? (Readable.toWeb(req) as unknown as ReadableStream) : undefined, + // `duplex: "half"` is required by Node fetch when sending a body + // stream. Cast keeps TS happy on older lib.dom.d.ts revisions. + ...(hasBody ? ({ duplex: "half" } as Record) : {}), + }); + + const webRes = await opts.fetch(webReq); + res.statusCode = webRes.status; + webRes.headers.forEach((value, name) => res.setHeader(name, value)); + + if (webRes.body) { + Readable.fromWeb(webRes.body as unknown as Parameters[0]).pipe( + res + ); + } else { + res.end(); + } + } catch (error) { + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("Content-Type", "text/plain"); + } + res.end(`Internal Server Error: ${error instanceof Error ? error.message : String(error)}`); + } + }); + + // Surface EADDRINUSE synchronously so callers can detect the + // already-running-instance case the same way they do under Bun. + let listenError: Error | undefined; + server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + listenError = err; + } + }); + server.listen(opts.port, opts.hostname); + + if (listenError) { + throw listenError; + } + return { stop: () => server.close() }; +} + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -38,7 +128,7 @@ interface WebServerConfig { } export class WebServer { - private server: ReturnType | null = null; + private server: PortableServerHandle | null = null; private config: WebServerConfig; private isOwner: boolean = false; private startPromise: Promise | null = null; @@ -68,7 +158,7 @@ export class WebServer { } try { - this.server = Bun.serve({ + this.server = serveFetch({ port: this.config.port, hostname: this.config.host, fetch: this.handleRequest.bind(this),