Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
219 changes: 133 additions & 86 deletions scripts/build/colocate-standalone.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);
/**
* 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);
}
}
return written;
}
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)."
function main() {
const hasOptionals = existsSync(
join(ROOT, "node_modules", "@atjsh", "llmlingua-2", "package.json")
);
process.exit(0);
}

// 1) Bundle the worker the resolver expects: <standalone>/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);
if (!existsSync(STANDALONE)) {
console.log("[colocate-standalone] .build/next/standalone not found — nothing to do.");
return;
}
} 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})`
);
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");

// 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");
}
} catch (err) {
console.warn(
"[colocate-standalone] ⚠️ could not update standalone package.json:",
err.message
// 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: <standalone>/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();
}
129 changes: 129 additions & 0 deletions tests/unit/build/colocate-standalone-esm-scope.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});