Skip to content

Keep require() of node builtins as written on both runtime transpile paths - #39316

Open
robobun wants to merge 3 commits into
mainfrom
farm/94f9fd94/require-builtin-specifier-concurrent-transpiler
Open

robobun wants to merge 3 commits into
mainfrom
farm/94f9fd94/require-builtin-specifier-concurrent-transpiler

Conversation

@robobun

@robobun robobun commented Aug 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • require.resolve("fs") returns "node:fs" in a file loaded with import / import(), and "fs" in the same file loaded with require(). Node returns "fs" (and "node:fs" only for require.resolve("node:fs")).
  • Same rewrite, more symptoms in files loaded with import: require("fs") ignores a require.cache["fs"] entry (node and bun's require() path honor it), a Module._resolveFilename override sees "node:fs" instead of "fs", and require("internal/cluster/round_robin_handle") (vendored node tests, --expose-internals) throws Cannot find module 'internal:cluster/RoundRobinHandle'.
  • For files of 4 KiB or more the result also depends on history: both paths share the on-disk transpiler cache, so after a run that loaded such a file with import, a later run that require()s it replays the rewritten output and returns "node:fs" too (probe in the details below).
  • Cause: the runtime has two copies of the import-record pre-pass that maps specifiers through the builtin alias table, and they disagree.
    • The JS-thread copy (Linker::link, src/bundler/linker.rs, skip added in module pr 2 #18266, bun 1.2.6) leaves require() / require.resolve() records of builtins as written. The runtime resolver (VirtualMachine::resolve_maybe_needs_trailing_slash, src/jsc/VirtualMachine.rs) then maps the written name to the module, returns the written name itself for require.resolve of a node builtin, and overridableRequire (src/js/builtins/CommonJS.ts) looks up require.cache under the written name.
    • The concurrent transpiler's copy (TranspilerJob::run, src/jsc/RuntimeTranspilerStore.rs) never got that skip. It prints the table's path in place of the name, which defeats all of the above, and for internal/cluster/round_robin_handle prints a path (internal:cluster/RoundRobinHandle) the resolver does not accept at all.
  • The JS-thread copy has a defect of its own: since Eliminates special handling for bun:test in the transpiler #22888 (bun 1.3.1) moved the @jest/globals / vitest aliases into the same table, its skip leaves require("@jest/globals") as written too, and the runtime resolver looked the name up without the test-mode entries. Under bun test that require() fails with Cannot find module '@jest/globals' in the test file itself and in any require()d file (@jest/globals not rewritten to bun-test when using CommonJS require to import #4466 again); it only worked in files loaded with import, where the store rewrote it.

Fix

  • One shared function, Alias::rewrite_import_record (src/resolve_builtins/HardcodedModule.rs), replaces both loop bodies; the linker's private adapter module over Alias::get is deleted. The rule is the linker's existing one: a require() / require.resolve() record of any Tag::Builtin entry stays as written, everything else (import, import(), and "bun" itself, which the printer inlines as globalThis.Bun) is rewritten as before.
    • Correct because the resolver accepts the names in the table and not necessarily the paths, and because node's require.resolve / require.cache / _resolveFilename behavior is defined in terms of the written name. Leaving the record alone hands the resolver exactly what it handles; this is the output every entry point and every require()d file has had since 1.2.6.
    • Both paths now print a given file identically, which the shared transpiler cache requires anyway.
  • VirtualMachine::_resolve and resolve_maybe_needs_trailing_slash look the name up with the VM's rewrite_jest_for_tests (builtin_alias_cfg()), so under bun test a require() of @jest/globals / vitest resolves to bun:test on both paths, the same way import of them already did. Outside bun test the flag is false and nothing changes.
  • Runtime transpiler cache version 25 -> 26, so entries the concurrent transpiler wrote with rewritten specifiers are not replayed (same reason as version 17, the previous change to this rewrite).
  • Verified:
    • test/js/node/module/node-module-module.test.js, new require() of node builtins keeps the written specifier on every transpile path: one probe file loaded three ways (require(), static import, import()) reports require.resolve of fs / node:fs / sys, whether require.cache["fs"] is honored for fs and not for node:fs, and what a _resolveFilename override sees; expected values are what node prints for the same file. Run with the concurrent transpiler on and off. Fails on the released binary for the two import loads.
    • test/js/bun/resolve/require.test.ts, new require() resolves the alias on both transpile paths: internal/cluster/round_robin_handle (the entry whose path is not a resolvable name) and ffi required from a require()d and an import()ed file. Fails on the released binary for the import()ed file; also the case that rejects skipping only node builtins, which a first version of this change did and test/js/node/test/parallel/test-cluster-accept-fail.js caught in CI (that test passes again).
    • test/js/bun/test/bun_test.test.ts, new require() of @jest/globals and vitest resolves to bun:test on every transpile path: the test file, a require()d helper and an import()ed helper each require() both aliases and require.resolve one. Fails on the released binary for the first two (MODULE_NOT_FOUND); the import() case pins the behavior that already worked.
    • Still passing locally: the rest of those files, test/js/node/module/*, test/js/bun/resolve/{import-meta,import-meta-resolve,resolve,resolve-error,builtin-esm-lazy-exports}, test/js/node/missing-module.test.js, test/cli/run/{transpiler-cache,require-and-import-trailing}.test.ts, test/regression/issue/{25707,09739}.test.ts, test/js/first_party/undici/undici-primordials.test.ts, test/js/node/test_runner/node-test.test.ts, test/js/bun/test/{fake-timers,test-auto-import-jest-globals,jest-extended,expect-extend,mock/mock-module}, test/js/node/test/parallel/{test-require-node-prefix,test-stream-iter-disabled,test-cluster-accept-fail}.js, and the test/internal/source-lints dead-export / byte-search lints. The RSS leak tests in test/cli/run/require-cache.test.ts exceed their time and memory budgets on the local debug ASAN build; the files they reload in a loop contain no import records, so this pass never runs in them.
  • Resolve bare undici to the installed package, keep the shim as fallback #36102 edits the two old loop bodies separately (it predates transpiler cache version 24); after a rebase its change is one condition inside rewrite_import_record.

Background

  • Runtime transpile paths: the entry point, every file reached through require(), and everything when a plugin is registered are transpiled on the JS thread (transpile_source_code -> Linker::link). Files reached through import statements or import() are transpiled on a thread pool by RuntimeTranspilerStore (BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1 turns that off, which is how the tests run a case on both paths). Each path runs its own pre-pass over the file's import records before printing.
  • Import record: the parser's entry for each import, import(), require() or require.resolve() in a file; kind says which (is_common_js() is require or require.resolve) and the printer emits path.text as the specifier, so rewriting it here changes the string the runtime later receives.
  • Builtin alias table (src/resolve_builtins/HardcodedModule.rs): maps names to built-in module paths, e.g. fs -> node:fs, sys -> node:util, ffi -> bun:ffi, internal/cluster/round_robin_handle -> internal:cluster/RoundRobinHandle, and, only when Cfg::rewrite_jest_for_tests is set (bun test), @jest/globals / vitest -> bun:test. Resolution (Alias::get) is keyed by name; the fetch step that serves the module is keyed by path.
  • Runtime transpiler cache: on-disk cache of printed output for source files of 4 KiB or more, keyed by source hash plus parser options, written and read by both transpile paths; EXPECTED_VERSION is bumped whenever the output for unchanged input changes.
  • src/runtime/jsc_hooks.rs also contains a resolve_hook with the same alias lookup; it is installed in LoaderHooks but nothing dispatches that field, so it is left alone here.
Shared-cache probe on the released binary (1.4.0)

big.cjs is module.exports = require.resolve("fs"); padded past 4 KiB; BUN_RUNTIME_TRANSPILER_CACHE_PATH points at an empty directory.

$ bun via-import.js     # await import("./big.cjs")
import(): node:fs
$ bun via-require.js    # require("./big.cjs"), cache hit
require(): node:fs

# fresh cache directory, reversed order
$ bun via-require.js
require(): fs
$ bun via-import.js
import(): fs
Superseded first version

The first commit skipped the rewrite only for entries with node_builtin set and rewrote the remaining require() records on both paths, on the theory that the resolver maps every other entry's path back to the same module. That holds for every entry except internal/cluster/round_robin_handle, whose path is not a name the resolver accepts, so it broke test-cluster-accept-fail.js on the JS-thread path. The current version keeps the linker's Tag::Builtin rule instead and fixes the jest aliases in the resolver, which is also where a require() of them has to be handled for files the store transpiles.

… concurrent transpiler

require() and require.resolve() of a node builtin keep the specifier as
written on both transpile paths, so require.resolve("fs") returns "fs",
require.cache["fs"] is honored and Module._resolveFilename overrides see
"fs" in files loaded with import as well as with require(). The other
aliases (ws, undici, bun:*, and @jest/globals / vitest under bun test) are
rewritten for require() on both paths, which makes require("@jest/globals")
work in files transpiled on the JS thread again.

Bumps the runtime transpiler cache version: entries written by the
concurrent transpiler carry the rewritten node: specifier and were replayed
on both paths.
@robobun

robobun commented Aug 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on the released 1.4.0 binary and on main; fix and tests pushed (current revision: require() of every builtin stays as written on both transpile paths, jest aliases applied by the resolver under bun test); waiting on CI.

Reproduced with:

  • test/js/node/module/node-module-module.test.js (require() of node builtins keeps the written specifier on every transpile path): the two import loads return node:fs / node:util, skip require.cache["fs"], and show node:os to a _resolveFilename override.
  • test/js/bun/resolve/require.test.ts (require() resolves the alias on both transpile paths): the import()ed file throws Cannot find module 'internal:cluster/RoundRobinHandle'.
  • test/js/bun/test/bun_test.test.ts (require() of @jest/globals and vitest resolves to bun:test on every transpile path): MODULE_NOT_FOUND in the test file and the require()d helper.

All three pass with this branch, as does test/js/node/test/parallel/test-cluster-accept-fail.js, which the first revision of this PR broke; see the PR description for the other suites run.

@coderabbitai

coderabbitai Bot commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c42e323d-a181-4a88-a7fe-3afe5edf1914

📥 Commits

Reviewing files that changed from the base of the PR and between 23d535a and 3cde0cd.

📒 Files selected for processing (6)
  • src/bundler/linker.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • src/resolve_builtins/HardcodedModule.rs
  • test/js/bun/test/bun_test.test.ts
  • test/js/node/module/node-module-module.test.js

Included review availability: Your plan includes up to 5 reviews per rolling hour; 0 remain after this review.


Walkthrough

Changes

The PR centralizes hardcoded alias rewriting for the linker and runtime transpiler. It preserves written Node builtin specifiers, updates the transpiler cache format, and adds integration coverage for aliases and builtin resolution.

Hardcoded alias rewrite

Layer / File(s) Summary
Centralized import-record rewriting
src/resolve_builtins/HardcodedModule.rs, src/bundler/linker.rs, src/jsc/RuntimeTranspilerStore.rs
Alias::rewrite_import_record handles alias lookup, Node builtin preservation, path and tag updates, and external side-effect flags. The linker and runtime transpiler use this method.
Transpiler cache compatibility
src/jsc/RuntimeTranspilerCache.rs
The cache format version increases to 26 for preserved Node builtin specifiers.
Alias and builtin integration coverage
test/js/bun/test/bun_test.test.ts, test/js/node/module/node-module-module.test.js
Tests cover Jest and Vitest aliases, builtin specifiers, require.resolve, _resolveFilename, require.cache, and concurrent transpiler modes.

Possibly related PRs

  • oven-sh/bun#36102: Related hardcoded alias rewriting changes preserve specifiers during deferred resolution.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary change: preserving written Node builtin specifiers across both runtime transpilation paths.
Description check ✅ Passed The description explains the problem, fix, implementation details, verification steps, and test coverage, although it uses headings different from the template.

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

Comment thread src/resolve_builtins/HardcodedModule.rs
@robobun

robobun commented Aug 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:06 AM PT - Aug 16th, 2026

✅ @robobun, your commit cb5c6dc62bcfbf6986591741f7f2607f547f7dad passed in Build #99234! 🎉


🧪   To try this PR locally:

bunx bun-pr 39316

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

bun-39316 --bun

…liases when resolving at runtime

The runtime resolves an alias by the name in the table, not by the path it
maps to: internal/cluster/round_robin_handle maps to
internal:cluster/RoundRobinHandle, which does not resolve, so rewriting
require() records of non-node entries broke test-cluster-accept-fail.js.
Keep every Tag::Builtin require() as written (what the JS-thread linker
always did) and make VirtualMachine's resolve apply the @jest/globals /
vitest entries under bun test, which is what lets require() of those work
on both transpile paths.
Comment thread src/jsc/RuntimeTranspilerCache.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/resolve_builtins/HardcodedModule.rs Outdated
Comment thread src/jsc/RuntimeTranspilerCache.rs
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/resolve_builtins/HardcodedModule.rs
Comment thread src/jsc/VirtualMachine.rs Outdated
Jarred-Sumner added a commit that referenced this pull request Aug 18, 2026
…s for dead fields (#39327)

### Problem
- `LoaderHooks` (src/jsc/ModuleLoader.rs) has a `resolve` field that
nothing reads.
- Its only dispatch site,
`module_loader::resolve_maybe_needs_trailing_slash`, was deleted in
#35002; the field and the body it pointed at survived.
- The body installed for it, `resolve_hook` plus its inner `resolve` in
src/runtime/jsc_hooks.rs (about 470 lines), is a second copy of the live
resolver, `VirtualMachine::resolve_maybe_needs_trailing_slash` /
`_resolve` in src/jsc/VirtualMachine.rs. Every real caller uses the live
one: `Bun.resolveSync` and `Bun__resolveSync*`
(src/runtime/api/BunObject.rs), the ESM loader's
`Zig__GlobalObject__resolve` (src/jsc/JSGlobalObject.rs), and
`Module._findPath` (src/jsc/NodeModuleModule.rs).
- The two copies had already drifted and the dead one keeps attracting
patches: #37157 added the `data:` ENAMETOOLONG exemption to the live
copy only; #34097 and #35703 (open) patch both copies; #39316 was first
attempted against the dead copy, where it had no effect.
- rustc's `dead_code` lint cannot catch this shape: the field is `pub`,
and the body is referenced by the `__BUN_LOADER_HOOKS` static
initializer.

### Fix
- Delete the `resolve` field of `LoaderHooks`, `resolve_hook`, its inner
`resolve`, and the helpers only they used (`EVAL_SUFFIX` /
`STDIN_SUFFIX` / `normalize_specifier_for_resolution` /
`normalize_source` in jsc_hooks.rs), plus the `resolve: resolve_hook`
entry in the static.
- Delete `src/bundler_jsc/PluginRunner.rs`, a re-export module whose
only user was the dead body, and make the function it re-exported
(`plugin_runner_on_resolve_jsc`) `pub(crate)`, since its remaining
caller is in the same crate.
- No behavior change: the deleted code was unreachable, and the live
resolver in VirtualMachine.rs is untouched (the alias lookups there are
what #39316 changes).
- Test: `test/internal/source-lints/dispatch-hook-tables.test.ts` finds
every `safe static __BUN_*: XHooks;` table (`LoaderHooks`,
`RuntimeHooks`, `SqlRuntimeHooks`, 48 fields) and fails on any field
with no `(hooks.field)(..)` call site. On main it fails naming exactly
`LoaderHooks ... resolve`; with this diff it passes. It also covers the
next field that loses its dispatch site in any of the three tables.
- Call sites are attributed per table: only files that call the table's
accessor (`loader_hooks()`, `runtime_hooks()`, `hooks()`) or name its
struct in code count, and a site in a file that can reach two tables
sharing the field name counts for neither (so
`RuntimeHooks::timer_insert` cannot vouch for
`SqlRuntimeHooks::timer_insert`). Full-line `//` comments are blanked
before matching.
- Verified: `bun bd` builds; `bun bd test` on
test/js/bun/resolve/{resolve,resolve-error,require,import-query,import-meta,import-meta-resolve,bun-main-entry-point},
test/js/node/module/node-module-module.test.js,
module-resolve-filename-paths.test.js,
test/js/node/missing-module.test.js, and test/js/bun/plugin (220 + 44
pass); `bun test test/internal/source-lints/` (all 25 lints pass, the
existing dead-code inventories are unaffected); `cargo fmt --check`
clean.

### Background
- Crate tiers: `bun_jsc` (module loader, VM) sits below `bun_runtime`
(resolver plumbing, plugins, blob registry) in the crate graph, so
`bun_jsc` cannot call `bun_runtime` functions directly.
- Hook table: the way around that. The lower crate declares a struct of
fn pointers (`LoaderHooks`, `RuntimeHooks`, `SqlRuntimeHooks`) and an
`extern "Rust" { safe static __BUN_*_HOOKS: ... }`; `bun_runtime`
defines that `#[no_mangle]` static with one body per field; the lower
crate calls a field as `(hooks.field)(..)`. A field is live only if such
a call exists somewhere.
- The live resolver can sit in `bun_jsc` because the two `bun_runtime`
pieces it needs are already reachable from there: the blob URL registry
through `RuntimeHooks::has_blob_url`, and the plugin `onResolve` runner
through `plugin_runner_on_resolve_jsc`, which was moved down into
`bun_jsc`. That leaves nothing for a `LoaderHooks::resolve` route to do.

<details>
<summary>Evidence the field is dead</summary>

```
$ grep -rn "hooks\.resolve)" src      # no dispatch site
$ grep -rn "loader_hooks()" src       # all in src/jsc/ModuleLoader.rs: transpile_source_code,
                                      # fetch_builtin_module, transpile_file, get_hardcoded_module,
                                      # resolve_embedded_node_file, transpile_virtual_module
$ git log --oneline -S "(hooks.resolve)" -- src
85ddc95 Remove ~39k lines of dead Rust across the workspace (#35002)
23427db Rewrite Bun in Rust (#30412)
```

Lint output with main's versions of the five src files checked out:

```
error: LoaderHooks (src/jsc/ModuleLoader.rs) has hook fields that nothing dispatches:
  resolve
(fail) every field of LoaderHooks is dispatched
(pass) every field of RuntimeHooks is dispatched
(pass) every field of SqlRuntimeHooks is dispatched
```

Not changed here: `bun_bundler_jsc`'s Cargo.toml still lists
`bun_resolver`, which this crate no longer uses; its manifest already
lists a dozen other crates its sources do not reference, so that is left
for a separate manifest cleanup. `VirtualMachine.entry_point` in bun_jsc
(read only by the `bun:main` branch of `_resolve`, which the alias table
short-circuits before it is reached) looks similarly vestigial but sits
in the function #39316 edits, so it is also left alone.
</details>

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>

This branch has not been deployed

No deployments
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