Files
OmniRoute/scripts/build/colocateOptionals.mjs
Diego Rodrigues de Sa e Souza bc38965088 maint: follow-up cherry-pick fix-in-place #9712 (conflict-resolved fallback) (#9892)
* fix(build): colocateLlmlinguaOptionals skip-check treated a Next-traced stub as fully copied

Debugging the omniroute-beta Docker rebuild: `npm run build` (and the
Dockerfile's own post-build verification) failed with
`Cannot find module '.../node_modules/@atjsh/llmlingua-2/dist/index.js'`.

Root cause, reproduced directly (both against a live Docker builder image
and in a unit test): Next.js's own standalone trace creates a stub
directory for `@atjsh/llmlingua-2` containing only `package.json` — it
references the package (a dynamically-imported optional dependency) but
can't fully bundle it. colocateLlmlinguaOptionals's skip checks (both the
closure-level early return and the per-package loop) only tested
`existsSync(dest)`, so that stub was indistinguishable from "already fully
co-located" — the function skipped copying the real `dist/` output
entirely, silently shipping a package with a manifest but no code.

Fix: check for the package's declared `main` entry file when it has one
(the real-world case for every actual SLM optional). Packages with no
`main` field fall back to comparing the destination's top-level entries
against the source's — correct both for genuinely multi-file packages and
for a metadata-only source (package.json is then its complete, faithfully-
copied contents), which the existing idempotency test exercises.

Covered by tests/unit/colocate-optionals.test.ts's new stub-reproduction
case (fails against the pre-fix code, passes after — confirmed directly)
plus the 6 pre-existing cases, all still green.

(cherry picked from commit 359aba59c7)

* fix(build): register onnxruntime-node's native bin/ as a standalone asset (#9687)

Docker/standalone builds of the LLMLingua SLM compression tier failed at
runtime with "Error: libonnxruntime.so.1: cannot open shared object file:
No such file or directory" (open-sse/services/compression/engines/llmlingua's
worker, via @huggingface/transformers -> onnxruntime-node).

onnxruntime-node's dist/binding.js is a normal JS file Next.js's standalone
trace bundles correctly, but binding.js dlopen()s a platform-specific native
library shipped under bin/napi-v3/<platform>/<arch>/libonnxruntime.so.1 — a
dynamic native load static file tracing can't see (same blind-spot class as
the separate colocateLlmlinguaOptionals stub bug, just for a .so instead of
a JS import, via NATIVE_ASSET_ENTRIES instead). That directory was simply
never registered, unlike better-sqlite3's native binary, which already goes
through the exact same mechanism correctly.

Fix: add an entry for onnxruntime-node/bin, mirroring the existing
better-sqlite3 entry. Confirmed against a real Docker build of the
Dockerfile's own post-build verification step: this was the very next
failure once the separate llmlingua-2 stub bug was fixed and the build
progressed far enough to reach it.

Covered by tests/unit/assemble-standalone-onnxruntime-native-asset.test.ts
(fails against the pre-fix code on both assertions, passes after).

(cherry picked from commit 8c98a59f26)

---------

Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-09 09:54:24 -03:00

211 lines
8.3 KiB
JavaScript

