diff --git a/.github/workflows/embedding-backend.yml b/.github/workflows/embedding-backend.yml index a52ad2d..fe62f32 100644 --- a/.github/workflows/embedding-backend.yml +++ b/.github/workflows/embedding-backend.yml @@ -14,8 +14,12 @@ on: - "bun.lock" - "src/services/embedding.ts" - "src/services/onnxruntime-resolve.ts" + - "src/services/runtime-require.ts" - "scripts/verify-embedding-backend.mjs" - "scripts/verify-nested-onnxruntime-fixture.mjs" + - "scripts/fixtures/compiled-host-entry.mjs" + - "tests/runtime-require.test.ts" + - "tests/onnxruntime-resolve.test.ts" - ".github/workflows/embedding-backend.yml" workflow_dispatch: @@ -110,8 +114,11 @@ jobs: - name: Build package run: bun run build - - name: Nested fixture + embedding (Bun 1.3.14) + - name: Nested fixture + compiled host + embedding (Bun 1.3.14) run: bun scripts/verify-nested-onnxruntime-fixture.mjs - name: Nested fixture + embedding (Node 22) + # Node cannot Bun --compile; the compiled-host check is Bun-only above. run: node scripts/verify-nested-onnxruntime-fixture.mjs + env: + SKIP_COMPILE_HOST: "1" diff --git a/README.md b/README.md index 8c2915b..529fe7f 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ Example — remote OpenAI embeddings: Changing `embeddingModel` (or dimensions) can trigger re-embedding of stored memories on next startup. Prefer picking a model once and sticking with it for a given data directory. -**Intel Mac (`darwin/x64`):** newer `onnxruntime-node` builds may ship without an x64 native binding, so local embedding init can fail. `opencode-mem` pins `onnxruntime-node@1.22.0` and loads transformers through a CJS resolve shim so OpenCode nested installs keep that binding. If init still fails after a plugin upgrade, clear OpenCode's nested plugin cache (`~/.cache/opencode/packages/opencode-mem@*`) and reinstall, or use a remote endpoint via `embeddingApiUrl` + `embeddingApiKey` (example above). +**Intel Mac (`darwin/x64`):** newer `onnxruntime-node` builds may ship without an x64 native binding, so local embedding init can fail. `opencode-mem` pins `onnxruntime-node@1.22.0` and loads transformers through a CJS resolve shim so OpenCode nested installs keep that binding. Transformers is resolved to an absolute path before that shim is installed so OpenCode's Bun `--compile` host does not fail with `Cannot find module '@huggingface/transformers' from ''`. If init still fails after a plugin upgrade, clear OpenCode's nested plugin cache (`~/.cache/opencode/packages/opencode-mem@*`) and reinstall, or use a remote endpoint via `embeddingApiUrl` + `embeddingApiKey` (example above). ### Memory Scope diff --git a/scripts/fixtures/compiled-host-entry.mjs b/scripts/fixtures/compiled-host-entry.mjs new file mode 100644 index 0000000..f8d486f --- /dev/null +++ b/scripts/fixtures/compiled-host-entry.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env bun +/** + * Minimal OpenCode-shaped Bun host entry for #210. + * + * OpenCode is shipped via Bun.build({ compile: { autoloadPackageJson: true, ... }}) + * and dynamically imports external plugins. This entry mimics that load path. + * + * Env: + * OPENCODE_MEM_PLUGIN_ENTRY — absolute file URL or path to embedding.js + */ +const entry = process.env.OPENCODE_MEM_PLUGIN_ENTRY; +if (!entry) { + console.error("OPENCODE_MEM_PLUGIN_ENTRY is required"); + process.exit(1); +} + +const mod = await import(entry); +if (typeof mod.loadLocalTransformersBackend !== "function") { + console.error("plugin entry does not export loadLocalTransformersBackend"); + process.exit(1); +} + +const transformers = await mod.loadLocalTransformersBackend(); +if (typeof transformers.pipeline !== "function" || !transformers.env) { + console.error("loadLocalTransformersBackend did not expose pipeline/env"); + process.exit(1); +} + +const { createRequire } = await import("node:module"); +const { dirname, join } = await import("node:path"); +const { existsSync, readFileSync } = await import("node:fs"); +const { fileURLToPath, pathToFileURL } = await import("node:url"); + +const entryPath = entry.startsWith("file:") ? fileURLToPath(entry) : entry; +const pluginRequire = createRequire(entryPath); +const resolveUrl = pathToFileURL(join(dirname(entryPath), "onnxruntime-resolve.js")).href; +const { getPinnedOnnxruntimePackageRoot, prepareOnnxruntimeForTransformers } = + await import(resolveUrl); +prepareOnnxruntimeForTransformers(); + +function pkgVersion(entryFile) { + let dir = dirname(entryFile); + for (let i = 0; i < 6; i++) { + const candidate = join(dir, "package.json"); + if (existsSync(candidate)) { + const parsed = JSON.parse(readFileSync(candidate, "utf8")); + if (typeof parsed.version === "string" && parsed.version.length > 0) { + return parsed.version; + } + } + dir = dirname(dir); + } + throw new Error(`versioned package.json not found near ${entryFile}`); +} + +const nodeEntry = pluginRequire.resolve("onnxruntime-node"); +const commonEntry = createRequire(nodeEntry).resolve("onnxruntime-common"); +const nodeVersion = pkgVersion(nodeEntry); +const commonVersion = pkgVersion(commonEntry); +const pinnedRoot = getPinnedOnnxruntimePackageRoot(); + +if (nodeVersion !== "1.22.0") { + console.error(`expected onnxruntime-node@1.22.0, got ${nodeVersion} at ${nodeEntry}`); + process.exit(1); +} +if (commonVersion !== "1.22.0") { + console.error(`expected onnxruntime-common@1.22.0, got ${commonVersion} at ${commonEntry}`); + process.exit(1); +} + +console.log( + JSON.stringify({ + ok: true, + nodeEntry, + commonEntry, + nodeVersion, + commonVersion, + pinnedRoot, + }) +); diff --git a/scripts/verify-nested-onnxruntime-fixture.mjs b/scripts/verify-nested-onnxruntime-fixture.mjs index c60319b..5437fad 100644 --- a/scripts/verify-nested-onnxruntime-fixture.mjs +++ b/scripts/verify-nested-onnxruntime-fixture.mjs @@ -6,6 +6,14 @@ * so @huggingface/transformers may keep nested onnxruntime-node@1.24.3. * Then verifies the production CJS prepare+load path pins the direct 1.22.0 stack. * + * Unlike earlier revisions, Transformers is loaded through the production + * `loadLocalTransformersBackend()` export (createRuntimeRequire + shim), not via + * a separately constructed absolute createRequire() that would hide empty-referrer + * failures inside compiled OpenCode/Bun hosts. + * + * When running under Bun, also compiles a minimal host binary that dynamically + * imports the installed plugin — the same pattern OpenCode uses. + * * npm may hoist dependencies to the consumer root (fixture/node_modules/...) while * OpenCode keeps them under the plugin package. Both layouts are accepted as long as * transformers can resolve a nested 1.24.x copy and the production shim pins 1.22.0. @@ -18,6 +26,7 @@ * FIXTURE_DIR — reuse an existing fixture root that already has opencode-mem installed * SKIP_INSTALL — when FIXTURE_DIR is set, skip pack/install * SKIP_EMBEDDING — skip the real feature-extraction smoke + * SKIP_COMPILE_HOST — skip Bun --compile host verification * KEEP_FIXTURE — keep the temp fixture directory */ @@ -42,12 +51,12 @@ function fail(msg) { process.exit(1); } -function run(cmd, args, cwd) { +function run(cmd, args, cwd, env = process.env) { const result = spawnSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], - env: process.env, + env, }); if (result.status !== 0) { fail( @@ -102,6 +111,77 @@ function findTransformersPackageJson(searchRoots) { return null; } +async function verifyCompiledHost(pluginRoot) { + if (runtime !== "bun") { + log("skipping compiled-host check (requires Bun)"); + return; + } + if (process.env.SKIP_COMPILE_HOST === "1") { + log("skipping compiled-host check (SKIP_COMPILE_HOST=1)"); + return; + } + + const hostDir = mkdtempSync(join(tmpdir(), "opencode-mem-compiled-host-")); + const entrySource = join(repoRoot, "scripts", "fixtures", "compiled-host-entry.mjs"); + const entryCopy = join(hostDir, "entry.ts"); + const outfile = join(hostDir, "compiled-host"); + writeFileSync(entryCopy, readFileSync(entrySource)); + // Match OpenCode's Bun.build compile options — plain `bun build --compile` + // does not reproduce the empty-referrer failure from #210. + writeFileSync( + join(hostDir, "package.json"), + JSON.stringify({ name: "opencode-mem-compiled-host", type: "module" }, null, 2) + ); + writeFileSync( + join(hostDir, "build.ts"), + ` +await Bun.build({ + entrypoints: ["./entry.ts"], + conditions: ["bun", "node"], + format: "esm", + compile: { + autoloadBunfig: false, + autoloadDotenv: false, + autoloadTsconfig: true, + autoloadPackageJson: true, + outfile: ${JSON.stringify(outfile)}, + }, +}); +` + ); + + log(`compiling OpenCode-shaped Bun host -> ${outfile}`); + run("bun", ["build.ts"], hostDir); + + const embeddingEntry = join(pluginRoot, "dist", "services", "embedding.js"); + const pluginEntry = pathToFileURL(embeddingEntry).href; + log(`running compiled host against ${pluginEntry}`); + const stdout = run(outfile, [], hostDir, { + ...process.env, + OPENCODE_MEM_PLUGIN_ENTRY: pluginEntry, + }); + const line = stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .at(-1); + let parsed; + try { + parsed = JSON.parse(line); + } catch { + fail(`compiled host did not print JSON status\nstdout:\n${stdout}`); + } + if (!parsed?.ok) fail(`compiled host reported failure: ${line}`); + if (parsed.nodeVersion !== PINNED) { + fail(`compiled host onnxruntime-node=${parsed.nodeVersion}, expected ${PINNED}`); + } + if (parsed.commonVersion !== PINNED) { + fail(`compiled host onnxruntime-common=${parsed.commonVersion}, expected ${PINNED}`); + } + log(`compiled host PASS — pinned node=${parsed.nodeEntry}`); + rmSync(hostDir, { recursive: true, force: true }); +} + async function main() { log(`runtime=${runtime} platform=${process.platform} arch=${process.arch}`); @@ -121,19 +201,33 @@ async function main() { const tarball = run("bash", ["-lc", `ls "${packDir}"/opencode-mem-*.tgz | head -1`]).trim(); if (!tarball) fail("npm pack produced no tarball"); + // OpenCode installs plugins as a tiny consumer package that depends on the + // plugin version, then hoists deps under that cache root (#210 reporter layout). + // Intentionally NO root overrides — OpenCode nested installs ignore nested overrides (#184). writeFileSync( join(fixtureDir, "package.json"), - JSON.stringify({ name: "opencode-mem-nested-fixture", private: true }, null, 2) + JSON.stringify( + { + name: "opencode-mem-nested-fixture", + private: true, + dependencies: { + "opencode-mem": `file:${tarball}`, + }, + }, + null, + 2 + ) ); - // Intentionally NO root overrides — OpenCode nested installs ignore nested overrides (#184). - log(`installing ${tarball} into ${fixtureDir} (ignore-scripts, no overrides)`); - run("npm", ["install", "--ignore-scripts", tarball], fixtureDir); + log(`installing into ${fixtureDir} (ignore-scripts, OpenCode-shaped hoist, no overrides)`); + run("npm", ["install", "--ignore-scripts"], fixtureDir); } const pluginRoot = join(fixtureDir, "node_modules", "opencode-mem"); if (!existsSync(pluginRoot)) fail(`plugin not installed at ${pluginRoot}`); const searchRoots = [pluginRoot, fixtureDir]; + // Diagnostic-only require anchored at the production embedding file. Production + // loading must go through loadLocalTransformersBackend() below. const pluginRequire = createRequire(join(pluginRoot, "dist", "services", "embedding.js")); let directNodeEntry; @@ -177,19 +271,17 @@ async function main() { log(`pre-shim transformers resolve -> ${nestedResolved} (@${resolvedPkg.version})`); } - // Load production prepare from the installed plugin dist. - const resolveUrl = pathToFileURL( - join(pluginRoot, "dist", "services", "onnxruntime-resolve.js") - ).href; - const { prepareOnnxruntimeForTransformers, getPinnedOnnxruntimePackageRoot } = - await import(resolveUrl); - - prepareOnnxruntimeForTransformers(); + // Load production prepare + transformers through the real embedding module path. + // This exercises createRuntimeRequire(import.meta) instead of a hand-built absolute require. + const embeddingUrl = pathToFileURL(join(pluginRoot, "dist", "services", "embedding.js")).href; + const embeddingMod = await import(embeddingUrl); + if (typeof embeddingMod.loadLocalTransformersBackend !== "function") { + fail("dist/services/embedding.js does not export loadLocalTransformersBackend"); + } - const transformersSpecifier = ["@huggingface", "transformers"].join("/"); - const transformers = pluginRequire(transformersSpecifier); + const transformers = await embeddingMod.loadLocalTransformersBackend(); if (typeof transformers.pipeline !== "function" || !transformers.env) { - fail("CJS transformers load did not expose pipeline/env"); + fail("production loadLocalTransformersBackend did not expose pipeline/env"); } const pinnedNode = pluginRequire.resolve("onnxruntime-node"); @@ -206,12 +298,17 @@ async function main() { ); } + const resolveUrl = pathToFileURL( + join(pluginRoot, "dist", "services", "onnxruntime-resolve.js") + ).href; + const { getPinnedOnnxruntimePackageRoot } = await import(resolveUrl); const pinnedRoot = getPinnedOnnxruntimePackageRoot(); if (!pinnedRoot.includes("onnxruntime-node")) { fail(`unexpected pinned root: ${pinnedRoot}`); } // From nested transformers context, shim must force both packages onto the pin. + const transformersSpecifier = ["@huggingface", "transformers"].join("/"); const transformersEntry = pluginRequire.resolve(transformersSpecifier); const fromTransformers = createRequire(transformersEntry); const shimmedNode = fromTransformers.resolve("onnxruntime-node"); @@ -256,6 +353,8 @@ async function main() { log(`embedding ok: ${dims} dims, L2=${norm.toFixed(4)}`); } + await verifyCompiledHost(pluginRoot); + log("PASS — nested fixture loads production CJS path on onnxruntime 1.22.0 stack"); if (cleanup && process.env.KEEP_FIXTURE !== "1") cleanup(); diff --git a/src/services/embedding.ts b/src/services/embedding.ts index 1da93f2..cc142bb 100644 --- a/src/services/embedding.ts +++ b/src/services/embedding.ts @@ -1,13 +1,13 @@ import { CONFIG } from "../config.js"; import { log } from "./logger.js"; -import { createRequire } from "node:module"; import { join } from "node:path"; import { formatOnnxruntimeInitError, prepareOnnxruntimeForTransformers, } from "./onnxruntime-resolve.js"; +import { createRuntimeRequire } from "./runtime-require.js"; -const requireFromHere = createRequire(import.meta.url); +const requireFromHere = createRuntimeRequire(import.meta); const TIMEOUT_MS = 30000; const GLOBAL_EMBEDDING_KEY = Symbol.for("opencode-mem.embedding.instance"); @@ -58,8 +58,27 @@ async function ensureTransformersLoaded(): Promise> { + return ensureTransformersLoaded(); +} + function withTimeout(promise: Promise, ms: number): Promise { return Promise.race([ promise, diff --git a/src/services/onnxruntime-resolve.ts b/src/services/onnxruntime-resolve.ts index 9565baf..78459b4 100644 --- a/src/services/onnxruntime-resolve.ts +++ b/src/services/onnxruntime-resolve.ts @@ -12,10 +12,11 @@ import { createRequire } from "node:module"; import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; +import { createRuntimeRequire } from "./runtime-require.js"; const PACKAGE_NAME = "onnxruntime-node"; const COMMON_PACKAGE = "onnxruntime-common"; -const requireFromHere = createRequire(import.meta.url); +const requireFromHere = createRuntimeRequire(import.meta); let shimInstalled = false; let pinnedPackageRoot: string | null = null; @@ -168,11 +169,15 @@ export function installOnnxruntimeResolveShim(): void { const original = Module._resolveFilename; Module._resolveFilename = function ( + this: unknown, request: string, parent: unknown, isMain: boolean, options?: unknown ): string { + // Only intercept the onnx stack. Forwarding every other request through a + // wrapped _resolveFilename is required, but must not alter Bun --compile + // parent handling for unrelated packages (#210 follow-up). const pinnedNode = resolvePinnedRequest(request, PACKAGE_NAME, pinnedEntry); if (pinnedNode) return pinnedNode; diff --git a/src/services/runtime-require.ts b/src/services/runtime-require.ts new file mode 100644 index 0000000..50e816d --- /dev/null +++ b/src/services/runtime-require.ts @@ -0,0 +1,87 @@ +/** + * Create a CommonJS require() bound to the current ESM module. + * + * OpenCode ships as a Bun `--compile` binary. In that host, `import.meta.url` + * can be empty for dynamically loaded plugins, so `createRequire(import.meta.url)` + * yields `Cannot find module '…' from ''` (#210). Prefer Bun's module-bound + * `import.meta.require` when available, then fall back to a valid file/path anchor. + */ +import { createRequire } from "node:module"; +import { join } from "node:path"; + +export type RuntimeImportMeta = ImportMeta & { + require?: NodeRequire; + path?: string; + dirname?: string; + dir?: string; + resolve?: (specifier: string) => string; +}; + +function isUsableAnchor(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0 && value !== "undefined"; +} + +function collectAnchors(meta: RuntimeImportMeta): string[] { + const anchors: string[] = []; + if (isUsableAnchor(meta.url)) anchors.push(meta.url); + if (isUsableAnchor(meta.path)) anchors.push(meta.path); + + const dir = isUsableAnchor(meta.dirname) + ? meta.dirname + : isUsableAnchor(meta.dir) + ? meta.dir + : null; + if (dir) { + // createRequire needs a filename, not a directory. + anchors.push(join(dir, "runtime-require.js")); + } + + if (typeof meta.resolve === "function") { + try { + const resolved = meta.resolve("./package.json"); + if (isUsableAnchor(resolved)) anchors.push(resolved); + } catch { + // import.meta.resolve may be unavailable or reject relative specifiers. + } + } + return anchors; +} + +/** + * Build a require() for resolving/loading packages next to this plugin module. + * Throws a concrete diagnostic when no usable Bun/Node anchor exists. + * + * Prefer a path/url-anchored createRequire over Bun's import.meta.require when a + * concrete file anchor exists. In OpenCode's Bun --compile host, import.meta.url + * and import.meta.path are still valid for dynamically imported plugins even when + * some require referrers later surface as `from ''` (#210). + */ +export function createRuntimeRequire(meta: RuntimeImportMeta): NodeRequire { + const anchors = collectAnchors(meta); + const errors: string[] = []; + for (const anchor of anchors) { + try { + return createRequire(anchor); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`${anchor}: ${message}`); + } + } + + if (typeof meta.require === "function") { + return meta.require; + } + + throw new Error( + [ + "Unable to create a module require for the OpenCode plugin host.", + `import.meta.url=${JSON.stringify(meta.url)}`, + `import.meta.path=${JSON.stringify(meta.path)}`, + "Bun import.meta.require is unavailable.", + errors.length > 0 ? `createRequire attempts: ${errors.join("; ")}` : null, + "This usually means the compiled OpenCode/Bun host cleared the plugin module referrer (#210).", + ] + .filter(Boolean) + .join(" ") + ); +} diff --git a/tests/plugin-bundle-boundary.test.ts b/tests/plugin-bundle-boundary.test.ts index c183868..2e536b5 100644 --- a/tests/plugin-bundle-boundary.test.ts +++ b/tests/plugin-bundle-boundary.test.ts @@ -27,7 +27,7 @@ describe("OpenCode plugin loader bundle boundary", () => { expect(output).not.toContain("@huggingface/transformers/dist"); // Guard against the old backend silently coming back too. expect(output).not.toContain("node_modules/@xenova/transformers"); - }); + }, 30_000); it("does not pull opencode SDK or OIDC internals into the plugin-loader bundle", async () => { const result = await Bun.build({ @@ -42,7 +42,7 @@ describe("OpenCode plugin loader bundle boundary", () => { expect(output).not.toContain("node_modules/@opencode-ai/sdk"); expect(output).not.toContain("@vercel/oidc"); expect(output).not.toContain("getVercelOidcToken"); - }); + }, 30_000); it("resolves the provider module from a single-file bundled lazy loader", async () => { const result = await Bun.build({ @@ -66,5 +66,5 @@ describe("OpenCode plugin loader bundle boundary", () => { expect(typeof provider.generateStructuredOutput).toBe("function"); expect(typeof provider.createV2Client).toBe("function"); - }); + }, 30_000); }); diff --git a/tests/plugin-loader-contract.test.ts b/tests/plugin-loader-contract.test.ts index b1678fc..d5fd4af 100644 --- a/tests/plugin-loader-contract.test.ts +++ b/tests/plugin-loader-contract.test.ts @@ -69,8 +69,16 @@ describe("OpenCode 1.3.x plugin-loader contract", () => { // Verify the callable surface is correct regardless of warmup outcome expect(typeof serverFn).toBe("function"); + // This is a loader-contract test, not an embedding smoke test. Mark the + // process-global warmup as complete so server() does not load the native + // ONNX stack in the background. Bun 1.3.14 can crash during process teardown + // after that native addon was loaded, even though every assertion passed. + const warmupKey = Symbol.for("opencode-mem.plugin.warmedup"); + const hadWarmupState = Object.prototype.hasOwnProperty.call(globalThis, warmupKey); + const previousWarmupState = Reflect.get(globalThis, warmupKey); + Reflect.set(globalThis, warmupKey, true); + // Attempt to invoke server with a minimal mock PluginInput. - // The plugin may throw during warmup (missing Turso runtime in test env) — that is expected. // If it succeeds, assert the returned hooks have the expected shape. const mockInput = { client: {}, @@ -89,8 +97,14 @@ describe("OpenCode 1.3.x plugin-loader contract", () => { expect(typeof hooks["chat.message"]).toBe("function"); expect(typeof hooks["event"]).toBe("function"); } catch { - // Warmup/Turso failure in test environment is acceptable. + // Other runtime setup failures in the minimal test environment are acceptable. // The callable surface assertion above is sufficient for contract verification. + } finally { + if (hadWarmupState) { + Reflect.set(globalThis, warmupKey, previousWarmupState); + } else { + Reflect.deleteProperty(globalThis, warmupKey); + } } }); }); diff --git a/tests/runtime-require.test.ts b/tests/runtime-require.test.ts new file mode 100644 index 0000000..e521c06 --- /dev/null +++ b/tests/runtime-require.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "bun:test"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { createRuntimeRequire, type RuntimeImportMeta } from "../src/services/runtime-require.js"; + +describe("createRuntimeRequire (#210 compiled host)", () => { + it("falls back to Bun import.meta.require when no file anchor exists", () => { + const fakeRequire = ((id: string) => ({ ok: id })) as NodeRequire; + fakeRequire.resolve = ((id: string) => `/fake/${id}`) as NodeRequire["resolve"]; + + const req = createRuntimeRequire({ + url: "", + path: "", + dirname: "", + dir: "", + require: fakeRequire, + } as RuntimeImportMeta); + + expect(req).toBe(fakeRequire); + expect(req.resolve("onnxruntime-node")).toBe("/fake/onnxruntime-node"); + }); + + it("prefers createRequire(file anchor) over import.meta.require when both exist", () => { + const fakeRequire = ((id: string) => ({ ok: id })) as NodeRequire; + fakeRequire.resolve = ((id: string) => `/fake/${id}`) as NodeRequire["resolve"]; + + const req = createRuntimeRequire({ + url: import.meta.url, + require: fakeRequire, + } as RuntimeImportMeta); + + // Anchored createRequire should win so we do not depend on Bun referrer quirks. + expect(req).not.toBe(fakeRequire); + expect(typeof req("node:path").join).toBe("function"); + }); + + it("falls back to createRequire(import.meta.url) when url is valid", () => { + const req = createRuntimeRequire(import.meta); + // Resolving a builtin proves the require is usable without depending on Bun-only APIs. + expect(typeof req("node:path").join).toBe("function"); + expect(req.resolve("node:module")).toContain("module"); + }); + + it("uses import.meta.path when url is empty", () => { + const path = + typeof (import.meta as RuntimeImportMeta).path === "string" + ? (import.meta as RuntimeImportMeta).path! + : new URL(import.meta.url).pathname; + const req = createRuntimeRequire({ + url: "", + path, + } as RuntimeImportMeta); + const expected = createRequire(path).resolve("node:fs"); + expect(req.resolve("node:fs")).toBe(expected); + }); + + it("uses import.meta.dirname as a filename anchor when url/path are empty", () => { + const dirname = new URL(".", import.meta.url).pathname; + const req = createRuntimeRequire({ + url: "", + dirname, + } as RuntimeImportMeta); + expect(typeof req("node:path").join).toBe("function"); + }); + + it("throws a concrete diagnostic when no usable anchor exists", () => { + expect(() => + createRuntimeRequire({ + url: "", + path: "", + dirname: "", + dir: "", + } as RuntimeImportMeta) + ).toThrow(/Unable to create a module require.*#210/); + }); + + it("resolves plugin packages when import.meta.url is empty but path is set", () => { + // Bun's missing-module errors often say `from ''` even with a valid require; + // the regression is failing to resolve an installed package under an empty url. + const path = + typeof (import.meta as RuntimeImportMeta).path === "string" + ? (import.meta as RuntimeImportMeta).path! + : new URL(import.meta.url).pathname; + const req = createRuntimeRequire({ + url: "", + path, + } as RuntimeImportMeta); + const resolved = req.resolve("@huggingface/transformers"); + expect(resolved.replaceAll("\\", "/")).toContain("@huggingface/transformers"); + // Do not load transformers here: another test covers the production load. + // Loading its native ONNX stack twice in separate Bun test modules can crash + // Bun 1.3.14 on macOS x64 during process teardown after all tests passed. + expect(existsSync(resolved)).toBe(true); + }); +});