diff --git a/README.md b/README.md index 7683cc5..a36177a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ For Node.js, also install the SQLite driver: npm install @wgtechlabs/config-engine better-sqlite3 ``` +The Bun SQLite backend is loaded only at runtime on Bun, so Node-targeted +bundles can safely consume the package without pulling in a static `bun:*` import. + ## Quick Start ```typescript diff --git a/src/runtime.ts b/src/runtime.ts index 8f1ba8d..4e6b810 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -4,8 +4,11 @@ * Uses `bun:sqlite` on Bun, `better-sqlite3` on Node.js. */ +import { createRequire } from "node:module"; import type { DatabaseAdapter, StatementAdapter } from "./types.js"; +const runtimeRequire = createRequire(import.meta.url); + /** Returns `true` when running under Bun. */ export function isBun(): boolean { return typeof globalThis.Bun !== "undefined"; @@ -28,9 +31,10 @@ export function openDatabase(filepath: string): DatabaseAdapter { // --------------------------------------------------------------------------- function openBunDatabase(filepath: string): DatabaseAdapter { - // Using dynamic import syntax to avoid static analysis issues on Node + // Construct the module specifier at runtime so Node-targeted bundlers + // don't preserve a static bun:* import in published CLI artifacts. // biome-ignore lint/suspicious/noExplicitAny: bun:sqlite types vary - const { Database } = require("bun:sqlite") as any; + const { Database } = runtimeRequire(getBunSqliteSpecifier()) as any; const db = new Database(filepath); // Enable WAL for concurrent read performance @@ -72,8 +76,8 @@ function openNodeDatabase(filepath: string): DatabaseAdapter { // better-sqlite3 is a peer dep — error if missing let BetterSqlite3: typeof import("better-sqlite3"); try { - // biome-ignore lint/suspicious/noExplicitAny: dynamic require - BetterSqlite3 = require("better-sqlite3") as any; + // biome-ignore lint/suspicious/noExplicitAny: runtime require for ESM output + BetterSqlite3 = runtimeRequire("better-sqlite3") as any; } catch { throw new Error( 'config-engine requires "better-sqlite3" as a peer dependency when running on Node.js. ' + @@ -113,3 +117,8 @@ function openNodeDatabase(filepath: string): DatabaseAdapter { }, }; } + +function getBunSqliteSpecifier(): string { + // Spells "bun:sqlite" without exposing a static bun:* import to bundlers. + return String.fromCharCode(98, 117, 110, 58, 115, 113, 108, 105, 116, 101); +} diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts new file mode 100644 index 0000000..57b2161 --- /dev/null +++ b/tests/runtime.test.ts @@ -0,0 +1,66 @@ +/** + * Tests for the runtime SQLite adapter selection. + */ + +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +describe("runtime bundling", () => { + test("does not emit a static bun:sqlite import in Node bundles", async () => { + const workDir = mkdtempSync(join(tmpdir(), "config-engine-runtime-")); + const entryPath = join(workDir, "entry.ts"); + const outDir = join(workDir, "dist"); + // Resolve through a file:// URL, then back to a filesystem path: the bundler + // resolves plain paths, and JSON.stringify escapes Windows separators safely. + const runtimePath = fileURLToPath(new URL("../src/runtime.ts", import.meta.url)); + + try { + mkdirSync(outDir, { recursive: true }); + // The bundle is emitted as ESM, so mark the output directory as a module + // package, otherwise Node parses the .js output as CommonJS and fails. + writeFileSync(join(outDir, "package.json"), `${JSON.stringify({ type: "module" })}\n`); + // Actually reference openDatabase (not just import it) so the bundler cannot + // tree-shake the SQLite adapter code, so the bun:sqlite guard is really + // exercised by the bundle. + writeFileSync( + entryPath, + `import { isBun, openDatabase } from ${JSON.stringify(runtimePath)};\n` + + "console.log(isBun());\n" + + "console.log(typeof openDatabase);\n", + ); + + const result = await Bun.build({ + entrypoints: [entryPath], + outdir: outDir, + target: "node", + format: "esm", + splitting: false, + external: ["better-sqlite3"], + }); + + expect(result.success).toBe(true); + + const outputPath = join(outDir, "entry.js"); + const bundled = readFileSync(outputPath, "utf8"); + // Allow for minified output: `from"bun:sqlite"`, `import"bun:sqlite"`, + // and `require("bun:sqlite")` must all be absent. + expect(bundled).not.toMatch(/(?:from|import|require\s*\()\s*["']bun:sqlite["']/); + + const run = Bun.spawnSync(["node", outputPath], { + stdout: "pipe", + stderr: "pipe", + }); + + expect(run.exitCode).toBe(0); + expect(new TextDecoder().decode(run.stdout).trim().split(/\r?\n/)).toEqual([ + "false", + "function", + ]); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } + }); +});