#!/usr/bin/env node
/**
* OmniRoute — Co-locate the LLMLingua-2 optional dependency closure into the standalone bundle.
*
* The compression "ultra" SLM tier (PR #4257) runs `@atjsh/llmlingua-2` +
* `@huggingface/transformers` + `@tensorflow/tfjs` + `js-tiktoken` inside a worker thread
* (`open-sse/services/compression/engines/llmlingua/onnxWorker.js`, shipped under `dist/`). These
* are `optionalDependencies`: npm installs them into the ROOT `node_modules` on
* `--include=optional`, but the Next.js standalone trace bundles ONLY `@huggingface/transformers`
* (3.5.2, pinned) into `dist/node_modules` — it does NOT trace the optional, dynamically-imported
* SLM packages.
*
* ## Why this matters (the instance-split bug)
*
* The worker lives under `dist/`, so its `import("@huggingface/transformers")` resolves
* `dist/node_modules/@huggingface/transformers` (3.5.2) and the worker sets the model `cacheDir`
* on THAT instance's `env`. But its `import("@atjsh/llmlingua-2")` walks past `dist/node_modules`
* (no `@atjsh` there) up to the ROOT `node_modules`, and llmlingua-2's own
* `import("@huggingface/transformers")` then resolves the ROOT transformers — a DIFFERENT instance.
* The `cacheDir`/`localModelPath` config the worker set never reaches the instance llmlingua-2
* actually uses, so the local model under `DATA_DIR/models/llmlingua` is never found and the SLM
* tier silently fails-open (no compression). Worse, if the root transformers is a 4.x line,
* llmlingua-2 throws on a tokenizer-API change (`decoder.decode` is undefined).
*
* ## The fix
*
* Co-locate the SLM optional dependency CLOSURE from the root `node_modules` into
* `dist/node_modules` (NO-CLOBBER, so the pinned `dist` transformers 3.5.2 / onnxruntime / sharp
* stay). Then the worker resolves `@atjsh/llmlingua-2` AND `@huggingface/transformers` from the
* SAME `dist/node_modules` — a single 3.5.2 instance — so the env config applies and the local
* model loads.
*
* `@huggingface/transformers` is intentionally NOT a closure seed: it is a PEER of
* `@atjsh/llmlingua-2` (not a regular dependency) and is already bundled in `dist/node_modules`,
* so the closure walk never reaches it and the no-clobber guard would skip it anyway.
*
* ## Validation (Hard Rule #18)
*
* Manual co-location of this exact closure on the production VPS produced real 54.8% compression
* (11520 → 5203 chars) via real ONNX inference — both the default and the `modelPath` (PR #4257)
* code paths. See the unit test for the closure-walk + no-clobber contract.
*
* Idempotent + fail-soft: skips when the optionals are absent (the common case — they are OPTIONAL)
* or already co-located; a per-package copy failure only disables the SLM tier, which is itself
* fail-open, so this never throws into the install.
*/
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, sep } from "node:path";
/**
* Entry packages of the SLM optional stack (the closure roots). `@huggingface/transformers` is
* deliberately absent — it is the pinned instance already present in `dist/node_modules`.
*/
export const SEED_PACKAGES = ["@atjsh/llmlingua-2", "@tensorflow/tfjs", "js-tiktoken"];
/**
* Compute the transitive dependency closure of `seeds` by walking each package's `dependencies` +
* `optionalDependencies` from a `node_modules` directory. Packages that are not present in that
* tree (e.g. peers provided elsewhere, like `@huggingface/transformers` in `dist`) are skipped —
* the closure only contains packages that actually exist in `nodeModulesDir`.
*
* @param {string} nodeModulesDir absolute path to the source `node_modules`
* @param {string[]} [seeds] closure roots (defaults to {@link SEED_PACKAGES})
* @returns {string[]} package names in discovery order, seeds first
*/
export function computeDependencyClosure(nodeModulesDir, seeds = SEED_PACKAGES) {
const closure = [];
const seen = new Set();
const stack = [...seeds];
while (stack.length) {
const name = stack.shift();
if (seen.has(name)) continue;
seen.add(name);
const pkgDir = join(nodeModulesDir, name);
if (!existsSync(pkgDir)) continue; // absent in this tree (peer provided elsewhere) — skip
closure.push(name);
let manifest;
try {
manifest = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
} catch {
continue; // unreadable/absent manifest — copy the dir but do not recurse
}
const deps = { ...manifest.dependencies, ...manifest.optionalDependencies };
for (const dep of Object.keys(deps)) {
if (!seen.has(dep)) stack.push(dep);
}
}
return closure;
}
/**
* A package in the target tree counts as PRESENT only when its entrypoint
* resolves from inside that tree — the same contract the Dockerfile's
* post-build guard enforces. Next's file tracing can materialize a package
* PARTIALLY (the package.json lands, the files its `main` points at do not),
* and a directory-level `existsSync` check then skips the package forever
* while the runtime dies with "Cannot find module <pkg>/dist/index.js".
*
* @param {string} targetNodeModulesDir
* @param {string} name
* @returns {boolean}
*/
function isPackageIntact(targetNodeModulesDir, name) {
if (!existsSync(join(targetNodeModulesDir, name))) return false;
try {
const probe = createRequire(
join(targetNodeModulesDir, "__colocate_probe__.js")
);
const resolved = probe.resolve(name);
// A resolution that walked past the target into an ancestor tree does not
// prove the target copy is usable.
return resolved.startsWith(targetNodeModulesDir + sep);
} catch {
return false;
}
}
/**
* Co-locate the SLM optional dependency closure from `<rootDir>/node_modules`
* into a standalone bundle's `node_modules`.
*
* The default destination remains `<rootDir>/dist/node_modules` for the npm
* postinstall path. Standalone builders, including Docker, may provide
* `targetNodeModulesDir`.
*
* Packages already present in the destination are never overwritten. This
* preserves the standalone bundle's pinned dependency instances while filling
* dynamically imported packages that Next.js did not trace.
*
* @param {{
* rootDir: string,
* targetNodeModulesDir?: string,
* seeds?: string[],
* log?: (message: string) => void
* }} opts
* @returns {{ skipped: true, reason: string }
* | { skipped: false, copied: number, closure: number }}
*/
export function colocateLlmlinguaOptionals({
rootDir,
targetNodeModulesDir,
seeds = SEED_PACKAGES,
log = () => {},
}) {
const rootNm = join(rootDir, "node_modules");
const targetNm = targetNodeModulesDir ?? join(rootDir, "dist", "node_modules");
if (!existsSync(targetNm)) {
return {
skipped: true,
reason: targetNodeModulesDir ? "no target node_modules" : "no standalone dist/node_modules",
};
}
// Only run when every requested closure root was installed.
if (!seeds.every((seed) => existsSync(join(rootNm, seed)))) {
return { skipped: true, reason: "SLM optionals not installed at root" };
}
const closure = computeDependencyClosure(rootNm, seeds);
// Check the complete closure rather than only the entry package, and judge
// presence by entrypoint integrity — a partially traced directory (see
// isPackageIntact) must still receive its missing files.
if (
closure.length > 0 &&
closure.every((name) => isPackageIntact(targetNm, name))
) {
return { skipped: true, reason: "already co-located" };
}
let copied = 0;
for (const name of closure) {
const dest = join(targetNm, name);
if (isPackageIntact(targetNm, name)) continue;
try {
mkdirSync(dirname(dest), { recursive: true });
// force:false merges into a partially traced directory: files the trace
// already materialized are kept, missing ones (the package payload) are
// filled in from the root tree.
cpSync(join(rootNm, name), dest, {
recursive: true,
force: false,
errorOnExist: false,
});
copied++;
} catch (err) {
log(` ⚠️ LLMLingua optional co-location failed for ${name}: ${err.message}`);
}
}
if (copied > 0) {
log(
` ✅ Co-located ${copied} LLMLingua SLM optional package(s) into standalone node_modules.\n`
);
}
return { skipped: false, copied, closure: closure.length };
}