mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
fix(build): scope standalone type:module to worker dirs so server.js stays CJS (#10936)
Validado no worktree combinado: typecheck:core, changelog-integrity, complexity, cognitive-complexity, file-size, lint e teste focado (colocate-standalone-esm-scope) todos verdes. Fix real, correção de regressão introduzida por #10836 (server.js CommonJS quebrando com type:module reintroduzido). CI vermelho é o base-red já rastreado em #9985. Obrigado!
This commit is contained in:
@@ -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
|
||||
@@ -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: <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)");
|
||||
}
|
||||
|
||||
// 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: <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
tests/unit/build/colocate-standalone-esm-scope.test.ts
Normal file
129
tests/unit/build/colocate-standalone-esm-scope.test.ts
Normal file
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user