Skip to content

require(esm): throw ERR_REQUIRE_ASYNC_MODULE instead of a bare TypeError - #33438

Closed
robobun wants to merge 2 commits into
mainfrom
farm/bda9213f/require-async-module-error-code
Closed

robobun wants to merge 2 commits into
mainfrom
farm/bda9213f/require-async-module-error-code

Conversation

@robobun

@robobun robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

require() of an ES module whose graph contains top-level await throws a bare TypeError with no .code:

// tla.mjs
await new Promise(r => setTimeout(r, 1));
export const v = 1;

// app.cjs
try { require("./tla.mjs"); }
catch (e) { console.log({ name: e.constructor.name, code: e.code ?? null }); }
node: { name: 'Error',     code: 'ERR_REQUIRE_ASYNC_MODULE' }
bun : { name: 'TypeError', code: null }

Node documents ERR_REQUIRE_ASYNC_MODULE as the signal for "this module needs import()", and the ecosystem's require(esm) migration path is try { require(f) } catch (e) { if (e.code === "ERR_REQUIRE_ASYNC_MODULE") return import(f) }. On Bun that branch never matched, so loaders and shims rethrow instead of falling back.

Cause

Three sites throw the same require() async module "..." is unsupported message, and all three used throwTypeError / throwVMTypeError directly, which attaches no code:

  • functionEsmLoadSync in ZigGlobalObject.cpp — the require(esm) path, reached whenever the synchronous module-loader drain leaves the load promise pending (top-level await anywhere in the graph).
  • Two copies in fetchCommonJSModule in ModuleLoader.cpp — require() of a virtual module whose plugin onLoad is async.

ERR_REQUIRE_ASYNC_MODULE was already declared in src/jsc/bindings/ErrorCode.ts (as an Error, matching Node) but nothing ever used it.

Fix

A single Bun::throwRequireAsyncModuleError helper routes all three sites through Bun::throwError(..., ErrorCode::ERR_REQUIRE_ASYNC_MODULE, ...). The thrown value is now an Error with code === "ERR_REQUIRE_ASYNC_MODULE", matching Node's class and code.

The message text is unchanged. Node's wording points at --experimental-print-required-tla, a flag Bun does not implement, and Bun's message already names the offending module, so copying it verbatim would be worse. Only the error class and .code change.

Note this is a behavior change for anyone matching e instanceof TypeError on this throw: it is now Error, as in Node. The one in-tree test doing that is updated to assert the code instead.

This is independent of #33249, which makes some TLA graphs load successfully under require(); graphs that genuinely need the event loop still throw, and now they throw the right thing.

Verification

$ bun bd test test/js/bun/resolve/require.test.ts \
             test/js/bun/resolve/require-esm-transitive-tla.test.ts \
             test/regression/issue/24387.test.ts \
             test/js/bun/plugin/plugins.test.ts
 49 pass, 0 fail

New tests in test/js/bun/resolve/require.test.ts (direct and transitive top-level await) and a strengthened assertion in test/js/bun/plugin/plugins.test.ts (async plugin module) fail on main with Expected: "ERR_REQUIRE_ASYNC_MODULE" / Received: undefined.

@coderabbitai

coderabbitai Bot commented Jul 6, 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: 19 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: 51b8717d-f909-4c7b-b549-2c4c7b3184c8

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and 0961d1a.

📒 Files selected for processing (6)
  • src/jsc/bindings/ModuleLoader.cpp
  • src/jsc/bindings/ModuleLoader.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • test/js/bun/plugin/plugins.test.ts
  • test/js/bun/resolve/require-esm-transitive-tla.test.ts
  • test/js/bun/resolve/require.test.ts

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

@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:30 AM PT - Jul 6th, 2026

❌ @robobun, your commit 0961d1a has 1 failures in Build #68960 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33438

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

bun-33438 --bun

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun 1.3.14 regression: require() async module error for jose via jwks-rsa/firebase-admin (works fine on 1.3.11) #33180 - jose/firebase-admin fails because the e.code === "ERR_REQUIRE_ASYNC_MODULE" fallback pattern doesn't trigger when Bun throws a bare TypeError with no .code property

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

Fixes #33180

🤖 Generated with Claude Code

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Not adding Fixes #33180 here. That issue is a false positive: jose's webapi entry has no top-level await, so require('jose') should return the namespace and Bun throws "require() async module" anyway when a concurrent static import of the same module is mid-flight. This PR does not change when that throw happens, only what it throws, so #33180 would still be broken with this merged.

The actual fix for #33180 is #33184, which corrects the pending-promise check in functionEsmLoadSync so the non-TLA case returns the namespace.

Worth noting the two are independent and compose: once #33184 stops the false positive, the remaining (correct) throws for real top-level-await graphs carry ERR_REQUIRE_ASYNC_MODULE from this PR, so the catch (e) { if (e.code === "ERR_REQUIRE_ASYNC_MODULE") return import(f) } fallback finally works.

@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.

LGTM — routes an existing error through the ErrorCode machinery so require(esm) on TLA throws with code: 'ERR_REQUIRE_ASYNC_MODULE', matching Node.

Extended reasoning...

Overview

This PR replaces three throwTypeError/throwVMTypeError call sites (two in fetchCommonJSModule in ModuleLoader.cpp, one in functionEsmLoadSync in ZigGlobalObject.cpp) with a single new helper Bun::throwRequireAsyncModuleError that routes through Bun::throwError(..., ErrorCode::ERR_REQUIRE_ASYNC_MODULE, ...). The error code was already declared in ErrorCode.ts but unused. The message text is unchanged; only the error class (TypeError → Error) and the presence of .code change, aligning with Node's documented behavior. Tests are added in require.test.ts (direct and transitive TLA) and strengthened in plugins.test.ts (async virtual module), plus one existing test updated to assert .code instead of instanceof TypeError.

Security risks

None. This only changes which error constructor and .code property are attached to an already-thrown error on an existing failure path. No new inputs are parsed, no validation is loosened, no auth/crypto/permissions code is touched.

Level of scrutiny

Low. The native change is a mechanical 4-line helper wrapping the existing centralized Bun::throwError / ErrorCode machinery — exactly what the repo's guidance recommends over inline error construction. Each call-site replacement preserves the surrounding RELEASE_AND_RETURN / return control flow. ERR_REQUIRE_ASYNC_MODULE was already registered in ErrorCode.ts as an Error, so the generated code path is well-trodden.

Other factors

The PR includes new tests that assert the exact error class, .name, .code, and message contents for both direct and transitive TLA, plus the async-plugin path, and the description confirms they fail on main and pass on the branch. No CODEOWNERS cover the touched files, there are no outstanding human review comments, and the intentional TypeError → Error change is called out explicitly in the description as a Node-compat correction.

require() of an ES module whose graph cannot finish loading synchronously
threw a TypeError with no .code. Node throws an Error carrying
ERR_REQUIRE_ASYNC_MODULE, which loaders catch to fall back to import(),
so that detection branch never fired on Bun.

Route the three throw sites that share this message through the existing
(previously unused) ERR_REQUIRE_ASYNC_MODULE entry in ErrorCode.ts.
@robobun
robobun force-pushed the farm/bda9213f/require-async-module-error-code branch from 09d388c to be19d26 Compare July 6, 2026 11:18

@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.

LGTM — clean Node compat fix routing three existing throw sites through the already-declared ERR_REQUIRE_ASYNC_MODULE code.

Extended reasoning...

Overview

This PR swaps three throwTypeError/throwVMTypeError calls for a new Bun::throwRequireAsyncModuleError helper that routes through Bun::throwError(..., ErrorCode::ERR_REQUIRE_ASYNC_MODULE, ...). The error code was already declared in ErrorCode.ts (as Error, matching Node) but unused. The message text is unchanged; only the error class (Error instead of TypeError) and the .code property differ. Three test files are updated: two new test cases in require.test.ts for direct and transitive TLA, a strengthened assertion in plugins.test.ts for the async-plugin path, and one existing instanceof TypeError check in require-esm-transitive-tla.test.ts migrated to check .code instead.

Security risks

None. This is purely error-metadata plumbing for Node compatibility — no input parsing, no auth, no resource handling changes.

Level of scrutiny

Low-to-medium. The touched files (ModuleLoader.cpp, ZigGlobalObject.cpp) are core, but the change is mechanical: same message, same control flow (each site still throws then RELEASE_AND_RETURN), just a different error constructor. I verified ERR_REQUIRE_ASYNC_MODULE exists in ErrorCode.ts:220, that Bun::throwError's signature (ErrorCode.h:63) returns EncodedJSValue matching the ZigGlobalObject.cpp call site's return, and that ZigGlobalObject.cpp already includes ModuleLoader.h for the declaration. The two ModuleLoader.cpp sites discard the return value, which is fine since the exception lives on the scope.

Other factors

The PR description is thorough, correctly identifies all three sites, and explains why the message text isn't copied from Node. The one intentional behavior change (instanceof TypeError no longer matches) is called out and moves Bun closer to Node. Test coverage hits all three modified code paths. The bug-hunting system found nothing. No outstanding reviewer comments.

@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.

LGTM — clean Node compat fix routing three existing throw sites through the already-declared ERR_REQUIRE_ASYNC_MODULE code.

Extended reasoning...

Overview

This PR changes three throw sites in the require(esm) path to throw an Error with code === "ERR_REQUIRE_ASYNC_MODULE" instead of a bare TypeError with no .code, matching Node.js. It adds a small shared helper Bun::throwRequireAsyncModuleError in ModuleLoader.cpp/.h and calls it from the two fetchCommonJSModule pending-promise branches and the functionEsmLoadSync pending branch in ZigGlobalObject.cpp. The error code was already declared in ErrorCode.ts but unused. The message text is unchanged.

Test changes: two new tests in require.test.ts (direct and transitive TLA), a strengthened assertion in plugins.test.ts (async virtual module), and one existing test in require-esm-transitive-tla.test.ts updated from instanceof TypeError to check the error code — the only in-tree consumer of the old class.

I confirmed the three remaining throw new TypeError(...async module...) matches in src/js/builtins/CommonJS.ts are inside a block-commented-out legacy implementation, so all live sites are covered.

Security risks

None. This only changes the error prototype and attaches a .code property on an existing error path. No input handling, auth, or resource management is touched.

Level of scrutiny

Low. This is a mechanical Node-compat refactor using the established Bun::throwError(globalObject, scope, ErrorCode::X, ...) pattern already used throughout src/jsc/bindings/. The helper is a one-liner; each call site is a 1:1 swap with identical control flow (RELEASE_AND_RETURN after the throw is preserved). None of the touched files are covered by CODEOWNERS.

Other factors

The intentional behavior change (TypeError → Error) is called out in the PR description and is the point of the fix — ecosystem loaders branch on e.code === "ERR_REQUIRE_ASYNC_MODULE" to fall back to import(). Test coverage exercises all three modified sites and asserts class, name, code, and message content. No prior reviews or outstanding comments on the PR.

@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready to merge, CI red only on infra lanes

The diff is unchanged since the LGTM and passes locally. Build 68960 finished 272 passed / 2 failed / 12 expired, and neither failure executes any code from this change.

1. darwin 26 aarch64 - test-bun — dies before running a single test, at artifact download:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).
    at getExecPathFromBuildKite (scripts/runner.node.mjs:2182:13)

2. darwin 14 aarch64 - test-bun — test/js/web/websocket/autobahn.test.ts, whose docker service cannot start because an amd64 image is being run on an arm64 agent:

autobahn  The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8)
container bun-test-services-autobahn-1 exited (255)
autobahn-1  | exec /opt/pypy/bin/wstest: exec format error

Plus 12 jobs expired waiting for an agent. The darwin 26 aarch64 lane also fails intermittently on unrelated branches right now (builds 68816, 68814, 68811, 68805, 68793, 68790, 68788). An earlier rebase in this PR cleared the only other failure (cookie-map.test.ts, a stale assertion on main that #33425 fixed).

Verified locally against the current sha:

$ bun bd test test/js/bun/resolve/require.test.ts \
             test/js/bun/resolve/require-esm-transitive-tla.test.ts \
             test/regression/issue/24387.test.ts \
             test/js/bun/plugin/plugins.test.ts \
             test/js/bun/cookie/cookie-map.test.ts
 82 pass, 0 fail

The new tests also ran green on Windows shards in CI, which covers the require.resolve() path assertion.

I have used my one CI re-roll and won't push further retriggers. Handing off for a maintainer to merge or re-run the two darwin lanes.

@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Opened #33891 which includes this commit and additionally rejects TLA graphs that complete on microtasks alone (await Promise.resolve(0)), so Bun's accept/reject classification matches Node's for every TLA shape. If #33891 lands this one can be closed.

@robobun

robobun commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favour of #41788, which throws ERR_REQUIRE_ASYNC_MODULE for the same case and also detects the top-level await before any module body runs.

@robobun robobun closed this Sep 7, 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.

1 participant