Skip to content

node:module: route CJS entrypoint and CJS-via-ESM-import through Module._extensions - #35774

Closed
robobun wants to merge 9 commits into
mainfrom
farm/1256acbd/module-extensions-entrypoint-esm
Closed

robobun wants to merge 9 commits into
mainfrom
farm/1256acbd/module-extensions-entrypoint-esm

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

What

Module._extensions['.js'] / require.extensions overrides now fire for the CommonJS entrypoint and for CJS modules reached via ESM import / import(), matching Node.js. Previously only nested require() hit the hook, so a -r preload hook (babel-register, pirates, nyc) left the entry file untransformed and was a complete no-op for any CJS reached from an ESM entry.

Repro

// hook.cjs
const M = require("module"), path = require("path"), oe = M._extensions[".js"];
globalThis.__hits = [];
M._extensions[".js"] = function (m, f) { globalThis.__hits.push(path.basename(f)); return oe(m, f); };
// main.cjs  (package.json: {"type":"commonjs"})
require("./dep.js"); console.log(JSON.stringify(globalThis.__hits));
// main.mjs
await import("./dep.js"); console.log(JSON.stringify(globalThis.__hits));
$ bun -r ./hook.cjs main.cjs   # before: ["dep.js"]          after/node: ["main.cjs","dep.js"]
$ bun -r ./hook.cjs main.mjs   # before: []                   after/node: ["dep.js"]

Cause

Both bypass cases funneled through fetchESMSourceCode, which called Bun__transpileFile with isCommonJSRequire=false (so the require.extensions check in transpile_file was skipped) and had no handler for the CommonJSCustomExtension result tag. The result went straight to createCommonJSModule / evaluateCommonJSModuleOnce with no _extensions consultation.

Separately, find_longest_registered_extension had no .js fallback, so .cjs files never matched an overridden .js handler (Node's _extensions has no .cjs key and falls back to .js).

Fix

  • src/runtime/jsc_hooks.rs: compute module_type before the require.extensions check and gate on (is_commonjs_require || module_type == Cjs) so the entrypoint and ESM-imports-CJS paths consult the override map too, while .mjs/.mts, .js under "type": "module", and .js/.ts with no package.json type (where Bun auto-detects from syntax) stay on the existing path.
  • src/jsc/bindings/ModuleLoader.cpp: handle CommonJSCustomExtension in fetchESMSourceCode by creating a JSCommonJSModule with the handler stashed on a new m_pendingCustomExtension field. The deferred synthetic-module generator and JSCommonJSModule::load() invoke it instead of evaluateCommonJSModuleOnce, so a sibling require() of a module pre-fetched by the ESM loader also runs the handler.
  • src/jsc/NodeModuleModule.rs: add Node's .js fallback to find_longest_registered_extension, restricted to .cjs and extensions Bun has no native loader for so hooking .js does not hijack .jsx/.tsx/.toml.
  • src/jsc/bindings/JSCommonJSModule.cpp: extract the requireMap get-or-create block shared by two createCommonJSModule paths into getOrCreateCommonJSModule.

Verification

New tests in test/js/node/module/require-extensions-entrypoint-esm.test.ts cover: CJS entrypoint, .cjs fallback, ESM-import-of-CJS, two negative cases (ESM .js under "type": "module" and auto-detected ESM .js with no package.json type), a sibling-cross-require case, and an end-to-end source-transforming preload hook. Existing require-extensions / require-extensions-override / 22929-module-extensions-asi suites pass unchanged.


[review] gate passed · iteration 1 · 7 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/module/require-extensions-entrypoint-esm.test.ts
bun test v1.4.0 (0c9da8daa)

test/js/node/module/require-extensions-entrypoint-esm.test.ts:
45 |       cwd: String(dir),
46 |       stderr: "pipe",
47 |     });
48 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
49 |     expect(stderr).toBe("");
50 |     expect(stdout.trim()).toBe('["main.cjs","dep.js"]');
                               ^
error: expect(received).toBe(expected)

Expected: "["main.cjs","dep.js"]"
Received: "["dep.js"]"

      at <anonymous> (/workspace/bun/test/js/node/module/require-extensions-entrypoint-esm.test.ts:50:27)
(fail) Module._extensions fires for the CJS entrypoint and CJS reached via ESM import > .cjs entrypoint falls back to the overridden .js handler [475.72ms]
66 |       cwd: String(dir),
67 |       stderr: "pipe",
68 |     });
69 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
70 |     expect(stderr).toBe("");

... (truncated)

release without fix: 4 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/module/require-extensions-entrypoint-esm.test.ts:
66 |       cwd: String(dir),
67 |       stderr: "pipe",
68 |     });
69 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
70 |     expect(stderr).toBe("");
71 |     expect(stdout.trim()).toBe('["dep.js","dep2.cjs"]');
                               ^
