Skip to content
Draft
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
7 changes: 7 additions & 0 deletions src/build/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { virtual, virtualDeps } from "./plugins/virtual.ts";
import { sourcemapMinify } from "./plugins/sourcemap-min.ts";
import { raw } from "./plugins/raw.ts";
import { externals } from "./plugins/externals.ts";
import { cjsRequire } from "./plugins/cjs-require.ts";

export async function baseBuildPlugins(nitro: Nitro, base: BaseBuildConfig) {
const plugins: Plugin[] = [];
Expand Down Expand Up @@ -69,6 +70,12 @@ export async function baseBuildPlugins(nitro: Nitro, base: BaseBuildConfig) {
);
}

// Rewrite runtime `require()` of bundled deps to their bundled copy (#4171)
const cjsRewrite = nitro.options.experimental.cjsRequireRewrite;
if (cjsRewrite) {
plugins.push(cjsRequire(nitro, cjsRewrite === true ? "all" : "react"));
}

// Sourcemap minify
if (
nitro.options.sourcemap &&
Expand Down
195 changes: 195 additions & 0 deletions src/build/plugins/cjs-require.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { dirname, relative } from "pathe";
import type { Nitro } from "nitro/types";
import type { OutputChunk, Plugin } from "rollup";

// Some dependencies vendor their own pre-bundled CommonJS (e.g.
// `use-sync-external-store`'s shim shipped inside base-ui / recharts / reactflow).
// When such code does `require("react")` and React is an SSR-external, Vite's SSR
// build lowers it to a runtime `__require("react")` (createRequire). Once a
// downstream bundler (Nitro, for a self-contained `.output/`) bundles React, that
// runtime require is left dangling — it either fails (`Cannot find module 'react'`)
// or loads a *second* React instance with a null dispatcher ("Invalid hook call").
// See nitrojs/nitro#4171.
//
// This plugin rewrites such leaked `__require("x")` calls to the copy of `x` already
// bundled in the output, so a single instance is used and the output stays
// self-contained.

// Maps a specifier to the CJS initializer variable Rolldown generates for it.
// Used in "react" (targeted) mode and as reliable hints in "all" mode.
const KNOWN_INITIALIZERS: Record<string, string> = {
react: "require_react",
"react-dom": "require_react_dom",
"react/jsx-runtime": "require_jsx_runtime",
"react/jsx-dev-runtime": "require_jsx_dev_runtime",
scheduler: "require_scheduler",
};

const LEAK_RE = /\b__require\(\s*"([^"]+)"\s*\)/g;
const DEF_RE = /\bvar (require_[A-Za-z0-9_$]+)\s*=[^\n]*__commonJS/g;
Comment on lines +28 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle single-quoted __require() calls too

LEAK_RE and the replacement regex only match __require("..."). If output uses __require('...'), the leak won’t be detected or rewritten, so runtime failures can persist in self-contained builds.

Suggested patch
-const LEAK_RE = /\b__require\(\s*"([^"]+)"\s*\)/g;
+const LEAK_RE = /\b__require\(\s*["']([^"'\\]+)["']\s*\)/g;
@@
-        const requireRe = new RegExp(`__require\\(\\s*"${escapeRe(spec)}"\\s*\\)`, "g");
+        const requireRe = new RegExp(
+          `__require\\(\\s*["']${escapeRe(spec)}["']\\s*\\)`,
+          "g"
+        );

