diff --git a/src/build/plugins.ts b/src/build/plugins.ts index 9d452bba1b..5cc37ce833 100644 --- a/src/build/plugins.ts +++ b/src/build/plugins.ts @@ -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[] = []; @@ -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 && diff --git a/src/build/plugins/cjs-require.ts b/src/build/plugins/cjs-require.ts new file mode 100644 index 0000000000..efb51fabc4 --- /dev/null +++ b/src/build/plugins/cjs-require.ts @@ -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 = { + 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; + +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(); + 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(); + 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 | 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, "\\$&"); +} diff --git a/src/types/config.ts b/src/types/config.ts index f8ebb8aa51..f0bd0374d6 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -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 * diff --git a/test/unit/cjs-require.test.ts b/test/unit/cjs-require.test.ts new file mode 100644 index 0000000000..e7c543625b --- /dev/null +++ b/test/unit/cjs-require.test.ts @@ -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; +}; + +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, 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(); + }); +});