error: expect(received).toBe(expected)

Expected: "["dep.js","dep2.cjs"]"
Received: "[]"

      at <anonymous> (/workspace/bun/test/js/node/module/require-extensions-entrypoint-esm.test.ts:71:27)
(fail) Module._extensions fires for the CJS entrypoint and CJS reached via ESM import > ESM import of a CJS module hits the overridden .js handler [95.04ms]
(pass) Module._extensions fires for the CJS entrypoint and CJS reached via ESM import > ESM import of an auto-detected ESM .js (no package.json type) still works with a passthrough .js override [92.47ms]
(pass) Module._extensions fires for the CJS entrypoint and CJS reached via ESM import > ESM import of an ESM .js module does NOT hit the overridden .js handler [94.99ms]
45 |       cwd: String(dir),
46 |       
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/module/require-extensions-entrypoint-esm.test.ts
bun test v1.4.0 (0c9da8daa)

test/js/node/module/require-extensions-entrypoint-esm.test.ts:
(pass) Module._extensions fires for the CJS entrypoint and CJS reached via ESM import > ESM import of an auto-detected ESM .js (no package.json type) still works with a passthrough .js override [346.96ms]
(pass) Module._extensions fires for the CJS entrypoint and CJS reached via ESM import > .cjs entrypoint falls back to the overridden .js handler [534.34ms]
(pass) Module._extensions fires for the CJS entrypoint and CJS reached via ESM import > ESM import of a CJS module hits the overridden .js handler [573.69ms]
(pass) Module._extensions fires for the CJS entrypoint and CJS reached via ESM import > ESM import of an ESM .js module does NOT hit the overridden .js handler [559.10ms]
(pass) Module._extensions fires for the CJS entrypoint and CJS reached via ESM import > CJS entrypoint and nested require hit the overridden .js handler [1264.80ms]
(pass) Module._extensions fires for the CJ
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     0c9da8daae
  features     baseline

22 deps, 108 codegen, 1171 objects in 2923ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[2/1234] gen bindgenv2
[3/1234] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[4/1234] gen ErrorCode+*.h
[5/1234] gen .bind.ts → GeneratedBindings.cpp
[6/1234] gen ProcessBindingBuffer.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingBuffer.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingBuffer.cpp
[7/1234] gen JSEvent.lut.h
Generating /workspace/bun/build/release/codegen/JSEvent.lut.h from /workspace/bun/src/jsc/bindings/webcore/JSEvent.cpp
[8/1234] gen ProcessBindingFs.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingFs.lut.h from /w
... (truncated)
diff hotspot
src/jsc/NodeModuleModule.rs                        |  14 ++
 src/jsc/bindings/JSCommonJSModule.cpp              | 142 +++++++++++------
 src/jsc/bindings/JSCommonJSModule.h                |   7 +
 src/jsc/bindings/ModuleLoader.cpp                  |  21 ++-
 src/jsc/bindings/ModuleLoader.h                    |   6 +
 src/runtime/jsc_hooks.rs                           |  65 ++++----
 .../require-extensions-entrypoint-esm.test.ts      | 174 +++++++++++++++++++++
 7 files changed, 349 insertions(+), 80 deletions(-)

gate history · 2 passed · 1 rejected · iteration 1

evidence per changed file
file                                                      reads  edits  tests
src/jsc/NodeModuleModule.rs                                   4      8      0
src/jsc/bindings/JSCommonJSModule.cpp                        11      9      0
src/jsc/bindings/JSCommonJSModule.h                           2      4      0
src/jsc/bindings/ModuleLoader.cpp                             3      3      0
src/jsc/bindings/ModuleLoader.h                               3      3      0
src/runtime/jsc_hooks.rs                                      6      5      0
…s/node/module/require-extensions-entrypoint-esm.test.ts      0      2      0

…le._extensions

Node.js routes every load handled by the CJS loader through
Module._extensions[ext]: the entrypoint, nested require(), and a CJS
module reached from an ESM import (via the ESM loader's commonjs
translator). Bun only routed nested require(), so a -r/--require preload
hook (babel-register, pirates, nyc) left the entry file untransformed and
was a no-op for any CJS reached from an ESM entry.

Both bypass cases funneled through fetchESMSourceCode, which called
Bun__transpileFile with isCommonJSRequire=false and had no handler for
the CommonJSCustomExtension result tag.

This computes the file's module_type before the require.extensions check
and gates on (is_commonjs_require || module_type != Esm) so the
entrypoint and ESM-imports-CJS paths consult the override map too, while
.mjs/.mts and .js under "type": "module" remain on the ESM path.
fetchESMSourceCode now handles CommonJSCustomExtension by creating a
JSCommonJSModule with the handler stashed on m_pendingCustomExtension; the
deferred synthetic-module generator invokes it in place of
evaluateCommonJSModuleOnce.

