fix(docker): bundle LLMLingua optional dependencies (#9185)

Validated in local merge-train T4 (HouMinXi+Zartharas+Andrian+artickc)
This commit is contained in:
Aman
2026-08-05 20:52:10 -06:00
committed by GitHub
parent def958b97a
commit d2f3c1abf5
4 changed files with 363 additions and 19 deletions

View File

@@ -77,7 +77,7 @@ RUN test -f package-lock.json \
# a broken/rate-limited fetch fails the BUILD loudly instead of shipping a
# broken image.
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
npm ci --include=optional --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
&& (cd node_modules/better-sqlite3 \
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \
&& node -e "require('better-sqlite3')(':memory:').close()" \
@@ -119,7 +119,9 @@ ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
COPY . ./
RUN --mount=type=cache,id=next-cache,target=/app/.build/next/cache \
mkdir -p /app/data && npm run build
mkdir -p /app/data \
&& npm run build \
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', '@tensorflow/tfjs', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
# ── Runner base ────────────────────────────────────────────────────────────
FROM base AS runner-base

View File

@@ -48,6 +48,10 @@
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import {
colocateLlmlinguaOptionals,
SEED_PACKAGES,
} from "./colocateOptionals.mjs";
/**
* Check whether a path exists (async).
@@ -736,6 +740,19 @@ export function assembleStandalone({
// 6. Optionally copy native assets + extra modules (synchronous)
if (copyNatives) {
copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir);
// #9166: dynamically imported LLMLingua packages are not reliably traced
// into the standalone bundle. Copy their complete dependency closure from
// the installed root tree without overwriting packages already traced by
// Next.js. Include transformers here so its ONNX runtime closure is also
// guaranteed in Docker/standalone builds.
colocateLlmlinguaOptionals({
rootDir: projectRoot,
targetNodeModulesDir: path.join(resolvedOutDir, "node_modules"),
seeds: [...SEED_PACKAGES, "@huggingface/transformers"],
log: (message) =>
console.log(`[assembleStandalone] ${message.trim()}`),
});
}
// 7. Optionally dereference Turbopack hashed-module symlinks so the bundle is

View File

@@ -97,47 +97,81 @@ export function computeDependencyClosure(nodeModulesDir, seeds = SEED_PACKAGES)
}
/**
* Co-locate the SLM optional closure from `<rootDir>/node_modules` into
* `<rootDir>/dist/node_modules`. No-op when the standalone `dist` bundle or the optional seeds are
* absent, and idempotent once co-located. Never throws.
* Co-locate the SLM optional dependency closure from `<rootDir>/node_modules`
* into a standalone bundle's `node_modules`.
*
* @param {{ rootDir: string, log?: (message: string) => void }} opts
* 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, log = () => {} }) {
export function colocateLlmlinguaOptionals({
rootDir,
targetNodeModulesDir,
seeds = SEED_PACKAGES,
log = () => {},
}) {
const rootNm = join(rootDir, "node_modules");
const distNm = join(rootDir, "dist", "node_modules");
const targetNm = targetNodeModulesDir ?? join(rootDir, "dist", "node_modules");
if (!existsSync(distNm)) {
return { skipped: true, reason: "no standalone dist/node_modules" };
if (!existsSync(targetNm)) {
return {
skipped: true,
reason: targetNodeModulesDir
? "no target node_modules"
: "no standalone dist/node_modules",
};
}
// Gate: only run when the optional stack was actually installed (`npm install --include=optional`).
if (!SEED_PACKAGES.every((seed) => existsSync(join(rootNm, seed)))) {
// 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" };
}
// Idempotent: the entry package is already co-located → nothing to do.
if (existsSync(join(distNm, "@atjsh", "llmlingua-2"))) {
const closure = computeDependencyClosure(rootNm, seeds);
// Check the complete closure rather than only the entry package. A partially
// populated bundle must still receive any missing transitive dependencies.
if (
closure.length > 0 &&
closure.every((name) => existsSync(join(targetNm, name)))
) {
return { skipped: true, reason: "already co-located" };
}
const closure = computeDependencyClosure(rootNm);
let copied = 0;
for (const name of closure) {
const dest = join(distNm, name);
if (existsSync(dest)) continue; // no-clobber: keep dist's pinned copy (transformers 3.5.2, …)
const dest = join(targetNm, name);
if (existsSync(dest)) continue;
try {
mkdirSync(dirname(dest), { recursive: true });
cpSync(join(rootNm, name), dest, { recursive: true });
copied++;
} catch (err) {
log(` ⚠️ LLMLingua optional co-location failed for ${name}: ${err.message}`);
log(
` ⚠️ LLMLingua optional co-location failed for ${name}: ${err.message}`
);
}
}
if (copied > 0) {
log(` ✅ Co-located ${copied} LLMLingua SLM optional package(s) into dist/node_modules.\n`);
log(
` ✅ Co-located ${copied} LLMLingua SLM optional package(s) into standalone node_modules.\n`
);
}
return { skipped: false, copied, closure: closure.length };

View File

@@ -0,0 +1,291 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { assembleStandalone } from "../../scripts/build/assembleStandalone.mjs";
const REQUIRED_RUNTIME_PACKAGES = [
"@atjsh/llmlingua-2",
"@huggingface/transformers",
"@tensorflow/tfjs",
"js-tiktoken",
];
function mkPkg(
nodeModulesDir: string,
name: string,
manifest: Record<string, unknown> = {},
files: Record<string, string> = {}
): void {
const packageDir = join(nodeModulesDir, name);
mkdirSync(packageDir, { recursive: true });
writeFileSync(
join(packageDir, "package.json"),
JSON.stringify({
name,
version: "1.0.0",
...manifest,
})
);
for (const [relativePath, content] of Object.entries(files)) {
const filePath = join(packageDir, relativePath);
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, content);
}
}
function buildLlmlinguaRoot(
rootDir: string,
transformersVersion = "3.5.2"
): void {
const rootNm = join(rootDir, "node_modules");
mkPkg(
rootNm,
"@atjsh/llmlingua-2",
{
dependencies: {
"es-toolkit": "^1.38.0",
},
peerDependencies: {
"@huggingface/transformers": "*",
"@tensorflow/tfjs": "*",
"js-tiktoken": "*",
},
},
{
"dist/index.js": "export const llmlingua = true;\n",
}
);
mkPkg(rootNm, "es-toolkit");
mkPkg(rootNm, "@tensorflow/tfjs", {
dependencies: {
"@tensorflow/tfjs-core": "4.22.0",
},
});
mkPkg(rootNm, "@tensorflow/tfjs-core", {
dependencies: {
long: "^5.0.0",
},
});
mkPkg(rootNm, "long");
mkPkg(rootNm, "js-tiktoken", {
dependencies: {
"base64-js": "^1.5.1",
},
});
mkPkg(rootNm, "base64-js");
mkPkg(rootNm, "@huggingface/transformers", {
version: transformersVersion,
dependencies: {
"onnxruntime-node": "1.21.0",
},
});
mkPkg(rootNm, "onnxruntime-node");
}
function createStandalone(rootDir: string): {
distDir: string;
standaloneDir: string;
} {
const distDir = join(rootDir, ".build", "next");
const standaloneDir = join(distDir, "standalone");
mkdirSync(join(standaloneDir, "node_modules"), {
recursive: true,
});
writeFileSync(
join(standaloneDir, "package.json"),
JSON.stringify({ name: "standalone-test" })
);
return { distDir, standaloneDir };
}
test("#9166 standalone assembly includes the complete LLMLingua runtime closure", () => {
const root = mkdtempSync(
join(tmpdir(), "omniroute-docker-llmlingua-9166-")
);
try {
buildLlmlinguaRoot(root);
const { distDir, standaloneDir } = createStandalone(root);
assembleStandalone({
distDir,
outDir: standaloneDir,
projectRoot: root,
copyNatives: true,
});
for (const packageName of [
...REQUIRED_RUNTIME_PACKAGES,
"es-toolkit",
"@tensorflow/tfjs-core",
"long",
"base64-js",
"onnxruntime-node",
]) {
assert.ok(
existsSync(
join(standaloneDir, "node_modules", packageName, "package.json")
),
`${packageName} must be present in the standalone runtime`
);
}
assert.ok(
existsSync(
join(
standaloneDir,
"node_modules",
"@atjsh",
"llmlingua-2",
"dist",
"index.js"
)
),
"the complete LLMLingua package payload must be copied"
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("#9166 standalone assembly never overwrites an already pinned transformers instance", () => {
const root = mkdtempSync(
join(tmpdir(), "omniroute-docker-llmlingua-pinned-9166-")
);
try {
buildLlmlinguaRoot(root, "4.2.0");
const { distDir, standaloneDir } = createStandalone(root);
mkPkg(
join(standaloneDir, "node_modules"),
"@huggingface/transformers",
{
version: "3.5.2",
}
);
assembleStandalone({
distDir,
outDir: standaloneDir,
projectRoot: root,
copyNatives: true,
});
const targetManifest = JSON.parse(
readFileSync(
join(
standaloneDir,
"node_modules",
"@huggingface",
"transformers",
"package.json"
),
"utf8"
)
);
assert.equal(
targetManifest.version,
"3.5.2",
"standalone's pinned transformers version must not be overwritten"
);
assert.ok(
existsSync(
join(
standaloneDir,
"node_modules",
"onnxruntime-node",
"package.json"
)
),
"missing dependencies from the transformers closure must still be copied"
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("#9166 Docker explicitly installs and validates LLMLingua optionals", () => {
const dockerfile = readFileSync(
new URL("../../Dockerfile", import.meta.url),
"utf8"
);
const builderStart = dockerfile.indexOf("FROM base AS builder");
const runnerStart = dockerfile.indexOf("FROM base AS runner-base");
assert.ok(builderStart >= 0, "Docker builder stage must exist");
assert.ok(runnerStart > builderStart, "Docker runner stage must follow builder");
const builder = dockerfile.slice(builderStart, runnerStart);
assert.match(
builder,
/npm ci\b[^\n]*--include=optional/,
"Docker dependency installation must explicitly include optional dependencies"
);
assert.doesNotMatch(
builder,
/npm ci\b[^\n]*--omit=optional/,
"Docker must not omit optional dependencies"
);
assert.match(
builder,
/createRequire\(['"]\/app\/\.build\/next\/standalone\/package\.json['"]\)/,
"Docker validation must resolve packages from the standalone package context"
);
assert.match(
builder,
/resolved\.startsWith\(standaloneRoot\)/,
"Docker validation must reject packages resolved from the builder root"
);
assert.match(
builder,
/await import\(pathToFileURL\(resolved\)\.href\)/,
"Docker validation must import each packaged LLMLingua dependency"
);
assert.ok(
builder.includes("require.resolve('onnxruntime-node')"),
"Docker validation must resolve the native ONNX runtime"
);
assert.match(
builder,
/await import\(pathToFileURL\(onnxRuntime\)\.href\)/,
"Docker validation must load the ONNX runtime and its native binding"
);
for (const packageName of REQUIRED_RUNTIME_PACKAGES) {
assert.ok(
builder.includes(packageName),
`Docker standalone validation must include ${packageName}`
);
}
});