Add Module.prototype.load for new Module() instances#29256
Add Module.prototype.load for new Module() instances#29256
Conversation
requizzle (jsdoc's dependency) constructs modules by hand and
calls .load() on them — the same pattern Node's cjs loader uses
internally. Bun's Module.prototype was a disposable object with
only `require` and `_compile`, and instances had no `load`
at all, so `targetModule.load is not a function` fired before
jsdoc could start.
Fix:
- Unify `require('module').prototype` with the prototype used
by `new Module(...)` (JSCommonJSModulePrototype). Patching
one now affects the other, matching Node.
- Add `Module.prototype.load(filename)` as a builtin that sets
filename/paths, dispatches to the registered extension, and
marks the module loaded. The default .js extension reads the
file and calls module._compile, which honors the current
Module.wrapper.
Fixes #29253
|
Updated 9:36 AM PT - Apr 13th, 2026
❌ @robobun, your commit 4d3379d has 4 failures in
🧪 To try this PR locally: bunx bun-pr 29256That installs a local version of the PR into your bun-29256 --bun |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds Node-compatible Module.prototype.load by registering a builtin on the CommonJS module prototype, implementing Module.prototype.load in TypeScript, and adding regression tests covering load semantics, error recovery, wrapper injection, and compound-extension dispatch. Changes
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Warning Review ran into problems🔥 ProblemsTimed out fetching pipeline failures after 30000ms Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/js/builtins/CommonJS.ts`:
- Around line 428-430: Remove the unused fs import by deleting the `const fs =
require("node:fs");` line (or removing `fs` from the grouped require
declaration) in src/js/builtins/CommonJS.ts; keep the existing `Module` and
`path` requires intact so `Module` and `path` used by
`modulePrototypeLoad`/`modulePrototypeLoadJSExtension` remain unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 2993df3c-bef5-490a-a314-9dd485f7a7ad
📒 Files selected for processing (4)
src/bun.js/bindings/JSCommonJSModule.cppsrc/bun.js/modules/NodeModuleModule.cppsrc/js/builtins/CommonJS.tstest/regression/issue/29253.test.ts
There was a problem hiding this comment.
This PR touches critical CommonJS prototype unification code, and there is a real bug where the _compile CustomAccessor setter/getter fails when accessed directly on Module.prototype (the prototype object is not a JSCommonJSModule instance, so the dynamic cast fails silently). This can break tools like Istanbul/nyc/ts-node that monkey-patch Module.prototype._compile.
Extended reasoning...
Overview
This PR fixes a real compatibility gap: new Module(...).load() was missing, breaking packages like requizzle/jsdoc. The fix has three parts: (1) getModulePrototypeObject now returns the real JSCommonJSModulePrototype instead of a disposable plain object; (2) Module.prototype.load is added as a builtin; (3) the TypeScript builtin modulePrototypeLoad is implemented.
Security risks
No security-sensitive code paths touched. This is module loader plumbing with no auth, crypto, or permission changes.
Level of scrutiny
High. This modifies the CommonJS prototype unification — a fundamental invariant of the module system. The change affects every new Module(...) instance and every access to Module.prototype. The bug hunting system flagged a real issue: after unification, Module.prototype._compile is backed by a CustomAccessor whose getter/setter both do jsDynamicCast<JSCommonJSModule*>(thisValue) and bail on failure. When thisValue is the prototype object itself (not an instance), the cast fails silently — the getter returns undefined and the setter is a no-op in non-strict mode. This breaks the canonical Module.prototype._compile = wrapper pattern used by Istanbul, nyc, ts-node, and babel/register.
Other factors
Two nit-level issues (dead code and an unused require) were also flagged and are minor. The test coverage for the new load() functionality is comprehensive. However, the _compile accessor regression is a correctness issue that warrants a human look before merging.
Address review feedback: - Revert the Module.prototype unification in NodeModuleModule.cpp. Unifying exposed the pre-existing CustomAccessor on _compile to prototype-level reads, which silently bailed on the cast to JSCommonJSModule* and broke Module.prototype._compile monkey-patching. The new load() method lives on JSCommonJSModulePrototype (the real instance prototype), which is all requizzle/jsdoc need. - Use Node's longest-registered-extension lookup in modulePrototypeLoad instead of path.extname, so Module._extensions['.test.js'] (and any other compound-suffix handler) wins over '.js'. - Reset this.loaded in the catch branch when the extension handler throws, so a failed load() does not permanently trap the instance behind the 'Module already loaded' assert. module._compile sets hasEvaluated eagerly for cycle-breaking, so without this reset, retrying on the same module object was impossible. - Throw an AssertionError (via node:assert) for the already-loaded guard to match Node's error shape / code. - Remove the dead modulePrototypeLoadJSExtension helper — the .js handler is already registered in C++. New regression tests: failed-load retry, compound-extension dispatch.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/regression/issue/29253.test.ts`:
- Around line 71-82: In the test block where const [stdout, stderr, exitCode] =
await Promise.all([...]) is used, move the assertions that inspect stdout
(parsing JSON into result and the expects on result.loaded, result.filename,
result.exports.*) to occur before the expect(exitCode).toBe(0); keep the stderr
assertions (expect(stderr).not.toContain(...)) adjacent to stdout checks but
ensure exitCode assertion comes last. Apply the same reorder in the other
mentioned blocks (around lines referenced) so stdout parsing/assertions execute
before asserting exitCode, using the variables stdout, stderr, exitCode and
result to locate the code to change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: df738f79-1cff-44b3-943b-91df9db8fdc8
📒 Files selected for processing (2)
src/js/builtins/CommonJS.tstest/regression/issue/29253.test.ts
There was a problem hiding this comment.
♻️ Duplicate comments (1)
test/regression/issue/29253.test.ts (1)
71-82:⚠️ Potential issue | 🟡 MinorReorder subprocess assertions so stdout checks run before
exitCode.
expect(exitCode).toBe(0)is still executed before stdout parsing/assertions in these blocks. Keep stdout/stderr assertions first and assertexitCodelast for better failure diagnostics.♻️ Suggested reorder pattern (apply similarly to each block)
- expect(stderr).not.toContain("TypeError"); - expect(stderr).not.toContain("Error"); - expect(exitCode).toBe(0); - - const result = JSON.parse(stdout.trim()); + expect(stderr).not.toContain("TypeError"); + expect(stderr).not.toContain("Error"); + const result = JSON.parse(stdout.trim()); expect(result.loaded).toBe(true); expect(result.filename).toMatch(/target\.js$/); expect(result.exports.answer).toBe(42); expect(result.exports.filename).toBe(result.filename); + expect(exitCode).toBe(0);As per coding guidelines: “Expect stdout assertions before exit code assertions - expect(stdout).toBe(...) BEFORE expect(exitCode).toBe(0) for more useful error messages”.
Also applies to: 120-126, 163-169, 213-218, 257-263
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@test/regression/issue/29253.test.ts` around lines 71 - 82, Reorder the assertions in this test so stdout/stderr checks and parsing happen before asserting exitCode: read and parse stdout/stderr (the Promise.all result variables stdout, stderr), assert stderr does not contain errors and then parse JSON into result and run expect(result.loaded/filename/exports...) first, and only after all stdout/stderr-based expectations are done assert expect(exitCode).toBe(0); apply the same reordering pattern to the other blocks mentioned (lines with the same stdout, stderr, exitCode usage).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@test/regression/issue/29253.test.ts`:
- Around line 71-82: Reorder the assertions in this test so stdout/stderr checks
and parsing happen before asserting exitCode: read and parse stdout/stderr (the
Promise.all result variables stdout, stderr), assert stderr does not contain
errors and then parse JSON into result and run
expect(result.loaded/filename/exports...) first, and only after all
stdout/stderr-based expectations are done assert expect(exitCode).toBe(0); apply
the same reordering pattern to the other blocks mentioned (lines with the same
stdout, stderr, exitCode usage).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 25ca0606-f064-43c9-b2bc-6c233266b305
📒 Files selected for processing (1)
test/regression/issue/29253.test.ts
Matches the coding guideline: stdout checks run first so a failing expect() shows the actual subprocess output before the exit code.
There was a problem hiding this comment.
LGTM — all previously flagged bugs addressed in 1bf6b8b; remaining nits are test-style only.
Extended reasoning...
Overview
The PR adds Module.prototype.load(filename) to JSCommonJSModulePrototype — the C++ change is a one-liner putDirectBuiltinFunction call, and the TypeScript implementation mirrors Node's CJS loader semantics. Three files are touched: the C++ prototype, the CommonJS.ts builtins, and a new regression test suite.
Security risks
None. This is a compatibility shim that routes through the existing extension-handler dispatch (Module._extensions) and module._compile, both of which already exist and are trusted. No new privilege surfaces or external input paths are introduced.
Level of scrutiny
The C++ change is mechanical (one function registration). The TypeScript implementation is moderate complexity but well-bounded: it sets filename/paths, runs the extension handler, and resets loaded on failure. The logic closely mirrors Node's own Module.prototype.load and is covered by 5 targeted tests.
Other factors
All serious concerns from prior review cycles were resolved: the unused fs import was removed, the risky prototype unification was reverted (avoiding the _compile CustomAccessor regression), dead modulePrototypeLoadJSExtension code was removed, the loaded flag is now correctly reset on handler failure, the already-loaded guard throws an AssertionError via assert(), and compound extensions (.test.js) are handled via a longest-match loop. The two remaining inline comments are minor test-style nits (concurrent vs sequential test declarations and assertion ordering) that do not affect correctness.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/regression/issue/29253.test.ts`:
- Around line 78-80: Remove the brittle broad stderr substring assertions (the
expect(stderr).not.toContain("TypeError") and
expect(stderr).not.toContain("Error") checks) from this test and the similar
occurrences mentioned (around the checks at lines 122-124, 165-167, 215-216,
259-261); rely on the deterministic stdout assertions and
expect(exitCode).toBe(0) for success instead, and only add explicit stderr
assertions if you are verifying a specific, expected error message for a
regression. Locate the offending assertions by searching for
expect(stderr).not.toContain(...) in issue/29253.test.ts and delete them or
replace them with a focused check that matches a known, specific stderr message
if needed.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1fbf7690-f07b-4f55-a164-bcb653ffbca2
📒 Files selected for processing (1)
test/regression/issue/29253.test.ts
Each spawning test owns its own tempDir and child process with no
shared state, so they can safely run in parallel. Removed the broad
stderr substring checks ("Error", "TypeError", etc.) per the
existing guideline that only specific, expected stderr strings should
be asserted — deterministic stdout + exitCode already cover success.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/regression/issue/29253.test.ts`:
- Around line 23-34: The test must assert that a new Module instance shares the
exact prototype object with Module.prototype, not just that it inherited a load
method; update the assertions around the instance m to replace or augment the
prototype checks so they verify Object.getPrototypeOf(m) === Module.prototype
(in addition to existing checks for typeof m.load and no own property),
referencing the Module constructor, the instance m, and Module.prototype to
ensure prototype identity is enforced.
- Around line 123-161: The test's paths assertion is too weak; instead of only
checking Array.isArray(m.paths), call
Module._nodeModulePaths(path.dirname(target)) and assert m.paths strictly equals
that result (or deep-equals the array) so the test verifies load() computes
paths from the correct directory; locate the driver.js code that references
m.paths and replace the Array.isArray check with a comparison to
Module._nodeModulePaths(path.dirname(target)) (or alternatively exercise a
require() that would only succeed if the paths are correct).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 89ae26af-86c9-484d-a44c-9202aac71a6d
📒 Files selected for processing (1)
test/regression/issue/29253.test.ts
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/bun.js/bindings/JSCommonJSModule.cpp:819-824— After this PR, require('module').prototype.load is undefined on Bun while (new Module()).load is a function — a split that has no Node.js precedent. In Node.js both paths point to the same object so load is always present. Any code that inspects or monkey-patches require('module').prototype.load to intercept all module loads will silently fail on Bun.Extended reasoning...
What the bug is and how it manifests
The PR adds load exclusively to JSCommonJSModulePrototype::finishCreation in JSCommonJSModule.cpp (lines 819-824). That object is the real per-instance prototype backing instances created by new Module(). However, require('module').prototype is served by a completely separate code path: getModulePrototypeObject() in NodeModuleModule.cpp (lines 766-780), which constructs a fresh plain JSObject and attaches only require and _compile. No load is added there. As a result, typeof require('module').prototype.load === 'undefined' on Bun after this PR, while typeof (new Module()).load === 'function'.
The specific code path
NodeModuleModule.cpp:917 registers prototype as a PropertyCallback backed by getModulePrototypeObject. That function returns a new disposable object each call. JSCommonJSModule.cpp:819-824 adds load only to JSCommonJSModulePrototype, the C++ class used as the real prototype of instances. These two objects are distinct and neither inherits from the other.
Why existing code does not prevent it
The PR originally attempted prototype unification but that was reverted in commit 1bf6b8b because the _compile CustomAccessor performs a jsDynamicCast<JSCommonJSModule*> and silently returns undefined when called on the prototype object itself. The revert was correct for _compile, but it re-introduced the load gap: before this PR neither object had load (consistently absent); after this PR only the instance prototype has it (split with no Node.js precedent).
Impact
Code that checks typeof require('module').prototype.load === 'function' will see 'undefined' on Bun. More critically, monkey-patching require('module').prototype.load = myWrapper to intercept all module loads (an Istanbul/nyc style instrumentation pattern) will silently not work on Bun, because the patch is applied to the disposable object while actual instances inherit from JSCommonJSModulePrototype, which is unaffected.
Step-by-step proof
- require('node:module').prototype.load triggers getModulePrototypeObject(), returning a disposable object with no load property.
- typeof require('node:module').prototype.load returns 'undefined'.
- Patching: require('node:module').prototype.load = fn writes to the disposable object.
- const m = new Module('/foo.js'); m.load looks up JSCommonJSModulePrototype, finds the original builtin — the patch on the disposable object is invisible.
- On Node.js: require('module').prototype === Object.getPrototypeOf(new Module()) — both see the same object with load as a function.
How to fix it
The simplest fix is to also add load to the disposable prototype object in getModulePrototypeObject(), similarly to how _compile is added at line 777. Since the disposable object is a plain data property (not a CustomAccessor), assigning load there as a direct property would make typeof require('module').prototype.load === 'function' true, closing the compatibility gap without requiring full prototype unification.
-
🔴
src/js/builtins/CommonJS.ts:433-434— modulePrototypeLoad sets this.filename but not this.path (m_dirname), so __dirname inside a loaded file is taken from the Module constructor id rather than from the load filename — wrong when dirname(constructor_id) ≠ dirname(filename). Add this.path = path.dirname(filename) alongside the existing this.filename = filename assignment to fix it.Extended reasoning...
What the bug is and how it manifests
modulePrototypeLoad (CommonJS.ts:433) sets this.filename = filename and this.paths = Module._nodeModulePaths(path.dirname(filename)), but does NOT update this.path, which maps to m_dirname via setterPath. The native .js extension handler (jsLoaderJS) does NOT call module._compile directly; instead it routes through builtinLoader → fetchCommonJSModuleNonBuiltin → evaluateWithPotentiallyOverriddenCompile → evaluate(). The evaluate() call at JSCommonJSModule.cpp line ~1381 passes this->m_dirname.get() as the __dirname argument to evaluateCommonJSModuleOnce. m_dirname is set once from the constructor id argument (jsFunctionNodeModuleModuleConstructor) and never updated by load(), so it reflects the directory of the constructor path, not the load path.
The specific code path that triggers it
I confirmed this by reading the source directly:
- modulePrototypeLoad calls handler.$call(extensions, this, filename) for the .js handler.
- jsLoaderJS → builtinLoader(globalObject, callFrame, BunLoaderTypeJS) (JSCommonJSExtensions.cpp:243).
- builtinLoader calls fetchCommonJSModuleNonBuiltin (ModuleLoader.cpp:267).
- fetchCommonJSModuleNonBuiltin at line 799: target->evaluateWithPotentiallyOverriddenCompile(globalObject, specifierWtfString, specifierValue, res->result.value).
- evaluateWithPotentiallyOverriddenCompile checks m_overriddenCompile; if not set (the default), calls this->evaluate(globalObject, key, source, false).
- evaluate() calls evaluateCommonJSModuleOnce(vm, globalObject, this, this->m_dirname.get(), this->m_filename.get()).
m_filename was updated by this.filename = filename in modulePrototypeLoad. m_dirname was NOT updated; it still holds the value derived from the constructor id.
Addressing the refutation
The refutation claims 'jsLoaderJS reads the file content and calls module._compile(content, filename)', citing functionJSCommonJSModule_compile (which does derive dirname from the filename argument). This is incorrect. Reading JSCommonJSExtensions.cpp:243-297, builtinLoader does NOT call _compile. It calls fetchCommonJSModuleNonBuiltin (line 267-279). The _compile path (functionJSCommonJSModule_compile) is a separate entry point, only reached when JavaScript code calls module._compile(source, filename) directly. The builtinLoader + fetchCommonJSModuleNonBuiltin path bypasses _compile entirely in the default (no overridden _compile) case.
Why existing tests mask it
All tests in 29253.test.ts construct Module with the same path they pass to load():
const target = path.resolve(__dirname, "target.js");
const m = new Module(target, module);
m.load(target);
So dirname(constructor_id) === dirname(filename) in every test. The stale m_dirname happens to be correct, hiding the bug.Concrete proof of the bug
- const m = new Module('/foo/id.js', module); — m_dirname = '/foo'
- m.load('/bar/other.js'); — m_filename is updated to '/bar/other.js' via this.filename=filename, but m_dirname stays '/foo'
- builtinLoader → fetchCommonJSModuleNonBuiltin → evaluateWithPotentiallyOverriddenCompile → evaluate()
- evaluateCommonJSModuleOnce is called with dirname='/foo', filename='/bar/other.js'
- Inside other.js: __dirname === '/foo' (wrong — should be '/bar'), __filename === '/bar/other.js' (correct)
- Any require('./dep') call inside other.js resolves against /foo, not /bar — also wrong.
How to fix it
Add this.path = path.dirname(filename) in modulePrototypeLoad alongside the existing this.filename = filename:
this.filename = filename;
this.path = path.dirname(filename); // <-- add this line to update m_dirname
this.paths = Module._nodeModulePaths(path.dirname(filename));Node.js does not have this problem because its Module.prototype._compile always derives dirname directly from the filename argument rather than reading it from the module object.
-
🟡
src/js/builtins/CommonJS.ts:443-449— The longest-match extension loop inmodulePrototypeLoaddoes not skip leading-dot position (index 0), diverging from Node.js for dotfiles. For a file named.test.js, Bun checksModule._extensions['.test.js']first (if user-registered), while Node.js'sfindLongestRegisteredExtensionexplicitly skips index 0 and only tries.js. The fix is to add a guard skippingstartDot === 0at the top of the while loop body.Extended reasoning...
What the bug is and how it manifests
The while loop in
modulePrototypeLoad(CommonJS.ts, the extension-lookup block) starts scanning fromstartDot = basename.indexOf('.')with no guard forstartDot === 0. For a dotfile like.test.js,basename.indexOf('.')returns 0, so the first loop iteration producessuffix = '.test.js'and checksextensions['.test.js']. Node.js'sfindLongestRegisteredExtensionhas an explicitif (index === 0) continueguard that skips position 0, meaning Node would never try.test.jsfor a dotfile — it would only see.js.The specific code path that triggers it
For basename
.test.js:- Bun:
startDot = 0, first iterationsuffix = '.test.js', checksextensions['.test.js'](fires if user-registered), then falls through to.js. - Node.js:
index = 0, skipped bycontinue, thenindex = 5,currentExtension = '.js'— only.jsis ever tried.
The PR's compound-extension test covers
foo.test.js(dot at position 3, not 0), so it passes correctly on both Bun and Node. The dotfile case (.test.js,.gitignore, etc.) is not exercised by any test.Why existing code doesn't prevent it
The loop condition
while (startDot \!== -1 && startDot \!== basename.length - 1)correctly avoids scanning a trailing dot but makes no distinction between a leading dot at position 0 and a legitimate compound-extension dot at a later position.What the impact would be
This is a narrow edge case:
Module.prototype.loadis never called on dotfiles in real codebases. However, if a user registersModule._extensions['.test.js']and calls.load()on a file literally named.test.js, Bun dispatches to their handler while Node.js silently falls back to the.jshandler. Conversely, for.gitignore(single dot at position 0), Bun tries.gitignoreas an extension while Node skips it entirely.Step-by-step proof (dotfile
.test.js)Module._extensions['.test.js'] = myHandleris registered.new Module('.test.js', null)is constructed.m.load('.test.js')is called;modulePrototypeLoadruns.basename = '.test.js',startDot = basename.indexOf('.') = 0.- First iteration:
suffix = '.test.js',handler = extensions['.test.js'] = myHandler— fires. - On Node.js: index 0 is skipped by the
continueguard; only.jsis tried;myHandleris never called.
How to fix it
Add a leading-dot guard at the top of the while loop body:
while (startDot \!== -1 && startDot \!== basename.length - 1) { if (startDot === 0) { startDot = basename.indexOf(".", 1); continue; } // skip leading dot like Node.js const suffix = basename.slice(startDot); handler = extensions[suffix]; if (handler) break; startDot = basename.indexOf(".", startDot + 1); }
This matches Node's
findLongestRegisteredExtensionsemantics exactly. - Bun:
A stale git index from an earlier rebase dance caused f344c1f to accidentally remove the fix. Putting the modulePrototypeLoad builtin and the Module.prototype.load registration back.
Two small Node-compat fixes:
- modulePrototypeLoad now sets this.path = dirname(filename) alongside
this.filename. The .js extension goes through evaluate(), which reads
this.m_dirname for __dirname. Without this, loading a file from a
path different from the constructor id produced stale __dirname.
- getModulePrototypeObject (the disposable object backing
require('module').prototype) now exposes load as a builtin, so
typeof require('module').prototype.load === 'function' matches Node.
The instance prototype still has its own binding via JSCommonJSModule.
- modulePrototypeLoad's longest-match loop now skips startDot === 0, matching Node's findLongestRegisteredExtension and Bun's native Zig equivalent. Without this, a registered Module._extensions['.gitignore'] handler would fire for a file literally named .gitignore. - Test header comment still claimed the PR unifies the two module prototypes; it doesn't. Rewrote it to describe the actual shape: both prototypes now expose load independently, but they're still distinct objects (unification was reverted in 1bf6b8b to preserve _compile CustomAccessor semantics).
Previously only asserted that instances inherit load(). This adds
an explicit check that `typeof require('module').prototype.load === 'function'`
so a future revert of the getModulePrototypeObject registration is
caught by this test, not just by the instance-prototype path.
Without `$overriddenName`, the builtin's .name was inferred from
the source identifier ("modulePrototypeLoad"). Matches overridableRequire's
`$overriddenName = "require"` annotation.
The fields test used to pass the same path as both the Module constructor id and the load argument. That meant the C++ ctor's m_dirname init already set this.path correctly, so removing the this.path = dirname assignment from modulePrototypeLoad would leave all tests green while silently breaking __dirname when the ids differ. New fixture: constructor is given a stub id, load() is called with a file in a different subdirectory, and the loaded file's __dirname is asserted against dirname(target).
There was a problem hiding this comment.
LGTM — all functional issues from prior review rounds have been addressed.
Extended reasoning...
Overview
Adds Module.prototype.load to fix a crash in packages like requizzle (a jsdoc dependency) that construct new Module(...) and call .load() on it. Touches JSCommonJSModule.cpp (prototype registration), NodeModuleModule.cpp (disposable prototype), CommonJS.ts (TypeScript builtin implementation), and a regression test file.
Security risks
None. This is a Node.js API compatibility fix adding a method to the module prototype. No auth, crypto, or permission-sensitive paths are involved.
Level of scrutiny
Moderate complexity but well-scoped. The PR went through multiple review rounds and addressed all raised issues: unused imports, dead code, prototype unification revert (to preserve _compile monkey-patching), try/catch for loaded state reset on failure, assert error type matching, compound extension lookup with leading-dot guard, concurrent tests, stdout-before-exitCode assertion ordering, and coverage for both the instance prototype and the disposable Module.prototype object.
Other factors
The remaining inline nit (missing .name assertion for the $overriddenName = "load" annotation) is a test coverage gap for an already-correct implementation detail — the annotation is confirmed present at CommonJS.ts:422. The inline comment serves as a prompt to tighten coverage in a follow-up.
Without the `$overriddenName` annotation, JSC would derive the
name from the source identifier ("modulePrototypeLoad"). Pin
`.name === 'load'` on both the instance and Module.prototype so
a future refactor can't silently regress it.
One ASAN shard has been flaking at the default 5s — each test spawns a subprocess bun that imports node:module + node:path and runs a synthetic load(), which routinely takes 2.5-3s just for startup on debug+ASAN. Under concurrent execution on the same shard, an overloaded container can blow the 5s budget.
Closes #29253
Repro
Cause
Two related gaps:
require('module').prototypereturned a disposable object with onlyrequireand_compileon it. Instances created vianew Module(...)were backed byJSCommonJSModulePrototype— a different object. Patching one never affected the other, andModule.prototype.loaddid not exist.Neither prototype had
load. Packages likerequizzle(ajsdocdependency) construct a module by hand and calltargetModule.load(targetModule.id)— the same pattern Node's cjs loader uses internally — so the whole jsdoc CLI crashed on launch.Fix
src/bun.js/modules/NodeModuleModule.cpp—getModulePrototypeObjectnow returnsCommonJSModuleObjectStructure()->storedPrototypeObject(), sorequire('module').prototype === Object.getPrototypeOf(new Module(...)). Patching the constructor prototype is now visible on every instance (Node semantics).src/js/builtins/CommonJS.ts— addedmodulePrototypeLoad(mirrors Node'sModule.prototype.load): setsfilename+paths, dispatches toModule._extensions[ext], marksloaded. Default.jsextension reads the file and callsmodule._compile, which honors the currentModule.wrapper.src/bun.js/bindings/JSCommonJSModule.cpp—JSCommonJSModulePrototype::finishCreationnow puts the builtinloadon the prototype.Verification
bun bd test test/regression/issue/29253.test.tsWithout the fix, all 5 tests fail with
m.load is not a function. Existing module tests (test/js/node/module/, 59 tests) still pass — in particular "Overwriting Module.prototype.require", which exercises the prototype-unification change.