diff --git a/changelog.d/fixes/10936-standalone-server-cjs-esm-scope.md b/changelog.d/fixes/10936-standalone-server-cjs-esm-scope.md new file mode 100644 index 0000000000..824f7df647 --- /dev/null +++ b/changelog.d/fixes/10936-standalone-server-cjs-esm-scope.md @@ -0,0 +1 @@ +- **fix(build):** the `next` Docker image no longer crashes on boot with `ReferenceError: require is not defined in ES module scope`. The standalone `server.js` is CommonJS, but the `postbuild` colocate step was re-adding `"type":"module"` to the standalone root `package.json` (undoing `assembleStandalone`'s strip) to make its ESM worker bundles load. The `type:module` scope is now written per-worker-directory instead of on the root, so `server.js` stays CommonJS while the workers stay ESM ([#10936](https://github.com/diegosouzapw/OmniRoute/pull/10936), fixes [#10933](https://github.com/diegosouzapw/OmniRoute/issues/10933)) — thanks @arminanton diff --git a/scripts/build/colocate-standalone.mjs b/scripts/build/colocate-standalone.mjs index 0748cf6db7..bb108da47f 100644 --- a/scripts/build/colocate-standalone.mjs +++ b/scripts/build/colocate-standalone.mjs @@ -16,14 +16,20 @@ * * Run manually after a build, or automatically via the `postbuild` npm hook. */ -import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { execFileSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { computeDependencyClosure } from "./colocateOptionals.mjs"; const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); -const STANDALONE = join(ROOT, ".build", "next", "standalone"); +// STANDALONE defaults to the real build output; OMNIROUTE_STANDALONE_DIR overrides +// it so tests can drive the co-location logic against a synthetic tree without a +// full `next build`. Mirrors the OMNIROUTE_* override seams in the sibling build +// scripts (write-build-sha.mjs, write-build-base-path.mjs, optionalPackStaging.mjs). +const STANDALONE = process.env.OMNIROUTE_STANDALONE_DIR + ? process.env.OMNIROUTE_STANDALONE_DIR + : join(ROOT, ".build", "next", "standalone"); const CALL_LOG_WORKER_REL = join("src", "lib", "usage", "callLogArtifactWorker.js"); const CALL_LOG_WORKER_SRC = join(ROOT, "src", "lib", "usage", "callLogArtifactWorker.ts"); @@ -35,97 +41,138 @@ const WORKER_REL = join( "llmlingua", "onnxWorker.js" ); -const GATE_PKG = join("node_modules", "@atjsh", "llmlingua-2", "package.json"); -const hasOptionals = existsSync( - join(ROOT, "node_modules", "@atjsh", "llmlingua-2", "package.json") -); - -if (!existsSync(STANDALONE)) { - console.log("[colocate-standalone] .build/next/standalone not found — nothing to do."); - process.exit(0); -} -const callLogWorkerDest = join(STANDALONE, CALL_LOG_WORKER_REL); -mkdirSync(dirname(callLogWorkerDest), { recursive: true }); -execFileSync( - join(ROOT, "node_modules", ".bin", "esbuild"), - [ - CALL_LOG_WORKER_SRC, - "--bundle", - "--platform=node", - "--packages=external", - "--format=esm", - `--outfile=${callLogWorkerDest}`, - ], - { stdio: "inherit" } -); -console.log("[colocate-standalone] ✅ call-log artifact worker bundled"); - -if (!hasOptionals) { - console.log( - "[colocate-standalone] optional SLM deps absent at root node_modules — LLMLingua stays fail-open (slim install)." - ); - process.exit(0); -} - -// 1) Bundle the worker the resolver expects: /open-sse/.../onnxWorker.js -const workerDest = join(STANDALONE, WORKER_REL); -if (!existsSync(workerDest)) { - mkdirSync(dirname(workerDest), { recursive: true }); - try { - execFileSync( - join(ROOT, "node_modules", ".bin", "esbuild"), - [ - join(ROOT, "open-sse", "services", "compression", "engines", "llmlingua", "onnxWorker.ts"), - "--bundle", - "--platform=node", - "--packages=external", - "--format=esm", - `--outfile=${workerDest}`, - ], - { stdio: "inherit" } - ); - console.log("[colocate-standalone] ✅ LLMLingua worker bundled into standalone tree"); - } catch (err) { - console.warn("[colocate-standalone] ⚠️ worker bundle error:", err.message); - } -} else { - console.log("[colocate-standalone] worker already present (skipping bundle)"); -} - -// 2) Co-locate the optional-dep closure (NO-CLOBBER, same semantics as colocateOptionals.mjs) -const srcNm = join(ROOT, "node_modules"); -const dstNm = join(STANDALONE, "node_modules"); -const closure = computeDependencyClosure(srcNm); -let copied = 0; -for (const pkg of closure) { - const src = join(srcNm, pkg); - const dst = join(dstNm, pkg); - if (!existsSync(src)) continue; - if (existsSync(dst)) continue; // no-clobber: keep traced instances (e.g. pinned @huggingface/transformers) - mkdirSync(dirname(dst), { recursive: true }); - cpSync(src, dst, { recursive: true }); - copied++; -} -console.log( - `[colocate-standalone] ✅ optional-dep closure: ${closure.length} packages (copied ${copied})` -); - -// 3) Ensure standalone package.json declares "type": "module" so Node 24 runs ESM worker bundles without warning -const standalonePkgPath = join(STANDALONE, "package.json"); -if (existsSync(standalonePkgPath)) { - try { - const rawPkg = readFileSync(standalonePkgPath, "utf8"); - const pkgJson = JSON.parse(rawPkg); - if (!pkgJson.type) { - pkgJson.type = "module"; - writeFileSync(standalonePkgPath, JSON.stringify(pkgJson, null, 2) + "\n", "utf8"); - console.log("[colocate-standalone] ✅ standalone package.json configured with type: module"); +/** + * Give each esbuild'd ESM worker its OWN `"type":"module"` scope. + * + * The worker bundles are emitted with `--format=esm` under `.js` names, so Node + * needs a nearest-ancestor package.json declaring `"type":"module"` to load them + * as ESM. It is tempting to set that on the standalone ROOT package.json, but the + * standalone entrypoint `server.js` is CommonJS (`require()`, `__dirname`); a root + * `"type":"module"` makes Node parse server.js as ESM and it crashes at startup + * with `ReferenceError: require is not defined in ES module scope`. + * assembleStandalone.mjs::patchStandalonePackageJson strips `type` for exactly + * this reason — re-adding it on the root here reintroduced that crash. + * + * Node resolves module type from the NEAREST package.json, so a scoped + * `{"type":"module"}` beside each worker makes the worker ESM while the root stays + * CommonJS for server.js. Both coexist with no format change and no root edit. + * + * @param {string[]} workerDirs Absolute directories that hold an ESM worker bundle. + * @returns {string[]} The package.json paths that were written (existing ones are left intact). + */ +export function writeEsmWorkerScopes(workerDirs) { + const written = []; + for (const dir of workerDirs) { + const scopedPkgPath = join(dir, "package.json"); + if (existsSync(scopedPkgPath)) continue; // never clobber a traced package.json + try { + writeFileSync(scopedPkgPath, JSON.stringify({ type: "module" }, null, 2) + "\n", "utf8"); + written.push(scopedPkgPath); + console.log(`[colocate-standalone] ✅ ESM scope written: ${scopedPkgPath}`); + } catch (err) { + console.warn(`[colocate-standalone] ⚠️ could not write ESM scope for ${dir}:`, err.message); } - } catch (err) { - console.warn( - "[colocate-standalone] ⚠️ could not update standalone package.json:", - err.message - ); } + return written; +} + +function main() { + const hasOptionals = existsSync( + join(ROOT, "node_modules", "@atjsh", "llmlingua-2", "package.json") + ); + + if (!existsSync(STANDALONE)) { + console.log("[colocate-standalone] .build/next/standalone not found — nothing to do."); + return; + } + + const callLogWorkerDest = join(STANDALONE, CALL_LOG_WORKER_REL); + mkdirSync(dirname(callLogWorkerDest), { recursive: true }); + execFileSync( + join(ROOT, "node_modules", ".bin", "esbuild"), + [ + CALL_LOG_WORKER_SRC, + "--bundle", + "--platform=node", + "--packages=external", + "--format=esm", + `--outfile=${callLogWorkerDest}`, + ], + { stdio: "inherit" } + ); + console.log("[colocate-standalone] ✅ call-log artifact worker bundled"); + + // The call-log worker is always present; scope it to ESM immediately. The + // optional LLMLingua worker dir is added below only when its deps are installed. + const workerDirs = [dirname(callLogWorkerDest)]; + + if (!hasOptionals) { + console.log( + "[colocate-standalone] optional SLM deps absent at root node_modules — LLMLingua stays fail-open (slim install)." + ); + writeEsmWorkerScopes(workerDirs); + return; + } + + // 1) Bundle the worker the resolver expects: /open-sse/.../onnxWorker.js + const workerDest = join(STANDALONE, WORKER_REL); + if (!existsSync(workerDest)) { + mkdirSync(dirname(workerDest), { recursive: true }); + try { + execFileSync( + join(ROOT, "node_modules", ".bin", "esbuild"), + [ + join( + ROOT, + "open-sse", + "services", + "compression", + "engines", + "llmlingua", + "onnxWorker.ts" + ), + "--bundle", + "--platform=node", + "--packages=external", + "--format=esm", + `--outfile=${workerDest}`, + ], + { stdio: "inherit" } + ); + console.log("[colocate-standalone] ✅ LLMLingua worker bundled into standalone tree"); + } catch (err) { + console.warn("[colocate-standalone] ⚠️ worker bundle error:", err.message); + } + } else { + console.log("[colocate-standalone] worker already present (skipping bundle)"); + } + workerDirs.push(dirname(workerDest)); + + // 2) Co-locate the optional-dep closure (NO-CLOBBER, same semantics as colocateOptionals.mjs) + const srcNm = join(ROOT, "node_modules"); + const dstNm = join(STANDALONE, "node_modules"); + const closure = computeDependencyClosure(srcNm); + let copied = 0; + for (const pkg of closure) { + const src = join(srcNm, pkg); + const dst = join(dstNm, pkg); + if (!existsSync(src)) continue; + if (existsSync(dst)) continue; // no-clobber: keep traced instances (e.g. pinned @huggingface/transformers) + mkdirSync(dirname(dst), { recursive: true }); + cpSync(src, dst, { recursive: true }); + copied++; + } + console.log( + `[colocate-standalone] ✅ optional-dep closure: ${closure.length} packages (copied ${copied})` + ); + + // 3) Give each esbuild'd ESM worker its own "type":"module" scope (see helper doc). + writeEsmWorkerScopes(workerDirs); +} + +// Run as a script (npm `postbuild` hook), but stay importable for unit tests. +const entryScript = process.argv[1] ? pathToFileURL(process.argv[1]).href : null; +if (entryScript === import.meta.url) { + main(); } diff --git a/tests/unit/build/colocate-standalone-esm-scope.test.ts b/tests/unit/build/colocate-standalone-esm-scope.test.ts new file mode 100644 index 0000000000..548d72f6f1 --- /dev/null +++ b/tests/unit/build/colocate-standalone-esm-scope.test.ts @@ -0,0 +1,129 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execFileSync } from "node:child_process"; + +import { writeEsmWorkerScopes } from "../../../scripts/build/colocate-standalone.mjs"; + +/** + * Regression coverage for the standalone CJS/ESM `package.json` conflict. + * + * The Next.js standalone entrypoint `server.js` is CommonJS, so the standalone + * root `package.json` must NOT carry `"type":"module"` (assembleStandalone strips + * it). The colocated worker bundles are ESM `.js` files that DO need a + * `"type":"module"` scope. Setting it on the root satisfied the workers but broke + * server.js with `ReferenceError: require is not defined in ES module scope`. + * + * The fix scopes `"type":"module"` to each worker directory instead of the root. + */ + +test("writeEsmWorkerScopes writes a scoped type:module beside each worker", () => { + const root = mkdtempSync(join(tmpdir(), "colocate-scope-")); + try { + const workerA = join(root, "src", "lib", "usage"); + const workerB = join(root, "open-sse", "services", "compression", "engines", "llmlingua"); + mkdirSync(workerA, { recursive: true }); + mkdirSync(workerB, { recursive: true }); + + const written = writeEsmWorkerScopes([workerA, workerB]); + assert.equal(written.length, 2, "both worker dirs get a package.json"); + + for (const dir of [workerA, workerB]) { + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); + assert.equal(pkg.type, "module", `${dir} declares type:module`); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("writeEsmWorkerScopes never touches the standalone root package.json", () => { + const root = mkdtempSync(join(tmpdir(), "colocate-root-")); + try { + // Standalone root as assembleStandalone leaves it: CommonJS, no `type`. + const rootPkgPath = join(root, "package.json"); + writeFileSync(rootPkgPath, JSON.stringify({ name: "omniroute-standalone" }, null, 2) + "\n"); + + const workerDir = join(root, "src", "lib", "usage"); + mkdirSync(workerDir, { recursive: true }); + writeEsmWorkerScopes([workerDir]); + + const rootPkg = JSON.parse(readFileSync(rootPkgPath, "utf8")); + assert.equal( + rootPkg.type, + undefined, + "root package.json stays type-less so server.js is parsed as CommonJS" + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("writeEsmWorkerScopes is no-clobber: it leaves an existing package.json intact", () => { + const root = mkdtempSync(join(tmpdir(), "colocate-noclobber-")); + try { + const workerDir = join(root, "src", "lib", "usage"); + mkdirSync(workerDir, { recursive: true }); + const traced = { name: "already-traced", type: "module", version: "9.9.9" }; + writeFileSync(join(workerDir, "package.json"), JSON.stringify(traced, null, 2) + "\n"); + + const written = writeEsmWorkerScopes([workerDir]); + assert.equal(written.length, 0, "existing package.json is not rewritten"); + + const pkg = JSON.parse(readFileSync(join(workerDir, "package.json"), "utf8")); + assert.equal(pkg.version, "9.9.9", "the traced manifest is preserved verbatim"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +// End-to-end proof that the scoping actually lets a CommonJS server.js and an ESM +// worker.js coexist under Node's nearest-package.json module resolution. This is +// the behavior the bug broke: with type:module on the root, `node server.js` +// threw "require is not defined in ES module scope". +test("scoped layout runs a CJS server.js and an ESM worker.js side by side", () => { + const root = mkdtempSync(join(tmpdir(), "colocate-e2e-")); + try { + // Root: CommonJS entrypoint, no `type` (what assembleStandalone produces). + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "standalone" }) + "\n"); + writeFileSync( + join(root, "server.js"), + 'const path = require("path");\nprocess.stdout.write("CJS_SERVER_OK:" + path.basename(__filename));\n' + ); + + // Worker: ESM bundle under its own directory. + const workerDir = join(root, "src", "lib", "usage"); + mkdirSync(workerDir, { recursive: true }); + writeFileSync( + join(workerDir, "callLogArtifactWorker.js"), + 'import os from "node:os";\nprocess.stdout.write("ESM_WORKER_OK:" + typeof os.cpus);\n' + ); + + writeEsmWorkerScopes([workerDir]); + + const serverOut = execFileSync(process.execPath, [join(root, "server.js")], { + encoding: "utf8", + }); + assert.match( + serverOut, + /CJS_SERVER_OK:server\.js/, + "CommonJS server.js runs under the type-less root" + ); + + const workerOut = execFileSync( + process.execPath, + [join(workerDir, "callLogArtifactWorker.js")], + { encoding: "utf8" } + ); + assert.match( + workerOut, + /ESM_WORKER_OK:function/, + "ESM worker.js runs under its scoped package.json" + ); + assert.ok(existsSync(join(workerDir, "package.json")), "worker scope package.json exists"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});