Repository navigation
Conversation
In a split build the runtime module declares `__require` in one chunk, and
`__require` resolves a request from that chunk. The linker prints the path
of a bundled `.node` file for the chunk that holds the file. When the two
chunks are in different directories, the addon does not load, or another
addon of the same name loads.
For Bun, the file now calls `import.meta.require`, as a split `require()`
does. For Node.js, a chunk without the runtime module makes the `require`
for its addon: `__require("node:module").createRequire(import.meta.url)`.
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 15 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Updated 10:32 AM PT - Oct 30th, 2026
❌ @robobun, your commit f43878d has 1 failures in
🧪 To try this PR locally: bunx bun-pr 47483That installs a local version of the PR into your bun-47483 --bun |
|
Status: ready for review. PR: #47483 How I reproduced it (main at f7607a3, linux-x64, Node.js v26.3.0): mkdir -p repro/src/a repro/src/b && cd repro && bun add @resvg/resvg-js@2.4.1
echo 'export const tag = "ran";' > src/tag.js
printf 'import { tag } from "../tag.js";\nimport { Resvg } from "@resvg/resvg-js";\nconst r = new Resvg(`<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"/>`);\nconsole.log(tag, r.width, "by", r.height);\n' > src/a/entry.js
printf 'import { tag } from "../tag.js";\nconsole.log(tag, require("./other.cjs").name);\n' > src/b/entry.js
echo 'module.exports = { name: "other" };' > src/b/other.cjs
bun build ./src/a/entry.js ./src/b/entry.js --splitting --format esm --target bun --outdir out
bun out/a/entry.js
The same build without CI (build 135824, finished): 183 of 188 jobs passed. The new cases pass on every lane: Linux (glibc, musl, ASAN), Windows x64, Windows arm64, macOS arm64 and macOS x64. The 5 failed jobs have one red test, For a maintainer: one review thread is open. It asks whether a |
There was a problem hiding this comment.
Beyond the inline findings, I also checked that non-splitting builds and CommonJS output are untouched: makes_require_for_asset is false whenever the chunk holds the runtime module (always true without splitting), the Bun rewrite in generate_code_for_lazy_export is gated on code_splitting and skipped for CJS (where calls_runtime_require is already false), and the printer flag is inert when require_ref is None (CJS format and the runtime-source print path, which passes false).
Extended reasoning...
The change threads a per-chunk flag through the linker into the printer so a bundled .node addon is required relative to the chunk that holds it, with a separate import.meta.require rewrite for Bun targets. It touches no security-sensitive surface. Two inline findings (a publicPath regression and the loss of --define import.meta.require on Bun targets) already signal that a human needs to weigh the behavior change; the note above records the paths confirmed unaffected.
| call.target = Expr::init( | ||
| E::Dot { | ||
| target: Expr::init(E::ImportMeta {}, loc), | ||
| name: b"require".as_slice().into(), | ||
| name_loc: loc, | ||
| ..Default::default() | ||
| }, | ||
| loc, | ||
| ); |
There was a problem hiding this comment.
🔴 Users who build a split Bun-target bundle with --define import.meta.require=... (or import.meta.url) lose their define for every bundled .node addon after merging. The target injected at src/bundler/linker_context/generateCodeForLazyExport.rs:433-441 is a fresh import.meta.require AST node built in the linker, after the parser has already applied defines, so it prints literally; on the base branch the call went through the runtime's __require, whose own import.meta.require the define had rewritten. Fix: make the injected addon call honour the build's defines, e.g. keep __require when import.meta.require is defined or run the define substitution over the injected expression. [also at: src/js_printer/lib.rs:3560 - Users who build a split Node.js or Bun bundle with a .node file and --define import.meta.url=... (or import.meta.require=...) get an addon that no longer loads after this merges.]
Why this was flagged
A user runs bun build --splitting --target bun --define import.meta.require=require entry.js so the output also runs on Node, and entry.js requires a .node addon that lands in the same directory as the runtime chunk. On the base branch the addon call prints __require(<path>) and the runtime module's __require is import.meta.require, which the parser-time define turned into require, so the addon loads. After this change requires_asset_from_import_meta (generateCodeForLazyExport.rs:589-593) is true and lines 433-441 replace the call target with E::Dot { target: E::ImportMeta, name: "require" }; defines are applied in the parser, not here, so the printer at src/js_printer/lib.rs:3278 emits the literal import.meta.require, which is undefined when the output runs on Node, and the addon fails to load. The PR description lists 10 of 208 checked builds that stop loading an addon because they redirect __require with --define; no guard in the diff keeps the old call when such a define is present.
Verification: Defines on import.meta.<x> are applied at parse time (src/js_parser/visit/visit_expr.rs:1358-1361). After this change, src/bundler/linker_context/generateCodeForLazyExport.rs:428-441 replaces the ERequireCallTarget with a freshly built E::Dot { target: E::ImportMeta, name: "require" } after parsing is done. The printer prints E::ImportMeta literally (src/js_printer/lib.rs:3273-3278), so the user's define no longer reaches it.
There was a problem hiding this comment.
This is true, and it is deliberate. The description states it under Downsides (10 of the 208 checked builds) and under "The rule, and what it decides" in the notes.
Main already prints a literal import.meta.require for the other request that the linker writes for a file that it emits: a split require() (#40519, src/js_printer/lib.rs:2934). A --define does not reach that call. On main at f7607a3:
$ printf 'const tool = () => require("./tool.js");\nconsole.log(tool().name);\n' > main.js
$ printf 'export const name = "tool";\n' > tool.js
$ bun build ./main.js --splitting --format esm --target bun --define 'import.meta.require=globalThis.hostRequire' --outdir out
$ grep -n 'tool-' out/main.js
8: var tool = () => import.meta.require("./tool-h5n7x3et.js");
So on main, a build that replaces import.meta.require loads its chunks with the real one and its addons with the replacement. With this change, both use the real one.
The example in the comment does not run on main. --define import.meta.require=require prints var __require = require;. Node.js throws ReferenceError: require is not defined in ES module scope, and Bun throws ReferenceError: require is not defined. With this change, Bun finds the addon.
An earlier revision of this branch kept main's call when a define or a plugin changed the declaration of __require. I removed it: it was 61 lines, and no build that needs it is known. If a maintainer wants a define of import.meta.require or import.meta.url to keep main's call for the addon, I can add a check for those two keys.
Problem
.nodeaddon of a--splittingbuild does not load when its chunk and the runtime module's chunk are in different directories:error: Cannot find module '../resvgjs.linux-x64-gnu-83v4kzhy.node' from '<out>/entry-<hash>.js'. The build exits 0.src/bundler/Chunk.rs:699). The runtime module's__require(src/bundler/ParseTask.rs:425) resolves it from that module's chunk, so main can also load the wrong addon.--external <package>avoids it.Fix
import.meta.require(<asset>), as a splitrequire()does (bundler: with --splitting --target bun, require() of an ES module is a chunk boundary #40519) and as 1.1.42 did.requirefor its addon:__require("node:module").createRequire(import.meta.url)(<asset>).test/bundler/bundler_splitting.test.ts(33 new cases, 23 fail on main), plus the loader, compile and plugin suites.Bun.ModuleGraphtest (experimental API), a layout fuzzer (follow-up).Background
Downsides
.nodefile: Bun +10 B. Node.js +46 B and +210k instructions per load, in a chunk without the runtime module.__requirewith--define, 4 moved the.nodefiles.requirecalls stay broken (repro in Notes). Question: take this now, or wait for arequireper chunk?Notes
History. Each version builds and runs its own output (linux-x64, node v26.3.0). The shape is one entry point and an
import()page, with--chunk-naming 'chunks/[name]-[hash].[ext]'.require("./addon.node")require("./addon.node")import addon from "./addon.node"import addon from "./addon.node"--target bunregressed in 1.1.43 (transpiler: dont inlineimport.meta.require#16222). In 1.4.1 and 1.4.2 the chunk fold put the runtime module into the chunk of the entry point. From 1.4.3, an entry point without[hash]in its name takes no fold (bundler(splitting): one edge rule for both walks; no chunk imports an unhashed entry #44023).@resvg/resvg-js2.4.1 and entry points in two directories,--target bunruns on 1.1.42 and throws from 1.1.43 to main.--target nodethrows on every version. For Node.js this PR is new behaviour, not a restore.module.exports = import.meta.require("../resvgjs.linux-x64-gnu-83v4kzhy.node");. This PR prints the same line.Trigger. All of these must hold:
Examples are entry points in two directories with the default names, a
chunks/directory with entry points at the root,[dir]in--chunk-naming, and[hash]in--entry-naming.What main does.
@resvg/resvg-js2.4.1,@napi-rs/canvas0.1.91 and@swc/core1.3.38, each with entry points in two directories, for both targets: main's build exits 0 and the output throws for 6 of 6. This PR runs 6 of 6. The errors of the last two name the package or npm:Cannot find native binding. npm has a bug related to optional dependenciesandBindings not found.--outdir. Fromout/a/entry.js, main prints__require("../a-required-<hash>.node")and resolves it fromout/.[dir]in--asset-namingand in--chunk-naming,a/andb/can each hold an addon of one name. On main,b/entry.jsloads the addon ofa/and exits 0. The caseAddonOfTheSameNameLoadsFromItsChunkshows it.The rule, and what it decides. In ESM output, a request that the linker writes for a file that it emits resolves from the chunk that holds the request.
__requireis a helper of the bundle. #40519 applied this rule to a splitrequire(). For a bundled.nodefile the rule decides three cases. A maintainer can want each one the other way:--defineofimport.meta.requireorimport.meta.url. On main the addon request follows the definition, because__requiredoes. Now Bun's addon calls the realimport.meta.require, and for Node.js a chunk without the runtime module uses its realimport.meta.url. These are the 10--definerows of Downsides.node:moduleimport of the runtime module. On main the addon request goes through the plugin'screateRequire. Now, for Node.js, a chunk without the runtime module asks__requirefornode:moduleand makes its ownrequirefrom it. Open PR bundler: do not offer the runtime module's imports to onResolve plugins #43111 stops the offer of that import to plugins.require = xin the program. On main,xgets each addon path. Now Bun's addon does not callx, and for Node.js a chunk without the runtime module callsx("node:module"). Open PR bundler: give a file that assignsrequireits ownrequirevariable #46640 gives a file that assignsrequirea variable of its own. The loader of@node-rs/xxhash1.7.6 assignsrequire: for Bun, a split build of such a loader now loads (AddonLoadsWhenItsLoaderAssignsRequire).An earlier revision kept main's call when a
defineor a plugin changed the declaration of__require. It cost 61 source lines and no such build is known, so it is not here.One import that nothing reads. For Bun, an addon in a module without a wrapper (an
import addon from "./x.node"that the linker does not wrap) keeps its import of__require. Without that use, the chunk of the runtime module and the order of the chunks change for other entry points (#44495). The caseAddonInOneEntryPointKeepsTheOrderOfTheOthersholds this. An addon in a module with a wrapper (__commonJS,__esm) drops the use, as a splitrequire()does.More behaviour changes.
requireran without its addon on main. It now loads the addon.Bun.ModuleGraph(experimental), for a Node.js build that runs on Bun: Bun'screateRequiretakes the graph of its caller, and the new call runs when the addon first loads. If that first use is in a callback of a graph that was disposed, it throwsERR_INVALID_STATE, and main's build loads. A build for Bun is not changed:import.meta.requiretakes the graph of its module, as__requiredoes.What is not changed.
.nodefile in the chunk of the runtime module: the output is byte-identical. @vercel/nft 1.11.0 follows that call. It does not follow the call in another chunk, on main or with this PR, so a deployment of traced files still lacks those addons..nodeloader. Thefileandwasmloaders export the same kind of path as a string (type: "file"imports emit cwd-relative paths instead of module-relative paths #41780, Fix Bun file loader paths from another cwd #41892). A text import of a standalone executable keeps__require.require(),require.resolve()orrequire.mainthat the user wrote and that stays in the output. Of 224 split cells of request forms by placement, 144 fail on main, and this PR fixes the 24 addon cells.The
requirethat a user writes (not fixed). No issue tracks it. The repro:error: Cannot find module '../lib.js' from '<dir>/out/entry-jjdkv1j6.js'. Without--splittingit printsa shared lib.--target nodewith Node.js fails in the same way.require(name), arequirethat the program passes to a function,require.resolve("../lib.js")andrequire.resolve(name)fail in the same way.--target bunprintedimport.meta.require("../lib.js")in 1.1.42 and ran. It fails from 1.1.43 (transpiler: dont inlineimport.meta.require#16222).--target nodefails on every version from 1.1.42 to 1.4.4.robobun/ec0f35a0/chunk-own-requirehas one design: a chunk in another directory than the runtime module gets arequireof its own. Its measurements are inscratch/chunk-own-require/RESULTS.txtthere. That design replaces the call that this PR prints for Node.js. It needs a maintainer's decision first, because it changes whichrequireobject a chunk has.Considered, in more words.
__requirekept. By a rewrite of main's output (not built), 196 of the 208 rows below print what main prints or are fixed, against 194 for this PR. It prints no new call and keeps the 10--definerows. It does not fix a relative--public-path, and it leaves 8 rows for Bun as on main: the second build with--compile --aot. It makes the addon's path the one path in a chunk that is not relative to that chunk (src/bundler/Chunk.rs:699-711), the call still resolves from another chunk, and arequireper chunk has to remove it again.Measurements (release builds of f7607a3 and of this change on it, linux-x64, node v26.3.0).
Loader::Napicheck removed from the printer path, those 3 fail. With the one of the link-time path removed, 1 of them fails. No other case of the file fails for these two edits.--minify. Node.js: 100 print what main prints or are fixed, 8 differ. Bun: 94, and 6 differ. The 14 rows that differ are the 10--definerows and the 4 moved-file rows of Downsides.MODULE_NOT_FOUNDto a loaded addon. 0 cells of a build that does not split differ..nodefile in a split build are byte-identical.bun build --compile --splittingin 5 modes (plain,--bytecode --format esm,--aot,--no-split-require, and the bytecode mode with--no-split-require): each executable prints what main's prints..nodefile. Bun: +10 B (+18 B minified), and -61 B once when a module with a wrapper was the one user of__require. Node.js, in a chunk without the runtime module: +46 B.process.dlopenreplaced. Bun: 113.7k to 114.7k for a required addon and 35.5k to 36.6k for an imported one, with noise of about 1k.perf_event_open.[human-review] gate passed · iteration 0 · 6 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file