Also applies to: 85-85

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/build/plugins/cjs-require.ts` around lines 28 - 29, The LEAK_RE regex
pattern at the current location only matches double-quoted require calls like
__require("...") but fails to match single-quoted variants like
__require('...'). Update the LEAK_RE regex pattern to match both single and
double quotes around the require path argument. Similarly, update the DEF_RE
regex pattern (also mentioned in the review at line 85) to match both quote
styles for the require definition pattern. Modify both regex patterns to use a
character class or alternation to capture both quote types, ensuring that leak
detection and rewriting work correctly regardless of which quote style is used
in the generated output.


export type CjsRequireMode = "react" | "all";

export function cjsRequire(nitro: Nitro, mode: CjsRequireMode = "react"): Plugin {
return {
name: "nitro:cjs-require",
generateBundle(_options, bundle) {
const chunks = Object.values(bundle).filter(
(asset): asset is OutputChunk => asset.type === "chunk"
);

// Bare specifiers that leaked as a runtime `__require(...)`.
const leaked = new Set<string>();
for (const chunk of chunks) {
for (const [, spec] of chunk.code.matchAll(LEAK_RE)) {
leaked.add(spec);
}
}
if (leaked.size === 0) {
return;
}

// Index of CJS initializers defined across the output (`require_x` -> chunk).
const defIndex = new Map<string, OutputChunk>();
for (const chunk of chunks) {
for (const [, name] of chunk.code.matchAll(DEF_RE)) {
if (!defIndex.has(name)) {
defIndex.set(name, chunk);
}
}
}

const unresolved: string[] = [];
for (const spec of leaked) {
// A leaked require for a genuinely external dependency is expected — only act
// on specifiers whose package is actually bundled in the output.
if (!isBundled(chunks, spec)) {
continue;
}

const initVar = resolveInitVar(spec, mode, defIndex);
if (!initVar) {
unresolved.push(spec);
continue;
}
const provider = defIndex.get(initVar)!;

// Ensure the initializer is exported from its chunk (Rolldown may have left
// it defined-but-unexported if nothing imported it).
let alias = findExportAlias(provider.code, initVar);
if (!alias) {
alias = `__nitro_${initVar}`;
provider.code += `\nexport { ${initVar} as ${alias} };\n`;
}

const requireRe = new RegExp(`__require\\(\\s*"${escapeRe(spec)}"\\s*\\)`, "g");
for (const chunk of chunks) {
if (!requireRe.test(chunk.code)) {
continue;
}
requireRe.lastIndex = 0;
if (chunk === provider) {
// The initializer is already in scope.
chunk.code = chunk.code.replace(requireRe, `${initVar}()`);
continue;
}
const local = `__nitroCjs_${spec.replace(/[^A-Za-z0-9_$]/g, "_")}`;
const rel = toImportPath(chunk.fileName, provider.fileName);
chunk.code = chunk.code.replace(requireRe, `${local}()`);
chunk.code = prependImport(chunk.code, `import { ${alias} as ${local} } from "${rel}";`);
}
}

if (unresolved.length > 0) {
nitro.logger.warn(
`Some bundled dependencies leaked a runtime \`require()\` in the server output and could not be rewritten to their bundled copy: ${[
...new Set(unresolved),
]
.map((s) => `\`${s}\``)
.join(
", "
)}.\nThis can fail at runtime when \`node_modules\` is not shipped (e.g. Docker). See https://github.com/nitrojs/nitro/issues/4171`
);
}
},
} satisfies Plugin;
}

/** Resolve the bundled CJS initializer variable for a leaked specifier. */
function resolveInitVar(
spec: string,
mode: CjsRequireMode,
defIndex: Map<string, OutputChunk>
): string | undefined {
const known = KNOWN_INITIALIZERS[spec];
if (known) {
return defIndex.has(known) ? known : undefined;
}
if (mode !== "all") {
return undefined;
}
// Generic resolution: derive likely initializer names from the specifier and keep
// the one defined in a chunk that actually bundles the package.
for (const candidate of candidateInitNames(spec)) {
const chunk = defIndex.get(candidate);
if (chunk && bundlesPkg(chunk, pkgOf(spec))) {
return candidate;
}
}
return undefined;
}

function candidateInitNames(spec: string): string[] {
const sanitize = (s: string) => s.replace(/[^A-Za-z0-9_$]/g, "_");
const last = spec.split("/").pop()!;
const pkg = pkgOf(spec);
const pkgBase = pkg.startsWith("@") ? pkg.split("/")[1] : pkg;
return [...new Set([`require_${sanitize(last)}`, `require_${sanitize(pkgBase)}`])];
}

function pkgOf(spec: string): string {
return spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
}

/** Whether the package behind a (sub-path) specifier is bundled in any chunk. */
function isBundled(chunks: OutputChunk[], spec: string): boolean {
return chunks.some((c) => bundlesPkg(c, pkgOf(spec)));
}

