-
-
Notifications
You must be signed in to change notification settings - Fork 844
fix(build): experimental flag to rewrite leaked runtime require of bundled deps (#4171) #4365
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
pi0x
wants to merge
1
commit into
main
Choose a base branch
from
fix/ext-react
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+361
−0
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
|
||
| 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, "\\$&"); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle single-quoted
__require()calls tooLEAK_REand 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
Also applies to: 85-85
🤖 Prompt for AI Agents