feat(deps): enabled fastembed and onnxruntime peers to install on demand
- Moved `fastembed` and `onnxruntime-node` to optional peerDependencies. - Fixed bundled installs that could not resolve `onnxruntime_binding.node`. - Added shared `runtime-install` utilities for on-demand module resolution. - Added tests for runtime-resolution parsing and exact peer-version checks.
This commit is contained in:
@@ -144,13 +144,21 @@
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
"fastembed": "catalog:",
|
||||
"lru-cache": "catalog:",
|
||||
"onnxruntime-node": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"fastembed": "catalog:",
|
||||
"onnxruntime-node": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"fastembed": "2.1.0",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"fastembed",
|
||||
"onnxruntime-node",
|
||||
],
|
||||
},
|
||||
"packages/natives": {
|
||||
"name": "@oh-my-pi/pi-natives",
|
||||
|
||||
@@ -10,13 +10,12 @@
|
||||
|
||||
- Changed collab links so full links with a write token grant mutation rights while links without a token now join as read-only
|
||||
- `/collab` now prints a join hint with both link forms: the compact `omp join` link for terminals and a click-to-join browser deep link (`https://<relay-host>/#<link>`, displayed scheme-less, OSC 8-linked) — the relay serves the collab web client at `/`, and the room id + key ride in the URL fragment, so they never appear in any HTTP request. `/join`, `omp join`, and the web connect screen accept either form
|
||||
- npm installs no longer download fastembed's ~270MB ONNX native dependency tree eagerly: `fastembed` and `onnxruntime-node` are external to the bundle and optional peers of `@oh-my-pi/pi-mnemopi`, fetched on demand only when Mnemopi local embeddings are first used
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed read-only collab sessions so prompting, interrupts, and other write actions are blocked with a read-only warning instead of being applied
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Mnemopi local embeddings in bundled and compiled installs failing with `Cannot find module '../bin/napi-v3/.../onnxruntime_binding.node'`: the Bun bundle inlined fastembed's loader so its relative native require resolved against `dist/cli.js`. `fastembed`/`onnxruntime-node` are no longer bundled; on first use Mnemopi `bun install`s the pinned pair into `~/.omp/cache/fastembed-runtime/<version-key>` and loads the binding from there ([#2389](https://github.com/can1357/oh-my-pi/issues/2389))
|
||||
- Fixed the interactive Model scope startup banner so models without an explicit thinking level do not show `:undefined`, and entries that were scoped without a `:level` are no longer rendered with the global default thinking level (which `applyRootSessionOptions` pre-fills on the cycling array for Ctrl+P) ([#2385](https://github.com/can1357/oh-my-pi/issues/2385)).
|
||||
|
||||
## [15.11.8] - 2026-06-12
|
||||
|
||||
@@ -59,6 +59,10 @@ async function main(): Promise<void> {
|
||||
`process.env.PI_TINY_TRANSFORMERS_VERSION=${JSON.stringify(transformersVersion)}`,
|
||||
"--external",
|
||||
"mupdf",
|
||||
"--external",
|
||||
"fastembed",
|
||||
"--external",
|
||||
"onnxruntime-node",
|
||||
"--root",
|
||||
".",
|
||||
"./packages/coding-agent/src/cli.ts",
|
||||
|
||||
@@ -72,6 +72,10 @@ async function main(): Promise<void> {
|
||||
"@oh-my-pi/pi-natives",
|
||||
"--external",
|
||||
"@huggingface/transformers",
|
||||
"--external",
|
||||
"fastembed",
|
||||
"--external",
|
||||
"onnxruntime-node",
|
||||
"--define",
|
||||
'process.env.PI_BUNDLED="true"',
|
||||
"./src/cli.ts",
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as Module from "node:module";
|
||||
import * as path from "node:path";
|
||||
|
||||
/**
|
||||
* Bun's compiled-binary module resolver only finds `<pkg>/index.js` for bare
|
||||
* specifiers loaded from the *real* filesystem — it ignores `main`/`exports`
|
||||
* (issue #1763). The tiny-model Transformers.js runtime is `bun install`ed into
|
||||
* a cache directory at runtime, and its graph (`@huggingface/transformers` →
|
||||
* `onnxruntime-node` → `onnxruntime-common`, plus an eager `require("sharp")`)
|
||||
* all point `main`/`exports` at nested files, so the stock resolver cannot load
|
||||
* any of them. We patch `Module._resolveFilename` to resolve those bare
|
||||
* specifiers against the cache ourselves, honoring `main`/`exports`.
|
||||
*
|
||||
* This module is filesystem-pure aside from {@link installRuntimeModuleResolver}
|
||||
* mutating the `node:module` resolver, so the resolution logic is unit-testable
|
||||
* without a compiled binary.
|
||||
*/
|
||||
|
||||
/** Conditions honored when resolving an `exports` map for a CommonJS `require`. */
|
||||
const RUNTIME_CONDITIONS: Record<string, true> = { node: true, require: true, default: true };
|
||||
|
||||
/** Extension probes appended to a `main`/`exports` target that lacks one. */
|
||||
const RUNTIME_EXTENSIONS: readonly string[] = [".js", ".cjs", ".mjs", ".json", ".node"];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a conditional `exports` target (string, array of fallbacks, or a
|
||||
* condition object) and return the first relative path that matches a runtime
|
||||
* condition in declaration order. Returns `null` when nothing applies (e.g.
|
||||
* `import`-only targets).
|
||||
*/
|
||||
export function selectConditionalTarget(target: unknown): string | null {
|
||||
if (typeof target === "string") return target;
|
||||
if (Array.isArray(target)) {
|
||||
for (const entry of target) {
|
||||
const resolved = selectConditionalTarget(entry);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (isRecord(target)) {
|
||||
for (const condition in target) {
|
||||
if (!RUNTIME_CONDITIONS[condition]) continue;
|
||||
const resolved = selectConditionalTarget(target[condition]);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve a relative target inside a package to a concrete file path, probing extensions and `index`. */
|
||||
function resolveFileTarget(pkgDir: string, relative: string): string | null {
|
||||
const base = path.join(pkgDir, relative);
|
||||
const candidates = [base, ...RUNTIME_EXTENSIONS.map(ext => base + ext)];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stat = fs.statSync(candidate);
|
||||
if (stat.isFile()) return candidate;
|
||||
if (stat.isDirectory()) {
|
||||
const indexed = resolveFileTarget(candidate, "index");
|
||||
if (indexed) return indexed;
|
||||
}
|
||||
} catch {
|
||||
// missing candidate — keep probing
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveExportsEntry(
|
||||
pkgDir: string,
|
||||
exports: Record<string, unknown>,
|
||||
subpath: string | undefined,
|
||||
): string | null {
|
||||
let subpathMap = false;
|
||||
for (const key in exports) {
|
||||
subpathMap = key === "." || key.startsWith("./");
|
||||
break;
|
||||
}
|
||||
if (subpathMap) {
|
||||
const key = subpath ? `./${subpath}` : ".";
|
||||
if (!(key in exports)) return null;
|
||||
const target = selectConditionalTarget(exports[key]);
|
||||
return target ? resolveFileTarget(pkgDir, target) : null;
|
||||
}
|
||||
// A bare condition map only describes the package root, so a subpath
|
||||
// request falls through to plain path joining at the call site.
|
||||
if (subpath) return null;
|
||||
const target = selectConditionalTarget(exports);
|
||||
return target ? resolveFileTarget(pkgDir, target) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a bare specifier into its package name and optional subpath, handling
|
||||
* scoped packages (`@scope/name/sub` → `@scope/name` + `sub`).
|
||||
*/
|
||||
export function splitBareSpecifier(specifier: string): { packageName: string; subpath: string | undefined } {
|
||||
const segments = specifier.split("/");
|
||||
const take = specifier.startsWith("@") ? 2 : 1;
|
||||
const packageName = segments.slice(0, take).join("/");
|
||||
const subpath = segments.length > take ? segments.slice(take).join("/") : undefined;
|
||||
return { packageName, subpath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bare specifier against an installed `node_modules` directory,
|
||||
* honoring `exports` (CommonJS conditions), then `main`, then `index.js`.
|
||||
* Returns an absolute file path, or `null` when the package/entry is absent.
|
||||
*/
|
||||
export function resolveRuntimeModule(runtimeNodeModules: string, specifier: string): string | null {
|
||||
const { packageName, subpath } = splitBareSpecifier(specifier);
|
||||
const pkgDir = path.join(runtimeNodeModules, ...packageName.split("/"));
|
||||
const manifest = readManifest(pkgDir);
|
||||
if (!manifest) return subpath ? resolveFileTarget(pkgDir, subpath) : null;
|
||||
|
||||
const { exports } = manifest;
|
||||
if (typeof exports === "string" || isRecord(exports)) {
|
||||
const map = typeof exports === "string" ? { ".": exports } : exports;
|
||||
const resolved = resolveExportsEntry(pkgDir, map, subpath);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
if (subpath) return resolveFileTarget(pkgDir, subpath);
|
||||
if (typeof manifest.main === "string") {
|
||||
const resolved = resolveFileTarget(pkgDir, manifest.main);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
return resolveFileTarget(pkgDir, "index.js");
|
||||
}
|
||||
|
||||
function readManifest(pkgDir: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ModuleResolver {
|
||||
_resolveFilename(request: string, parent: unknown, isMain: boolean, options?: unknown): string;
|
||||
}
|
||||
|
||||
const PATCHED = Symbol.for("omp.tiny.compiledRuntimeResolver");
|
||||
|
||||
interface ResolverOptions {
|
||||
/** Absolute path to the runtime cache's `node_modules`. */
|
||||
runtimeNodeModules: string;
|
||||
/** Bare specifier → absolute file path overrides (e.g. `sharp` → no-op stub). */
|
||||
stubs?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch `node:module`'s resolver (idempotently) so bare specifiers that the
|
||||
* stock compiled-binary resolver cannot find fall back to the runtime cache.
|
||||
* Stock resolution is tried first, so this never changes behavior for modules
|
||||
* that already resolve (the worker's own bundled imports, node builtins).
|
||||
*/
|
||||
export function installRuntimeModuleResolver({ runtimeNodeModules, stubs = {} }: ResolverOptions): void {
|
||||
const resolver = (Module as unknown as { default?: ModuleResolver } & ModuleResolver).default ?? Module;
|
||||
const target = resolver as unknown as ModuleResolver & { [PATCHED]?: boolean };
|
||||
if (target[PATCHED]) return;
|
||||
const original = target._resolveFilename.bind(target);
|
||||
target._resolveFilename = (request: string, parent: unknown, isMain: boolean, options?: unknown): string => {
|
||||
try {
|
||||
return original(request, parent, isMain, options);
|
||||
} catch (error) {
|
||||
const stub = stubs[request];
|
||||
if (stub) return stub;
|
||||
const resolved = resolveRuntimeModule(runtimeNodeModules, request);
|
||||
if (resolved) return resolved;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
target[PATCHED] = true;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import * as path from "node:path";
|
||||
import type {
|
||||
@@ -7,10 +6,16 @@ import type {
|
||||
TextGenerationStringOutput,
|
||||
StoppingCriteria as TransformersStoppingCriteria,
|
||||
} from "@huggingface/transformers";
|
||||
import { getTinyModelsCacheDir, isCompiledBinary, prompt } from "@oh-my-pi/pi-utils";
|
||||
import {
|
||||
ensureRuntimeInstalled,
|
||||
getTinyModelsCacheDir,
|
||||
installRuntimeModuleResolver,
|
||||
isCompiledBinary,
|
||||
prompt,
|
||||
resolveRuntimeModule,
|
||||
} from "@oh-my-pi/pi-utils";
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
import tinyTitleSystemPrompt from "../prompts/system/tiny-title-system.md" with { type: "text" };
|
||||
import { installRuntimeModuleResolver, resolveRuntimeModule } from "./compiled-runtime";
|
||||
import { resolveTinyModelDevicePreference, type TinyModelDevice, tinyModelDeviceLoadOrder } from "./device";
|
||||
import { resolveTinyModelDtypeOverride, type TinyModelDtype } from "./dtype";
|
||||
import {
|
||||
@@ -31,8 +36,6 @@ const TINY_TITLE_SYSTEM_PROMPT = prompt.render(tinyTitleSystemPrompt);
|
||||
const TRANSFORMERS_PACKAGE = "@huggingface/transformers";
|
||||
const COMPILED_TRANSFORMERS_VERSION = process.env.PI_TINY_TRANSFORMERS_VERSION;
|
||||
const sourceRequire = createRequire(import.meta.url);
|
||||
const INSTALL_LOCK_ATTEMPTS = 240;
|
||||
const INSTALL_LOCK_SLEEP_MS = 250;
|
||||
|
||||
const tinyModelDevicePreference = resolveTinyModelDevicePreference();
|
||||
const tinyModelDtypeOverride = resolveTinyModelDtypeOverride();
|
||||
@@ -97,10 +100,6 @@ function errorText(error: unknown): string {
|
||||
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
}
|
||||
|
||||
function isErrnoCode(error: unknown, code: string): boolean {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
function sendLog(
|
||||
transport: TinyTitleTransport,
|
||||
level: "debug" | "warn" | "error",
|
||||
@@ -118,69 +117,6 @@ function getTinyTitleRuntimeDir(): string {
|
||||
);
|
||||
}
|
||||
|
||||
async function acquireInstallLock(runtimeDir: string): Promise<() => Promise<void>> {
|
||||
const lockDir = `${runtimeDir}.lock`;
|
||||
await fs.mkdir(path.dirname(lockDir), { recursive: true });
|
||||
for (let attempt = 0; attempt < INSTALL_LOCK_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await fs.mkdir(lockDir);
|
||||
return async () => {
|
||||
await fs.rm(lockDir, { recursive: true, force: true });
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isErrnoCode(error, "EEXIST")) throw error;
|
||||
await Bun.sleep(INSTALL_LOCK_SLEEP_MS);
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for tiny title runtime install lock: ${lockDir}`);
|
||||
}
|
||||
|
||||
async function isCompiledRuntimeInstalled(runtimeDir: string): Promise<boolean> {
|
||||
return Bun.file(path.join(runtimeDir, "node_modules", "@huggingface", "transformers", "package.json")).exists();
|
||||
}
|
||||
|
||||
async function writeRuntimeManifest(runtimeDir: string): Promise<void> {
|
||||
await fs.mkdir(runtimeDir, { recursive: true });
|
||||
await Bun.write(
|
||||
path.join(runtimeDir, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
[TRANSFORMERS_PACKAGE]: getTransformersVersionSpec(),
|
||||
},
|
||||
trustedDependencies: ["onnxruntime-node"],
|
||||
},
|
||||
null,
|
||||
"\t",
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function readPipe(stream: ReadableStream<Uint8Array> | null): Promise<string> {
|
||||
if (!stream) return "";
|
||||
return new Response(stream).text();
|
||||
}
|
||||
|
||||
async function runRuntimeInstall(runtimeDir: string): Promise<void> {
|
||||
const proc = Bun.spawn([process.execPath, "install", "--cwd", runtimeDir, "--production"], {
|
||||
env: { ...Bun.env, BUN_BE_BUN: "1" },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
readPipe(proc.stdout as ReadableStream<Uint8Array> | null),
|
||||
readPipe(proc.stderr as ReadableStream<Uint8Array> | null),
|
||||
proc.exited,
|
||||
]);
|
||||
if (exitCode === 0) return;
|
||||
const output = `${stdout}\n${stderr}`.trim();
|
||||
throw new Error(
|
||||
`Failed to install tiny title runtime with ${process.execPath} install (exit ${exitCode}): ${output}`,
|
||||
);
|
||||
}
|
||||
|
||||
function sendRuntimeInstallProgress(
|
||||
transport: TinyTitleTransport,
|
||||
requestId: string,
|
||||
@@ -198,28 +134,6 @@ function sendRuntimeInstallProgress(
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureCompiledTransformersRuntime(
|
||||
transport: TinyTitleTransport,
|
||||
requestId: string,
|
||||
modelKey: TinyLocalModelKey,
|
||||
): Promise<string> {
|
||||
const runtimeDir = getTinyTitleRuntimeDir();
|
||||
if (await isCompiledRuntimeInstalled(runtimeDir)) return runtimeDir;
|
||||
|
||||
sendRuntimeInstallProgress(transport, requestId, modelKey, "initiate");
|
||||
const releaseLock = await acquireInstallLock(runtimeDir);
|
||||
try {
|
||||
if (await isCompiledRuntimeInstalled(runtimeDir)) return runtimeDir;
|
||||
await writeRuntimeManifest(runtimeDir);
|
||||
sendRuntimeInstallProgress(transport, requestId, modelKey, "download");
|
||||
await runRuntimeInstall(runtimeDir);
|
||||
sendRuntimeInstallProgress(transport, requestId, modelKey, "done");
|
||||
return runtimeDir;
|
||||
} finally {
|
||||
await releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the freshly-installed compiled runtime for loading: stub `sharp`
|
||||
* (the tiny models are text-generation only, so the native image pipeline is
|
||||
@@ -252,7 +166,15 @@ async function loadTransformers(
|
||||
if (transformersRuntime) return transformersRuntime;
|
||||
transformersRuntime = (async () => {
|
||||
if (!isCompiledBinary()) return configureTransformers(sourceRequire(TRANSFORMERS_PACKAGE) as TransformersRuntime);
|
||||
const runtimeDir = await ensureCompiledTransformersRuntime(transport, requestId, modelKey);
|
||||
const runtimeDir = await ensureRuntimeInstalled({
|
||||
runtimeDir: getTinyTitleRuntimeDir(),
|
||||
install: {
|
||||
dependencies: { [TRANSFORMERS_PACKAGE]: getTransformersVersionSpec() },
|
||||
trustedDependencies: ["onnxruntime-node"],
|
||||
},
|
||||
probePackage: TRANSFORMERS_PACKAGE,
|
||||
onPhase: phase => sendRuntimeInstallProgress(transport, requestId, modelKey, phase),
|
||||
});
|
||||
const entry = await prepareCompiledRuntime(runtimeDir);
|
||||
const require_ = createRequire(entry);
|
||||
return configureTransformers(require_(entry) as TransformersRuntime);
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved `fastembed` and `onnxruntime-node` from `dependencies` to optional `peerDependencies` pinned to exact versions. When the peers are absent (bundled CLI, compiled binary, or installs that skip optional peers), the local embedding path `bun install`s the pinned pair into `~/.omp/cache/fastembed-runtime/<version-key>` on first use and loads fastembed from there — restoring local embeddings in bundled distributions and removing ~270MB of eager native downloads from default installs ([#2389](https://github.com/can1357/oh-my-pi/issues/2389))
|
||||
|
||||
## [15.11.4] - 2026-06-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -42,12 +42,24 @@
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
"fastembed": "catalog:",
|
||||
"lru-cache": "catalog:",
|
||||
"onnxruntime-node": "catalog:"
|
||||
"lru-cache": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"fastembed": "2.1.0",
|
||||
"onnxruntime-node": "1.24.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"fastembed": {
|
||||
"optional": true
|
||||
},
|
||||
"onnxruntime-node": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:"
|
||||
"@types/bun": "catalog:",
|
||||
"fastembed": "catalog:",
|
||||
"onnxruntime-node": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.3.14"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { EmbeddingModel } from "fastembed";
|
||||
import { LRUCache } from "lru-cache/raw";
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
import { loadFastembed } from "./fastembed-runtime";
|
||||
import {
|
||||
type EmbeddingOutput,
|
||||
getMnemopiRuntimeOptions,
|
||||
@@ -61,17 +62,7 @@ const providerIds = new WeakMap<object, number>();
|
||||
let nextProviderId = 1;
|
||||
|
||||
async function defaultLocalModelInitializer(options: LocalModelInitOptions): Promise<LocalEmbeddingModel> {
|
||||
// Preload ORT 1.24 before fastembed's bundled ORT 1.21 — only on Windows,
|
||||
// where loading the older binding first triggers a DLL-reuse crash. The 1.24
|
||||
// line also has no darwin/x64 prebuilt, so importing it unconditionally breaks
|
||||
// the darwin-x64 `bun build --compile` (Bun folds process.platform/arch and
|
||||
// fails to resolve a binding that doesn't ship). The `win32` literal guard is
|
||||
// statically foldable, so Bun dead-code-eliminates this import on every
|
||||
// non-Windows target; fastembed loads its own ORT 1.21 binding there.
|
||||
if (process.platform === "win32") {
|
||||
await import("onnxruntime-node");
|
||||
}
|
||||
const { FlagEmbedding } = await import("fastembed");
|
||||
const { FlagEmbedding } = await loadFastembed();
|
||||
return FlagEmbedding.init(options);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createRequire } from "node:module";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
ensureRuntimeInstalled,
|
||||
getFastembedRuntimeDir,
|
||||
installRuntimeModuleResolver,
|
||||
logger,
|
||||
resolveRuntimeModule,
|
||||
} from "@oh-my-pi/pi-utils";
|
||||
import type * as Fastembed from "fastembed";
|
||||
import packageManifest from "../../package.json" with { type: "json" };
|
||||
|
||||
type FastembedModule = typeof Fastembed;
|
||||
|
||||
/**
|
||||
* `fastembed` and `onnxruntime-node` are optional peers (~270MB of native
|
||||
* assets across platforms), never bundled and never installed eagerly. When
|
||||
* the direct import cannot resolve — bundled `dist/cli.js`, compiled binary,
|
||||
* or a consumer that skipped the optional peers — the pinned pair is
|
||||
* `bun install`ed into a per-version runtime cache on first use and loaded
|
||||
* from there (#2389).
|
||||
*
|
||||
* The pins live in `peerDependencies` as exact versions (not `catalog:`) so
|
||||
* this module reads concrete specs even when the workspace manifest is
|
||||
* inlined into a bundle; a workspace test asserts they match the catalog.
|
||||
*/
|
||||
const FASTEMBED_SPEC = packageManifest.peerDependencies.fastembed;
|
||||
const ORT_SPEC = packageManifest.peerDependencies["onnxruntime-node"];
|
||||
|
||||
let fastembedLoad: Promise<FastembedModule> | null = null;
|
||||
|
||||
export function loadFastembed(): Promise<FastembedModule> {
|
||||
fastembedLoad ??= loadFastembedOnce().catch(error => {
|
||||
fastembedLoad = null;
|
||||
throw error;
|
||||
});
|
||||
return fastembedLoad;
|
||||
}
|
||||
|
||||
async function loadFastembedOnce(): Promise<FastembedModule> {
|
||||
// Dynamic imports: both packages are optional peers that eagerly load
|
||||
// native addons and may be absent at runtime — a static import would load
|
||||
// the addon at module-init and crash every consumer without the peers.
|
||||
try {
|
||||
// Preload ORT 1.24 before fastembed's nested ORT 1.21 — only on Windows,
|
||||
// where loading the older binding first triggers a DLL-reuse crash.
|
||||
if (process.platform === "win32") {
|
||||
await import("onnxruntime-node");
|
||||
}
|
||||
return await import("fastembed");
|
||||
} catch (error) {
|
||||
if (!isModuleResolutionError(error)) throw error;
|
||||
logger.debug("mnemopi: fastembed not resolvable, using on-demand runtime install", {
|
||||
error: String(error),
|
||||
});
|
||||
return loadFromRuntimeInstall();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFromRuntimeInstall(): Promise<FastembedModule> {
|
||||
const versionKey = `fastembed-${FASTEMBED_SPEC}_ort-${ORT_SPEC}`.replace(/[^A-Za-z0-9._-]/g, "_");
|
||||
const runtimeDir = await ensureRuntimeInstalled({
|
||||
runtimeDir: path.join(getFastembedRuntimeDir(), versionKey),
|
||||
install: { dependencies: { fastembed: FASTEMBED_SPEC, "onnxruntime-node": ORT_SPEC } },
|
||||
probePackage: "fastembed",
|
||||
});
|
||||
const nodeModules = path.join(runtimeDir, "node_modules");
|
||||
// The compiled-binary resolver ignores `main`/`exports` for real-FS bare
|
||||
// specifiers (Bun #1763); route the runtime graph's requires (fastembed →
|
||||
// onnxruntime-node, @anush008/tokenizers → platform binding, …) through
|
||||
// the runtime cache.
|
||||
installRuntimeModuleResolver({ runtimeNodeModules: nodeModules });
|
||||
if (process.platform === "win32") {
|
||||
const ortEntry = resolveRuntimeModule(nodeModules, "onnxruntime-node");
|
||||
if (ortEntry) createRequire(ortEntry)(ortEntry);
|
||||
}
|
||||
const entry = resolveRuntimeModule(nodeModules, "fastembed");
|
||||
if (!entry) throw new Error(`fastembed runtime install at ${runtimeDir} has no loadable entry`);
|
||||
const requireRuntime = createRequire(entry);
|
||||
return requireRuntime(entry) as FastembedModule;
|
||||
}
|
||||
|
||||
function isModuleResolutionError(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null) return false;
|
||||
const { name, code, message } = error as { name?: unknown; code?: unknown; message?: unknown };
|
||||
if (name === "ResolveMessage") return true;
|
||||
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") return true;
|
||||
return typeof message === "string" && /cannot find (module|package)/i.test(message);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import rootManifest from "../../../package.json" with { type: "json" };
|
||||
import packageManifest from "../package.json" with { type: "json" };
|
||||
|
||||
// The fastembed/onnxruntime-node peers are pinned as exact versions (not
|
||||
// `catalog:`) because `core/fastembed-runtime.ts` reads them to `bun install`
|
||||
// the on-demand embedding runtime — including from bundles where the inlined
|
||||
// manifest would otherwise carry an uninstallable `catalog:` spec (#2389).
|
||||
// This pins the contract: the runtime install materializes exactly the
|
||||
// versions the workspace develops and tests against.
|
||||
describe("fastembed runtime version pins", () => {
|
||||
const catalog = rootManifest.workspaces.catalog;
|
||||
|
||||
test("peer pins match the workspace catalog", () => {
|
||||
expect(packageManifest.peerDependencies.fastembed).toBe(catalog.fastembed);
|
||||
expect(packageManifest.peerDependencies["onnxruntime-node"]).toBe(catalog["onnxruntime-node"]);
|
||||
});
|
||||
|
||||
test("pins are exact installable versions, not catalog or range specs", () => {
|
||||
expect(packageManifest.peerDependencies.fastembed).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
expect(packageManifest.peerDependencies["onnxruntime-node"]).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added `runtime-install`: shared on-demand runtime dependency support — `ensureRuntimeInstalled()` (locked, idempotent `bun install` of a pinned dependency set into a cache dir) and a multi-root `installRuntimeModuleResolver()`/`resolveRuntimeModule()` for loading those graphs inside compiled binaries (Bun #1763). Extracted from the coding-agent tiny-model worker; now also backs Mnemopi's on-demand fastembed runtime ([#2389](https://github.com/can1357/oh-my-pi/issues/2389))
|
||||
- Added `getFastembedRuntimeDir()` (~/.omp/cache/fastembed-runtime) alongside `getFastembedCacheDir()`
|
||||
|
||||
## [15.11.4] - 2026-06-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -359,6 +359,11 @@ export function getFastembedCacheDir(): string {
|
||||
return dirs.rootSubdir(path.join("cache", "fastembed"), "cache");
|
||||
}
|
||||
|
||||
/** Get the on-demand fastembed runtime install root (~/.omp/cache/fastembed-runtime). */
|
||||
export function getFastembedRuntimeDir(): string {
|
||||
return dirs.rootSubdir(path.join("cache", "fastembed-runtime"), "cache");
|
||||
}
|
||||
|
||||
/** Get the natives directory (~/.omp/natives). */
|
||||
export function getNativesDir(): string {
|
||||
return dirs.rootSubdir("natives", "cache");
|
||||
|
||||
@@ -19,6 +19,7 @@ export * as procmgr from "./procmgr";
|
||||
export * as prompt from "./prompt";
|
||||
export * as ptree from "./ptree";
|
||||
export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
|
||||
export * from "./runtime-install";
|
||||
export * from "./sanitize-text";
|
||||
export * from "./snowflake";
|
||||
export * from "./stream";
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as fsp from "node:fs/promises";
|
||||
import * as Module from "node:module";
|
||||
import * as path from "node:path";
|
||||
|
||||
/**
|
||||
* On-demand runtime dependency support for native-heavy optional packages
|
||||
* (Transformers.js, fastembed) that are never bundled into the CLI or the
|
||||
* compiled binary. Consumers `bun install` a pinned dependency set into a
|
||||
* cache directory on first use ({@link ensureRuntimeInstalled}) and load the
|
||||
* entrypoint via `createRequire`.
|
||||
*
|
||||
* Bun's compiled-binary module resolver only finds `<pkg>/index.js` for bare
|
||||
* specifiers loaded from the *real* filesystem — it ignores `main`/`exports`
|
||||
* (issue #1763). Runtime-installed graphs (`@huggingface/transformers` →
|
||||
* `onnxruntime-node` → `onnxruntime-common`, `fastembed` →
|
||||
* `@anush008/tokenizers` → platform binding) all point `main`/`exports` at
|
||||
* nested files, so the stock resolver cannot load any of them. We patch
|
||||
* `Module._resolveFilename` to resolve those bare specifiers against the
|
||||
* registered runtime caches ourselves, honoring `main`/`exports`.
|
||||
*
|
||||
* This module is filesystem-pure aside from {@link installRuntimeModuleResolver}
|
||||
* mutating the `node:module` resolver, so the resolution logic is unit-testable
|
||||
* without a compiled binary.
|
||||
*/
|
||||
|
||||
/** Conditions honored when resolving an `exports` map for a CommonJS `require`. */
|
||||
const RUNTIME_CONDITIONS: Record<string, true> = { node: true, require: true, default: true };
|
||||
|
||||
/** Extension probes appended to a `main`/`exports` target that lacks one. */
|
||||
const RUNTIME_EXTENSIONS: readonly string[] = [".js", ".cjs", ".mjs", ".json", ".node"];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a conditional `exports` target (string, array of fallbacks, or a
|
||||
* condition object) and return the first relative path that matches a runtime
|
||||
* condition in declaration order. Returns `null` when nothing applies (e.g.
|
||||
* an `import`-only entry).
|
||||
*/
|
||||
export function selectConditionalTarget(target: unknown): string | null {
|
||||
if (typeof target === "string") return target;
|
||||
if (Array.isArray(target)) {
|
||||
for (const entry of target) {
|
||||
const resolved = selectConditionalTarget(entry);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (isRecord(target)) {
|
||||
for (const condition in target) {
|
||||
if (!RUNTIME_CONDITIONS[condition]) continue;
|
||||
const resolved = selectConditionalTarget(target[condition]);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve a relative target inside a package to a concrete file path, probing extensions and `index`. */
|
||||
function resolveFileTarget(pkgDir: string, relative: string): string | null {
|
||||
const base = path.join(pkgDir, relative);
|
||||
const candidates = [base, ...RUNTIME_EXTENSIONS.map(ext => base + ext)];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stat = fs.statSync(candidate);
|
||||
if (stat.isFile()) return candidate;
|
||||
if (stat.isDirectory()) {
|
||||
const indexed = resolveFileTarget(candidate, "index");
|
||||
if (indexed) return indexed;
|
||||
}
|
||||
} catch {
|
||||
// missing candidate — keep probing
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveExportsEntry(
|
||||
pkgDir: string,
|
||||
exports: Record<string, unknown>,
|
||||
subpath: string | undefined,
|
||||
): string | null {
|
||||
let subpathMap = false;
|
||||
for (const key in exports) {
|
||||
subpathMap = key === "." || key.startsWith("./");
|
||||
break;
|
||||
}
|
||||
if (subpathMap) {
|
||||
const key = subpath ? `./${subpath}` : ".";
|
||||
if (!(key in exports)) return null;
|
||||
const target = selectConditionalTarget(exports[key]);
|
||||
return target ? resolveFileTarget(pkgDir, target) : null;
|
||||
}
|
||||
// A bare condition map only describes the package root, so a subpath
|
||||
// request falls through to plain path joining at the call site.
|
||||
if (subpath) return null;
|
||||
const target = selectConditionalTarget(exports);
|
||||
return target ? resolveFileTarget(pkgDir, target) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a bare specifier into its package name and optional subpath, handling
|
||||
* scoped packages (`@scope/name/sub` → `@scope/name` + `sub`).
|
||||
*/
|
||||
export function splitBareSpecifier(specifier: string): { packageName: string; subpath: string | undefined } {
|
||||
const segments = specifier.split("/");
|
||||
const take = specifier.startsWith("@") ? 2 : 1;
|
||||
const packageName = segments.slice(0, take).join("/");
|
||||
const subpath = segments.length > take ? segments.slice(take).join("/") : undefined;
|
||||
return { packageName, subpath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bare specifier against an installed `node_modules` directory,
|
||||
* honoring `exports` (CommonJS conditions), then `main`, then `index.js`.
|
||||
* Returns an absolute file path, or `null` when the package/entry is absent.
|
||||
*/
|
||||
export function resolveRuntimeModule(runtimeNodeModules: string, specifier: string): string | null {
|
||||
const { packageName, subpath } = splitBareSpecifier(specifier);
|
||||
const pkgDir = path.join(runtimeNodeModules, ...packageName.split("/"));
|
||||
const manifest = readManifest(pkgDir);
|
||||
if (!manifest) return subpath ? resolveFileTarget(pkgDir, subpath) : null;
|
||||
|
||||
const { exports } = manifest;
|
||||
if (typeof exports === "string" || isRecord(exports)) {
|
||||
const map = typeof exports === "string" ? { ".": exports } : exports;
|
||||
const resolved = resolveExportsEntry(pkgDir, map, subpath);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
if (subpath) return resolveFileTarget(pkgDir, subpath);
|
||||
if (typeof manifest.main === "string") {
|
||||
const resolved = resolveFileTarget(pkgDir, manifest.main);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
return resolveFileTarget(pkgDir, "index.js");
|
||||
}
|
||||
|
||||
function readManifest(pkgDir: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ModuleResolver {
|
||||
_resolveFilename(request: string, parent: unknown, isMain: boolean, options?: unknown): string;
|
||||
}
|
||||
|
||||
interface ResolverRegistration {
|
||||
runtimeNodeModules: string;
|
||||
stubs: Record<string, string>;
|
||||
}
|
||||
|
||||
const REGISTRY = Symbol.for("omp.runtimeModuleResolver.registry");
|
||||
const PATCHED = Symbol.for("omp.runtimeModuleResolver.patched");
|
||||
|
||||
/**
|
||||
* The registration list lives on `globalThis` so a bundled copy and a
|
||||
* source copy of this module in one process share the same registry — the
|
||||
* resolver is patched once per process, and the patched closure must see
|
||||
* every registration.
|
||||
*/
|
||||
function resolverRegistry(): ResolverRegistration[] {
|
||||
const holder = globalThis as { [REGISTRY]?: ResolverRegistration[] };
|
||||
holder[REGISTRY] ??= [];
|
||||
return holder[REGISTRY];
|
||||
}
|
||||
|
||||
export interface RuntimeResolverOptions {
|
||||
/** Absolute path to the runtime cache's `node_modules`. */
|
||||
runtimeNodeModules: string;
|
||||
/** Bare specifier → absolute file path overrides (e.g. `sharp` → no-op stub). */
|
||||
stubs?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch `node:module`'s resolver (idempotently) so bare specifiers that the
|
||||
* stock compiled-binary resolver cannot find fall back to the registered
|
||||
* runtime caches. Stock resolution is tried first, so this never changes
|
||||
* behavior for modules that already resolve (bundled imports, node builtins).
|
||||
* Multiple runtime roots may register; they are consulted in registration
|
||||
* order.
|
||||
*/
|
||||
export function installRuntimeModuleResolver({ runtimeNodeModules, stubs = {} }: RuntimeResolverOptions): void {
|
||||
const registry = resolverRegistry();
|
||||
const existing = registry.find(entry => entry.runtimeNodeModules === runtimeNodeModules);
|
||||
if (existing) Object.assign(existing.stubs, stubs);
|
||||
else registry.push({ runtimeNodeModules, stubs: { ...stubs } });
|
||||
|
||||
const resolver = (Module as unknown as { default?: ModuleResolver } & ModuleResolver).default ?? Module;
|
||||
const target = resolver as unknown as ModuleResolver & { [PATCHED]?: boolean };
|
||||
if (target[PATCHED]) return;
|
||||
const original = target._resolveFilename.bind(target);
|
||||
target._resolveFilename = (request: string, parent: unknown, isMain: boolean, options?: unknown): string => {
|
||||
try {
|
||||
return original(request, parent, isMain, options);
|
||||
} catch (error) {
|
||||
for (const registration of resolverRegistry()) {
|
||||
const stub = registration.stubs[request];
|
||||
if (stub) return stub;
|
||||
const resolved = resolveRuntimeModule(registration.runtimeNodeModules, request);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
target[PATCHED] = true;
|
||||
}
|
||||
|
||||
/** Pinned dependency set materialized into a runtime cache directory. */
|
||||
export interface RuntimeInstallSpec {
|
||||
dependencies: Record<string, string>;
|
||||
/** Packages whose lifecycle scripts bun may run during the install. */
|
||||
trustedDependencies?: string[];
|
||||
}
|
||||
|
||||
export type RuntimeInstallPhase = "initiate" | "download" | "done";
|
||||
|
||||
export interface EnsureRuntimeInstalledOptions {
|
||||
/** Directory owning the runtime `package.json` + `node_modules`. */
|
||||
runtimeDir: string;
|
||||
install: RuntimeInstallSpec;
|
||||
/** Package whose installed manifest marks the runtime complete; defaults to the first dependency. */
|
||||
probePackage?: string;
|
||||
/** Phase notifications (progress UI); not emitted when already installed. */
|
||||
onPhase?: (phase: RuntimeInstallPhase) => void;
|
||||
lockAttempts?: number;
|
||||
lockSleepMs?: number;
|
||||
}
|
||||
|
||||
function isErrnoCode(error: unknown, code: string): boolean {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
async function acquireInstallLock(runtimeDir: string, attempts: number, sleepMs: number): Promise<() => Promise<void>> {
|
||||
const lockDir = `${runtimeDir}.lock`;
|
||||
await fsp.mkdir(path.dirname(lockDir), { recursive: true });
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
try {
|
||||
await fsp.mkdir(lockDir);
|
||||
return async () => {
|
||||
await fsp.rm(lockDir, { recursive: true, force: true });
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isErrnoCode(error, "EEXIST")) throw error;
|
||||
await Bun.sleep(sleepMs);
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for runtime install lock: ${lockDir}`);
|
||||
}
|
||||
|
||||
async function writeRuntimeManifest(runtimeDir: string, install: RuntimeInstallSpec): Promise<void> {
|
||||
await fsp.mkdir(runtimeDir, { recursive: true });
|
||||
const manifest: Record<string, unknown> = {
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: install.dependencies,
|
||||
};
|
||||
if (install.trustedDependencies?.length) manifest.trustedDependencies = install.trustedDependencies;
|
||||
await Bun.write(path.join(runtimeDir, "package.json"), `${JSON.stringify(manifest, null, "\t")}\n`);
|
||||
}
|
||||
|
||||
async function readPipe(stream: ReadableStream<Uint8Array> | null): Promise<string> {
|
||||
if (!stream) return "";
|
||||
return new Response(stream).text();
|
||||
}
|
||||
|
||||
async function runRuntimeInstall(runtimeDir: string): Promise<void> {
|
||||
// `process.execPath` is plain bun in source/bundle mode and the compiled
|
||||
// binary otherwise; BUN_BE_BUN makes the compiled binary act as bun.
|
||||
const proc = Bun.spawn([process.execPath, "install", "--cwd", runtimeDir, "--production"], {
|
||||
env: { ...Bun.env, BUN_BE_BUN: "1" },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
readPipe(proc.stdout as ReadableStream<Uint8Array> | null),
|
||||
readPipe(proc.stderr as ReadableStream<Uint8Array> | null),
|
||||
proc.exited,
|
||||
]);
|
||||
if (exitCode === 0) return;
|
||||
const output = `${stdout}\n${stderr}`.trim();
|
||||
throw new Error(
|
||||
`Failed to install runtime at ${runtimeDir} with ${process.execPath} install (exit ${exitCode}): ${output}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize a pinned dependency set into `runtimeDir` (idempotent,
|
||||
* cross-process safe via a lock directory). Returns `runtimeDir`.
|
||||
*/
|
||||
export async function ensureRuntimeInstalled(options: EnsureRuntimeInstalledOptions): Promise<string> {
|
||||
const { runtimeDir, install, onPhase, lockAttempts = 240, lockSleepMs = 250 } = options;
|
||||
let probePackage = options.probePackage;
|
||||
if (!probePackage) {
|
||||
for (const name in install.dependencies) {
|
||||
probePackage = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!probePackage) throw new Error(`Runtime install at ${runtimeDir} declares no dependencies`);
|
||||
const probeManifest = Bun.file(path.join(runtimeDir, "node_modules", ...probePackage.split("/"), "package.json"));
|
||||
if (await probeManifest.exists()) return runtimeDir;
|
||||
|
||||
onPhase?.("initiate");
|
||||
const releaseLock = await acquireInstallLock(runtimeDir, lockAttempts, lockSleepMs);
|
||||
try {
|
||||
if (await probeManifest.exists()) return runtimeDir;
|
||||
await writeRuntimeManifest(runtimeDir, install);
|
||||
onPhase?.("download");
|
||||
await runRuntimeInstall(runtimeDir);
|
||||
onPhase?.("done");
|
||||
return runtimeDir;
|
||||
} finally {
|
||||
await releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { resolveRuntimeModule, splitBareSpecifier } from "../src/runtime-install";
|
||||
|
||||
// Contract under test: runtime-installed packages (fastembed, Transformers.js
|
||||
// graphs) load inside compiled binaries through resolveRuntimeModule, which
|
||||
// must honor `exports` (CommonJS conditions), then `main` (including `.node`
|
||||
// targets without an extension probe match), then `index.js` — the shapes the
|
||||
// stock compiled-binary resolver gets wrong (Bun #1763).
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function makeNodeModules(packages: Record<string, { manifest: Record<string, unknown>; files: string[] }>) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-runtime-install-"));
|
||||
tempDirs.push(root);
|
||||
const nodeModules = path.join(root, "node_modules");
|
||||
for (const name in packages) {
|
||||
const pkg = packages[name];
|
||||
const pkgDir = path.join(nodeModules, ...name.split("/"));
|
||||
await Bun.write(path.join(pkgDir, "package.json"), JSON.stringify({ name, ...pkg.manifest }));
|
||||
for (const file of pkg.files) {
|
||||
await Bun.write(path.join(pkgDir, file), "");
|
||||
}
|
||||
}
|
||||
return nodeModules;
|
||||
}
|
||||
|
||||
describe("splitBareSpecifier", () => {
|
||||
test("splits scoped and unscoped specifiers with subpaths", () => {
|
||||
expect(splitBareSpecifier("fastembed")).toEqual({ packageName: "fastembed", subpath: undefined });
|
||||
expect(splitBareSpecifier("tar/lib/extract")).toEqual({ packageName: "tar", subpath: "lib/extract" });
|
||||
expect(splitBareSpecifier("@anush008/tokenizers")).toEqual({
|
||||
packageName: "@anush008/tokenizers",
|
||||
subpath: undefined,
|
||||
});
|
||||
expect(splitBareSpecifier("@huggingface/transformers/types")).toEqual({
|
||||
packageName: "@huggingface/transformers",
|
||||
subpath: "types",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRuntimeModule", () => {
|
||||
test("resolves conditional exports preferring require over import", async () => {
|
||||
const nodeModules = await makeNodeModules({
|
||||
fastembed: {
|
||||
manifest: {
|
||||
exports: {
|
||||
".": {
|
||||
import: { default: "./lib/esm/index.js" },
|
||||
require: { default: "./lib/cjs/index.js" },
|
||||
},
|
||||
},
|
||||
main: "./lib/cjs/index.js",
|
||||
},
|
||||
files: ["lib/esm/index.js", "lib/cjs/index.js"],
|
||||
},
|
||||
});
|
||||
expect(resolveRuntimeModule(nodeModules, "fastembed")).toBe(
|
||||
path.join(nodeModules, "fastembed", "lib", "cjs", "index.js"),
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to main pointing at a .node binding (napi-rs platform package)", async () => {
|
||||
const nodeModules = await makeNodeModules({
|
||||
"@anush008/tokenizers-darwin-arm64": {
|
||||
manifest: { main: "tokenizers.darwin-arm64.node" },
|
||||
files: ["tokenizers.darwin-arm64.node"],
|
||||
},
|
||||
});
|
||||
expect(resolveRuntimeModule(nodeModules, "@anush008/tokenizers-darwin-arm64")).toBe(
|
||||
path.join(nodeModules, "@anush008", "tokenizers-darwin-arm64", "tokenizers.darwin-arm64.node"),
|
||||
);
|
||||
});
|
||||
|
||||
test("probes extensions and directory index for extensionless main", async () => {
|
||||
const nodeModules = await makeNodeModules({
|
||||
"onnxruntime-node": {
|
||||
manifest: { main: "dist/index" },
|
||||
files: ["dist/index.js"],
|
||||
},
|
||||
"onnxruntime-common": {
|
||||
manifest: { main: "dist" },
|
||||
files: ["dist/index.js"],
|
||||
},
|
||||
});
|
||||
expect(resolveRuntimeModule(nodeModules, "onnxruntime-node")).toBe(
|
||||
path.join(nodeModules, "onnxruntime-node", "dist", "index.js"),
|
||||
);
|
||||
expect(resolveRuntimeModule(nodeModules, "onnxruntime-common")).toBe(
|
||||
path.join(nodeModules, "onnxruntime-common", "dist", "index.js"),
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves subpath requests through the exports map and via plain joining", async () => {
|
||||
const nodeModules = await makeNodeModules({
|
||||
mapped: {
|
||||
manifest: { exports: { ".": "./index.js", "./util": { require: "./lib/util.cjs" } } },
|
||||
files: ["index.js", "lib/util.cjs"],
|
||||
},
|
||||
plain: {
|
||||
manifest: { main: "index.js" },
|
||||
files: ["index.js", "lib/helper.js"],
|
||||
},
|
||||
});
|
||||
expect(resolveRuntimeModule(nodeModules, "mapped/util")).toBe(
|
||||
path.join(nodeModules, "mapped", "lib", "util.cjs"),
|
||||
);
|
||||
expect(resolveRuntimeModule(nodeModules, "plain/lib/helper")).toBe(
|
||||
path.join(nodeModules, "plain", "lib", "helper.js"),
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null for absent packages and import-only exports", async () => {
|
||||
const nodeModules = await makeNodeModules({
|
||||
"esm-only": {
|
||||
manifest: { exports: { ".": { import: "./index.mjs" } } },
|
||||
files: ["index.mjs"],
|
||||
},
|
||||
});
|
||||
expect(resolveRuntimeModule(nodeModules, "missing-package")).toBeNull();
|
||||
expect(resolveRuntimeModule(nodeModules, "esm-only")).toBeNull();
|
||||
});
|
||||
|
||||
test("falls back to index.js when manifest has no usable entry", async () => {
|
||||
const nodeModules = await makeNodeModules({
|
||||
bare: { manifest: {}, files: ["index.js"] },
|
||||
});
|
||||
expect(resolveRuntimeModule(nodeModules, "bare")).toBe(path.join(nodeModules, "bare", "index.js"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user