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
88 changes: 82 additions & 6 deletions src/services/sqlite/sqlite-bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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;
}
94 changes: 92 additions & 2 deletions src/services/web-server.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<Response>;
}): 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<string, string>,
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<string, unknown>) : {}),
});

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<typeof Readable.fromWeb>[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);

Expand All @@ -38,7 +128,7 @@ interface WebServerConfig {
}

export class WebServer {
private server: ReturnType<typeof Bun.serve> | null = null;
private server: PortableServerHandle | null = null;
private config: WebServerConfig;
private isOwner: boolean = false;
private startPromise: Promise<void> | null = null;
Expand Down Expand Up @@ -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),
Expand Down