Also adds Node's .js fallback to find_longest_registered_extension so
.cjs files use an overridden .js handler (Node's _extensions has no .cjs
key and falls back). The fallback is restricted to .cjs and extensions
Bun has no native loader for, so hooking .js does not hijack .jsx/.tsx.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 11 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b54c0ec4-364a-41e6-bb5f-30bd12c7167a

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and 0c9da8d.

📒 Files selected for processing (7)
  • src/jsc/NodeModuleModule.rs
  • src/jsc/bindings/JSCommonJSModule.cpp
  • src/jsc/bindings/JSCommonJSModule.h
  • src/jsc/bindings/ModuleLoader.cpp
  • src/jsc/bindings/ModuleLoader.h
  • src/runtime/jsc_hooks.rs
  • test/js/node/module/require-extensions-entrypoint-esm.test.ts

Comment @coderabbitai help to get the list of available commands.

Comment thread src/jsc/NodeModuleModule.rs Outdated
Comment thread src/jsc/NodeModuleModule.rs Outdated
Comment thread src/jsc/NodeModuleModule.rs Outdated
Comment thread src/jsc/bindings/JSCommonJSModule.h Outdated
Comment thread src/runtime/jsc_hooks.rs Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Nyc does not work with Bun / process.binding("spawn_sync") is not implemented #8694 - nyc relies on require.extensions (via istanbul-lib-hook / append-transform / pirates) to instrument source files at load time; this PR fixes Module._extensions overrides not firing for CJS entrypoints and CJS-via-ESM imports, which is exactly the mechanism nyc depends on.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #8694

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 PM PT - Jul 25th, 2026

❌ @robobun, your commit 0c9da8d has 1 failures in Build #81609 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    ❌ bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    ❌ bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    ❌ bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    ❌ bun-linux-x6472.48 MB71.95 MB+544.0 KB
    ❌ bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    ❌ bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    ❌ bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    ❌ bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    ❌ bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    ❌ bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    ❌ bun-windows-x6480.26 MB79.70 MB+572.0 KB
    ❌ bun-windows-aarch6470.86 MB70.34 MB+534.5 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 35774

That installs a local version of the PR into your bun-35774 executable, so you can run:

bun-35774 --bun

Comment thread src/jsc/NodeModuleModule.rs Outdated
Comment thread src/jsc/bindings/JSCommonJSModule.h Outdated
Comment thread src/runtime/jsc_hooks.rs Outdated
Comment thread src/jsc/bindings/JSCommonJSModule.cpp Outdated
Comment thread src/jsc/bindings/JSCommonJSModule.cpp Outdated
…et-or-create

When an ESM entry statically imports two CJS siblings and the first one's
body require()s the second, overridableRequire finds the second already in
requireMap (put there at fetch time with null sourceCode and the extension
handler stashed on m_pendingCustomExtension). load() bailed on
sourceCode.isNull() without checking the pending handler, so require()
returned the empty {} exports object. load() now runs the pending handler
first.

Also extracts the common requireMap get-or-create block shared by the
Ref<SourceProvider> createCommonJSModule overload and the new custom
extension path into getOrCreateCommonJSModule.
Comment thread src/runtime/jsc_hooks.rs Outdated
robobun and others added 2 commits July 25, 2026 20:32
…file

The != Esm gate also caught ModuleType::Unknown (a .js/.ts with no nearest
package.json type), which regressed Bun's syntax-driven ESM auto-detection:
a passthrough .js override made import of an ESM-syntax .js in that state
re-enter the ESM loader on its own in-flight key and fail. Gating on == Cjs
leaves Unknown to the parser as before.

Moved the new tests to require-extensions-entrypoint-esm.test.ts and
reverted require-extensions.test.ts to main; that file's in-process
'custom require extension 1' is flaky in release builds depending on the
on-disk runtime-transpiler-cache state (reported separately), and touching
it pulls that flake into the gate. Added a regression test for the
no-package-json auto-detect case.
Comment thread src/jsc/NodeModuleModule.rs
Comment thread src/jsc/bindings/JSCommonJSModule.cpp Outdated
…itional .js fallback, drop unused filename parameter

Pass lr.path.text (query-stripped) instead of the raw specifier to
find_longest_registered_extension at the first call site so a ?query suffix
cannot defeat the native-loader guard and hijack e.g. .jsx?v=1. Drop the
!last_ext.is_empty() guard so extensionless basenames and pure dotfiles
fall back to .js like Node.