function bundlesPkg(chunk: OutputChunk, pkg: string): boolean {
const re = new RegExp(`[/\\\\]node_modules[/\\\\]${escapeRe(pkg)}[/\\\\]`);
return Object.keys(chunk.modules).some((id) => re.test(id));
}

function findExportAlias(code: string, local: string): string | undefined {
for (const [, body] of code.matchAll(/export\s*\{([^}]*)\}/g)) {
for (const entry of body.split(",")) {
const m = entry.trim().match(/^(\S+)(?:\s+as\s+(\S+))?$/);
if (m && m[1] === local) {
return m[2] || m[1];
}
}
}
return undefined;
}

function toImportPath(from: string, to: string): string {
let rel = relative(dirname(from), to);
if (!rel.startsWith(".")) {
rel = `./${rel}`;
}
return rel;
}

function prependImport(code: string, importStmt: string): string {
// Keep imports after a leading shebang if present.
if (code.startsWith("#!")) {
const nl = code.indexOf("\n") + 1;
return code.slice(0, nl) + importStmt + "\n" + code.slice(nl);
}
return `${importStmt}\n${code}`;
}

function escapeRe(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
15 changes: 15 additions & 0 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,21 @@ export interface NitroOptions extends PresetOptions {
* Sourcemap minification is enabled by default when `sourcemap` is on.
*/
sourcemapMinify?: false;
/**
* Rewrite runtime `require()` calls of bundled dependencies to their bundled copy
* in the server output.
*
* Works around dependencies whose vendored CommonJS leaves an unresolved
* `__require("react")` (e.g. `use-sync-external-store`'s shim), which otherwise
* breaks self-contained output or loads a duplicate React instance.
*
* - `"react"` — only the React family (`react`, `react-dom`, `react/jsx-runtime`, …).
* - `true` — every bundled dependency that leaked a runtime require.
* - `false` (default) — disabled.
*
* @see https://github.com/nitrojs/nitro/issues/4171
*/
cjsRequireRewrite?: boolean | "react";
/**
* Allow env expansion in runtime config
*
Expand Down
144 changes: 144 additions & 0 deletions test/unit/cjs-require.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, expect, it, vi } from "vitest";
import { cjsRequire, type CjsRequireMode } from "../../src/build/plugins/cjs-require.ts";

type Chunk = {
type: "chunk";
fileName: string;
code: string;
modules: Record<string, unknown>;
};

function chunk(fileName: string, code: string, moduleIds: string[] = []): Chunk {
return {
type: "chunk",
fileName,
code,
modules: Object.fromEntries(moduleIds.map((id) => [id, {}])),
};
}

function run(bundle: Record<string, Chunk>, mode: CjsRequireMode = "react") {
const warn = vi.fn();
const plugin = cjsRequire({ logger: { warn } } as any, mode);
(plugin.generateBundle as Function).call(null, {}, bundle);
return { warn };
}

describe("cjsRequire (nitrojs/nitro#4171)", () => {
it("rewrites a leaked __require() to the bundled initializer", () => {
const bundle = {
"_libs/react.mjs": chunk(
"_libs/react.mjs",
`var require_react = /* @__PURE__ */ __commonJSMin(() => {});\nexport { require_react as n };`,
["/app/node_modules/react/index.js"]
),
"_ssr/ssr.mjs": chunk("_ssr/ssr.mjs", `var React = __require("react");`, [
"/app/node_modules/use-sync-external-store/shim.js",
]),
};
const { warn } = run(bundle);

const consumer = bundle["_ssr/ssr.mjs"].code;
expect(consumer).not.toContain(`__require("react")`);
expect(consumer).toContain(`import { n as __nitroCjs_react } from "../_libs/react.mjs";`);
expect(consumer).toContain(`var React = __nitroCjs_react();`);
expect(warn).not.toHaveBeenCalled();
});

it("adds an export when the bundled initializer is defined but not exported", () => {
const bundle = {
"_libs/react-dom.mjs": chunk(
"_libs/react-dom.mjs",
`var require_react_dom = /* @__PURE__ */ __commonJSMin(() => {});\nvar require_server_edge = /* @__PURE__ */ __commonJSMin(() => {});\nexport { require_server_edge as t };`,
["/app/node_modules/react-dom/index.js"]
),
"_ssr/ssr.mjs": chunk("_ssr/ssr.mjs", `var ReactDOM = __require("react-dom");`, [
"/app/node_modules/some-ui-lib/dist/index.js",
]),
};
run(bundle);

expect(bundle["_libs/react-dom.mjs"].code).toContain(
`export { require_react_dom as __nitro_require_react_dom };`
);
expect(bundle["_ssr/ssr.mjs"].code).toContain(
`import { __nitro_require_react_dom as __nitroCjs_react_dom } from "../_libs/react-dom.mjs";`
);
expect(bundle["_ssr/ssr.mjs"].code).toContain(`var ReactDOM = __nitroCjs_react_dom();`);
});

it("leaves genuinely external requires untouched and does not warn", () => {
const code = `var db = __require("better-sqlite3");`;
const bundle = {
"index.mjs": chunk("index.mjs", code, ["/app/src/index.ts"]),
};
const { warn } = run(bundle);

expect(bundle["index.mjs"].code).toBe(code);
expect(warn).not.toHaveBeenCalled();
});

it(`"react" mode leaves a non-React bundled dependency alone and warns`, () => {
const bundle = {
"_libs/some-lib.mjs": chunk(
"_libs/some-lib.mjs",
`var require_some_lib = /* @__PURE__ */ __commonJSMin(() => {});\nexport { require_some_lib as q };`,
["/app/node_modules/some-lib/index.js"]
),
"_ssr/ssr.mjs": chunk("_ssr/ssr.mjs", `var x = __require("some-lib");`, [
"/app/node_modules/consumer/index.js",
]),
};
const { warn } = run(bundle, "react");

expect(warn).toHaveBeenCalledOnce();
expect(warn.mock.calls[0][0]).toContain("some-lib");
expect(bundle["_ssr/ssr.mjs"].code).toContain(`__require("some-lib")`);
});

it(`"all" mode rewrites any bundled dependency via its derived initializer`, () => {
const bundle = {
"_libs/some-lib.mjs": chunk(
"_libs/some-lib.mjs",
`var require_some_lib = /* @__PURE__ */ __commonJSMin(() => {});\nexport { require_some_lib as q };`,
["/app/node_modules/some-lib/index.js"]
),
"_ssr/ssr.mjs": chunk("_ssr/ssr.mjs", `var x = __require("some-lib");`, [
"/app/node_modules/consumer/index.js",
]),
};
const { warn } = run(bundle, "all");

expect(bundle["_ssr/ssr.mjs"].code).not.toContain(`__require("some-lib")`);
expect(bundle["_ssr/ssr.mjs"].code).toContain(
`import { q as __nitroCjs_some_lib } from "../_libs/some-lib.mjs";`
);
expect(bundle["_ssr/ssr.mjs"].code).toContain(`var x = __nitroCjs_some_lib();`);
expect(warn).not.toHaveBeenCalled();
});

it(`"all" mode still warns when no bundled initializer can be found`, () => {
const bundle = {
// package is bundled (module present) but has no CJS initializer to reuse
"_libs/some-lib.mjs": chunk("_libs/some-lib.mjs", `export const x = 1;`, [
"/app/node_modules/some-lib/index.js",
]),
"_ssr/ssr.mjs": chunk("_ssr/ssr.mjs", `var x = __require("some-lib");`, [
"/app/node_modules/consumer/index.js",
]),
};
const { warn } = run(bundle, "all");

expect(warn).toHaveBeenCalledOnce();
expect(bundle["_ssr/ssr.mjs"].code).toContain(`__require("some-lib")`);
});

it("is a no-op when there are no leaked requires", () => {
const original = `import { createElement } from "../_libs/react.mjs";`;
const bundle = { "_ssr/ssr.mjs": chunk("_ssr/ssr.mjs", original) };
const { warn } = run(bundle);

expect(bundle["_ssr/ssr.mjs"].code).toBe(original);
expect(warn).not.toHaveBeenCalled();
});
});
Loading