diff --git a/Dockerfile b/Dockerfile index 47780263c6..905fb294e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index 7bda5fa527..3b9842e45a 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -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 diff --git a/scripts/build/colocateOptionals.mjs b/scripts/build/colocateOptionals.mjs index 5317d2fe8a..91548954b0 100644 --- a/scripts/build/colocateOptionals.mjs +++ b/scripts/build/colocateOptionals.mjs @@ -97,47 +97,81 @@ export function computeDependencyClosure(nodeModulesDir, seeds = SEED_PACKAGES) } /** - * Co-locate the SLM optional closure from `/node_modules` into - * `/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 `/node_modules` + * into a standalone bundle's `node_modules`. * - * @param {{ rootDir: string, log?: (message: string) => void }} opts + * The default destination remains `/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 }; diff --git a/tests/unit/docker-llmlingua-optionals-9166.test.ts b/tests/unit/docker-llmlingua-optionals-9166.test.ts new file mode 100644 index 0000000000..ba1f245743 --- /dev/null +++ b/tests/unit/docker-llmlingua-optionals-9166.test.ts @@ -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 = {}, + files: Record = {} +): 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}` + ); + } +});