Remove the unused String filename parameter from
evaluateCommonJSCustomExtension and the toWTFString + exception check in
JSCommonJSModule::load() that only existed to fill it.
Comment thread src/jsc/bindings/JSCommonJSModule.cpp Outdated
getOrCreateCommonJSModule can return an already-evaluated module
(require() then import() of the same key). Setting the one-shot field on
it would leave it stashed forever and, if user code later flipped
module.loaded = false, cause load() to re-run the handler.

@claude claude Bot left a comment

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.

All prior findings are addressed and this pass surfaced nothing new, but this is a ~350-line change to the core module-loader path (fetchESMSourceCode, JSCommonJSModule::load, transpile_file's require.extensions gate) with an explicitly acknowledged Node divergence for ModuleType::Unknown, so a human should sign off.

What was reviewed:

  • New m_pendingCustomExtension WriteBarrier: visited in visitChildrenImpl, cleared before invoking the handler in both load() and the synthetic-source lambda, and only set when !hasEvaluated.
  • getOrCreateCommonJSModule refactor: behavior-preserving vs. the pre-PR Ref<SourceProvider> overload; the un-refactored ResolvedSource& overload's isolation-cache work is intentionally left inline.
  • find_longest_registered_extension .js fallback: the EXTENSIONS_DEFAULT_KEYS early-return keeps .ts/.mjs/etc. from falling through, and the DEFAULT_LOADERS guard keeps .jsx/.tsx from being hijacked; both call sites now pass the query-stripped lr.path.text.
  • New fetchESMSourceCode CommonJSCustomExtension branch mirrors the adjacent isCommonJSModule block's exception/promise handling.
Extended reasoning...

Overview

Routes CJS entrypoints and CJS-reached-from-ESM through Module._extensions, matching Node's behavior for tools like babel-register/pirates/nyc. Touches transpile_file in jsc_hooks.rs (moves the module_type sniff before the require.extensions gate and widens the gate to is_commonjs_require || module_type == Cjs), adds a CommonJSCustomExtension handler to fetchESMSourceCode, adds a GC-visited m_pendingCustomExtension slot on JSCommonJSModule consumed by both load() and the synthetic-source generator, extracts a shared getOrCreateCommonJSModule helper, and adds Node's .js fallback to find_longest_registered_extension (restricted so it doesn't hijack Bun-native loaders). Seven new subprocess tests cover entrypoint, .cjs fallback, ESM-import-of-CJS, two negative cases, sibling cross-require, and an end-to-end source-transforming hook.

Security risks

None identified. No untrusted input parsing, no path resolution changes, no new syscall surface. The .js fallback is guarded by DEFAULT_LOADERS/EXTENSIONS_DEFAULT_KEYS so a .js override cannot hijack Bun-native extensions.

Level of scrutiny

High. This is the module loader — every import/require in every Bun program flows through the touched code. The change reorders transpile_file's gate, adds a deferred-evaluation slot with GC and exception-path implications, and refactors a createCommonJSModule overload. The four prior review rounds each surfaced a real correctness issue (sibling cross-require returning {}; ModuleType::Unknown re-entering the loader on an in-flight key; query-suffixed specifiers defeating the fallback guard; a stale one-shot slot). All were fixed, but the density of subtle interactions here is exactly the kind of thing a maintainer with module-loader context should look at.

Other factors

  • The author explicitly documents a Node divergence: a CJS-syntax .js with no package.json type reached from ESM import no longer hits the hook (gating on == Cjs instead of != Esm to preserve Bun's syntax auto-detection). That's a design trade-off a maintainer should ratify.
  • The getOrCreateCommonJSModule refactor changes one behavior of the Ref<SourceProvider> overload: when the requireMap already has a matching entry, the passed sourceProvider is now dropped instead of being assigned to the module's sourceCode — actually, on re-read, the pre-PR code also only used sourceProvider in the !moduleObject branch, so this is behavior-preserving. The ResolvedSource& overload was intentionally left un-refactored per prior discussion.
  • Test coverage is good (7 concurrent subprocess tests, all hermetic, exact-value assertions, both positive and negative cases) and the mechgate evidence shows fail-without-fix / pass-with-fix on ASAN debug.

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI on build 81609 (finished, 193/196 jobs passed): require-extensions-entrypoint-esm.test.ts passes on every lane.

The only hard failure is the binary-size check, which is comparing against main #79916 (commit ae4b17d, 8 commits behind this branch's base df6c7ee); the ~540KB delta is accumulated from those 8 main commits, not from this ~280-line diff.

The three [flaky] annotations (test-http-server-connections-checking-leak.js Alpine aarch64, no-orphans.test.ts darwin aarch64, 20144.test.ts darwin aarch64) are unrelated to module loading and passed on retry.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-25, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants