Skip to content

bundler: bundle require() calls made through createRequire(import.meta.url) bindings - #36565

Closed
robobun wants to merge 3 commits into
mainfrom
farm/d88f0c71/bundle-create-require
Closed

robobun wants to merge 3 commits into
mainfrom
farm/d88f0c71/bundle-create-require

Conversation

@robobun

@robobun robobun commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #36564

Problem

  • A dependency that builds its own require in ESM (css-tree, which playwright pulls in) loads JSON through const require = createRequire(import.meta.url); require('../data/patch.json'). The bundler copied the call through verbatim. In the output, import.meta.url points at the bundle, so the lookup fails once the bundle moves away from node_modules: error: Cannot find module '../data/patch.json' from '/path/to/output/index.js'.
  • The build succeeds with no warning. --reject-unresolved does not fire either, because the callee is a user binding and never gets classified as require.

Fix

  • process_import_statement (src/js_parser/p.rs) records the local bindings of createRequire imported from module/node:module when bundling.
  • visit_decls (src/js_parser/visit/mod.rs) records const bindings initialized with createRequire(import.meta.url | import.meta.filename). The argument shape is checked before the visit, because CJS output inlines import.meta.url to a string during it. The call is marked unwrappable-if-unused so the binding and its import tree-shake once every call is bundled.
  • e_call (src/js_parser/visit/visit_expr.rs) routes calls on those bindings through the same paths as require(): string literals go to transpose_require, ternaries split into two requires, req.resolve("...") goes to transpose_require_resolve_known_string, and the forced constant folding that require() gets applies too. Dynamic arguments stay runtime calls on the binding.
  • Verified: seven new tests in test/bundler/bundler_edgecase.test.ts (entry in /src, output at /out.js, so an untraced lookup cannot resolve by accident). Six fail on bun 1.4.3 with Cannot find module. Also ran bundler_edgecase, bundler_cjs2esm, bundler_bun, bundler_minify, bundler_npm, esbuild/default, and compile/Bun.embeddedFiles.

Background

  • require("x") in the parser: the require identifier becomes ERequireCallTarget, and e_call turns a string-literal call into E::RequireString with an import record. That import record is what makes the bundler follow the file.
  • createRequire(import.meta.url) returns a function that resolves relative to the file whose URL it was given. Inside a bundle that file is the source module, so the static calls mean the same thing as require() from that module.
  • Considered a build-time warning instead of tracing: it leaves the user with no way to fix a dependency they do not own. Considered matching only EString arguments: that diverged from require() for req('./a' + '.json'), which only bundled under --minify-syntax. The fix reuses the require() transpose helpers so the two paths cannot drift.

Downsides

  • Output changes for any bundle that has this pattern today: the JSON or module is now inlined instead of read from disk at runtime. A user who relied on editing that file after the build, with the bundle left next to node_modules, sees the build-time copy instead.
  • Per call cost for files without createRequire: one is_empty() check on a hash map in e_call and one in visit_decls. No allocation, no import record.
  • Non-const bindings (let req = createRequire(...)) and bindings created inside functions after a call site in a hoisted function are still left as runtime lookups.
Notes

…a.url) bindings

When an ESM module creates its own require with
const req = createRequire(import.meta.url), calls like req("./data.json")
resolve relative to that source file, but the bundler copied them through
verbatim. In the output, import.meta.url points at the bundle, so the
lookup fails at runtime once the bundle is moved away from node_modules
(css-tree inside playwright hits this with ../data/patch.json).

Track createRequire imported from "module"/"node:module" at parse time,
record const bindings initialized with createRequire(import.meta.url)
during the visit pass, and rewrite calls on those bindings with a static
string argument through the same transpose_require path as require().
Dynamic arguments and non-const bindings are left untouched.

Fixes #36564
@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 29 seconds

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: b32e0844-6635-4867-a6ea-8ddccd38f338

📥 Commits

Reviewing files that changed from the base of the PR and between b7fef25 and ed96eec.

📒 Files selected for processing (4)
  • src/js_parser/p.rs
  • src/js_parser/visit/mod.rs
  • src/js_parser/visit/visit_expr.rs
  • test/bundler/bundler_edgecase.test.ts

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

@robobun

robobun commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:39 AM PT - Jul 31st, 2026

❌ @robobun, your commit ed96eec has some failures in Build #86335 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 36565

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

bun-36565 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. [css-tree] error: Cannot find module "../data/patch.json" from "undefined" (may be createRequire bug) #13076 - css-tree createRequire(import.meta.url) followed by require("../data/patch.json") fails with "Cannot find module" because the bundler doesn't resolve the require call
  2. [css-tree] error: Cannot find module "../data/patch.json" from "undefined" (may be createRequire bug) claude trigger #30546 - Duplicate of [css-tree] error: Cannot find module "../data/patch.json" from "undefined" (may be createRequire bug) #13076, same createRequire(import.meta.url) + require() pattern failing in bundled output

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

Fixes #13076
Fixes #30546

🤖 Generated with Claude Code

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Those two are related but not fixed by this PR: #13076 and #30546 hit the same css-tree file at runtime (under bunx --bun with tsx overriding Module._resolveFilename, the error is "from undefined"), with no bundler involved. This change only affects bun build, so I'm not adding the Fixes lines.

Comment thread src/js_parser/visit/mod.rs Resolved
…meta.url

With CJS output format or Bake, the visit pass inlines import.meta.url to
a string literal, so checking the argument after visiting never matched
and createRequire bindings went untracked. Capture the argument shape
before visiting; the callee ref is still checked after, since refs are
only resolved during the visit.
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/visit/mod.rs Resolved
Comment thread src/js_parser/visit/mod.rs Resolved
Comment thread src/js_parser/visit/visit_expr.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/visit/mod.rs Resolved
Comment thread src/js_parser/visit/visit_expr.rs Resolved

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

The CJS-format ordering issue I flagged earlier is fixed in b56382f (arg shape captured pre-visit, callee ref checked post-visit) and locked in by the new CreateRequireImportMetaUrlCjsFormat test. No further issues found, but this is a new bundler semantic in the parser visit pass, so worth a maintainer look.

What was reviewed:

  • Verified the pre-visit/post-visit split in visit_decls now survives maybe_rewrite_property_access inlining under --format=cjs.
  • Checked the e_call hook sits after arg/target visiting and mirrors the adjacent ERequireCallTarget → transpose_require path; only EIdentifier targets with a single EString arg match, so req.resolve(...) and dynamic args fall through unchanged.
  • Confirmed the const-only gate plus per-symbol Ref matching prevents shadowed/reassigned bindings from being picked up.
Extended reasoning...

Overview

Adds bundler support for the const req = createRequire(import.meta.url); req("./x") pattern (used by css-tree). Two new Ref/RefMap fields on P, a parse-time hook recording the createRequire import binding, a visit_decls hook recording qualifying const bindings, and an e_call hook routing static-string calls on those bindings through the existing transpose_require path. Four new itBundled tests.

Security risks

None. Bundler-only, no runtime API surface, no untrusted-input parsing beyond what the JS parser already does.

Level of scrutiny

Moderate-high. visit_decls and e_call are hot, subtle parser visit-pass code where ordering matters (my earlier review caught an ordering bug here). The change is a new bundler semantic — deciding to statically bundle something that was previously a runtime call — so a maintainer should sanity-check that transpose_require is the right entry point and that this matches esbuild's behavior for the same pattern.

Other factors

  • My previous review found a real ordering bug (arg shape checked post-visit, defeated by CJS-format inlining); it was fixed exactly as suggested and a regression test added. I re-traced the fix and it's correct.
  • The change is fully additive and gated: only fires when bundling, only for const bindings, only for single-EString args, only when the callee ref matches the recorded createRequire import. All other shapes fall through to existing behavior.
  • ignore_usage(target_ref) on the transformed call mirrors how the regular require path handles usage counting.
  • Tests place the entry in /src with output at /out.js so an unbundled runtime lookup can't accidentally resolve — good fails-for-the-right-reason design. Coverage: ESM, CJS format, aliased import from node:module with import.meta.filename, and dynamic-arg fallthrough.
  • Minor limitation (not blocking): a second import { createRequire as x } from ... overwrites create_require_ref, and calls inside a hoisted function declared before the const won't be picked up. Both are edge cases with no regression from current behavior.
  • The comment-cop threads are all resolved; remaining comments are short field docs and one ordering note that earned its keep.

Bewinxed added a commit to Bewinxed/cawco that referenced this pull request Oct 19, 2026
… fixed at build instead of read from /$bunfs

@achingbrain/ssdp (pulled in by nat-port-mapper) reads its own package.json
through createRequire(import.meta.url); Bun's bundler leaves that require to
run time (oven-sh/bun#36565), where it resolves against /$bunfs and kills
`cawco up` at start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Closing this PR because it has been inactive for more than 90 days.

@robobun

robobun commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main and reopened as #47423 (GitHub does not allow reopening a PR after a force-push). The new PR also covers the review findings from here: multiple createRequire imports in one file, constant folding and ternary arguments, req.resolve, and tree-shaking of the dead binding.

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.

bun build: --loader:.json=inline fails to embed asset and throws Cannot find module at runtime when dependency uses createRequire

1 participant