Skip to content

Report unknown node: builtins in static imports with "No such built-in module" on every transpile path - #39246

Open
robobun wants to merge 5 commits into
mainfrom
farm/429a167d/unknown-node-builtin-link-message
Open

robobun wants to merge 5 commits into
mainfrom
farm/429a167d/unknown-node-builtin-link-message

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A file containing a static import "node:<unknown>" rejects with a different .message depending on which thread transpiled it:
    • require("./dep.ts") (and any other file transpiled on the JS thread: plugin onLoad sources, import() with BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1, the entry point): Could not resolve: "node:x". Maybe you need to "bun install"?
    • import("./dep.ts") (concurrent transpiler): No such built-in module: node:x
  • .code is ERR_UNKNOWN_BUILTIN_MODULE in both cases; only the text differs, and bun install cannot fix a node: specifier (Bun has no node:test/reporters module #24444 quotes this hint for a builtin Bun does not ship; that module is still missing after this PR, the hint is what changes).
  • Cause: Linker::link (src/bundler/linker.rs) routes every node: specifier that is not in the builtin table to when_module_not_found, which logged the generic bare-package text. The concurrent transpiler's copy of this pass has no such branch, so there the import reaches the runtime resolve hook, whose message (ResolveMessage::fmt in src/jsc/ResolveMessage.rs) already uses Node's text.

Fix

  • when_module_not_found has one caller, the node: branch inside link's if IS_BUN block (true since before the Rust port as well), so it becomes when_builtin_not_found and logs No such built-in module: <specifier>, the same text as the resolve hook. The parts only a different caller could reach are removed: the browser (--target="node" hint) and non-package message branches, the target parameter, the IS_BUN const generic (always true there) and the never-failing Result return. The handles_import_errors early-out and the deferral of require / require.resolve / import() to runtime are unchanged.
  • Correct because the message now matches Node's ERR_UNKNOWN_BUILTIN_MODULE text (which .code already claimed) and no longer depends on the transpile path. The linker path still attaches the import's source position, which the hook path does not have. .specifier / .code still work because they locate the specifier inside the message text, which contains it.
  • Scope is the text of this one branch. The two copies of the pre-pass have other behavioral differences that are not message text (a mock.module / build.module registered for a node: name is only honored on the concurrent path because this branch fails first; require.resolve("fs") returns "node:fs" on the concurrent path and "fs" on this one); those are filed separately, and whether this branch should eventually be removed in favor of the hook is decided there. This change is compatible with either outcome.
  • bun build is untouched: the bundler has its own copy of the bun install message in bundle_v2.rs, and test/bundler/bundler_browser.test.ts still pins it.
  • Verified with test/js/bun/resolve/resolve-error.test.ts (new static import of an unknown node: builtin block: require() vs import(), both again with the concurrent transpiler disabled, an export * from re-export, plugin onLoad source, and the entry point's stderr). All four fail on the released binary with exactly the old text and pass with this change; the rest of the file, test/js/node/missing-module.test.js, test/regression/issue/25707.test.ts, test/js/bun/resolve/import-meta.test.js, test/js/node/test/parallel/test-stream-iter-disabled.js, test-require-node-prefix.js and test/bundler/bundler_browser.test.ts still pass.
  • Found while working on ResolveMessage: report the module being transpiled as the referrer of transpile-time resolve errors #39182, which changes the referrer of these errors and does not touch their text.

Background

  • Runtime module loading transpiles each file either on the JS thread (require(), the entry point, plugin-provided sources, or everything when the concurrent transpiler is disabled) or on the concurrent transpiler (import() and static imports of files, src/jsc/RuntimeTranspilerStore.rs). Each path runs its own copy of a small pre-pass over the file's import records: both rewrite known builtins through the hardcoded alias table and strip bun:; only the JS-thread copy (Linker::link) additionally fails the module up front for node: names the table does not know. That early failure is what produced the text this PR changes.
  • The runtime resolve hook is what JSC calls when it links a module's imports at evaluation time. It is where an unknown node: import in a concurrently transpiled file fails, and it formats its errors with ResolveMessage::fmt.
  • ResolveMessage is the error object thrown on both paths; .code and .specifier are derived from the specifier's position inside the message text (BabyString), which is why the text must contain the specifier.

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/resolve/resolve-error.test.ts

…lt-in module" text

A static import of a node: specifier that is not a builtin is rejected in
Linker::link when the importing file is transpiled on the JS thread
(require(), plugin onLoad sources, the concurrent transpiler disabled), and
by the runtime resolve hook otherwise. The linker logged the generic
'Could not resolve: "node:x". Maybe you need to "bun install"?' while the
hook reports Node's 'No such built-in module: node:x', so the message
depended on which path transpiled the importer.

when_module_not_found is only ever reached for node: specifiers, so it
becomes when_builtin_not_found and logs the same text as the hook. The
browser and bare package branches it carried were unreachable from its
single caller.
@coderabbitai

coderabbitai Bot commented Aug 15, 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: 2 minutes

Limit details: You’ve used all 5 included reviews currently available under your plan.

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: c17d7911-3cce-4bc8-a454-7903fb968daa

📥 Commits

Reviewing files that changed from the base of the PR and between 1d230e2 and f8dedea.

📒 Files selected for processing (2)
  • src/bundler/linker.rs
  • test/js/bun/resolve/resolve-error.test.ts

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

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:54 PM PT - Aug 15th, 2026

@robobun, your commit f8dedea is building: #98851

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. CI is green on f8dedea and the review threads are resolved.

Reproduced on the released 1.4.0 binary with dep.ts containing import "node:this_builtin_does_not_exist": require("./dep.ts") rejects with Could not resolve: "node:this_builtin_does_not_exist". Maybe you need to "bun install"? while import("./dep2.ts") (same contents) rejects with No such built-in module: node:this_builtin_does_not_exist; with BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1 both print the bun install text, and so do plugin onLoad sources and the entry point. The same split shows up as the four new tests in test/js/bun/resolve/resolve-error.test.ts failing on the released binary (message text only) and passing with this branch.

Two other differences between the same two transpile paths turned up while checking this and are filed separately, since they are behavior rather than message text: virtual modules (mock.module / build.module) registered for a node: name are ignored on the JS-thread path, and require.resolve("fs") returns "node:fs" on the concurrent path. This PR is compatible with either way of resolving those.

@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 — the linker's node: not-found path now emits the same No such built-in module: <specifier> text as ResolveMessage::fmt, and the deleted browser/non-package branches were provably unreachable from the sole node: call site. One inline nit about the now-always-true IS_BUN const generic; not blocking.

Extended reasoning...

Overview

The PR renames Linker::when_module_not_found → when_builtin_not_found and replaces its three-branch Could not resolve: "…" message with the single No such built-in module: {specifier} string that ResolveMessage::fmt (src/jsc/ResolveMessage.rs:175) already produces for the concurrent-transpiler path. The target: BundleTarget parameter and Result<bool> wrapper are dropped since the browser/is_package_path arms could never be taken from the only call site (which is inside if strings::starts_with(path, b"node:") — always a package path, never target == Browser under the enclosing if IS_BUN). New tests in test/js/bun/resolve/resolve-error.test.ts cover require() vs import(), the DISABLE_ASYNC_TRANSPILER env, export * from, plugin onLoad sources, and the entry-point stderr snapshot.

Security risks

None. This is an error-message text change on a resolve-failure path; no new inputs are parsed, no allocation sizing changes, and the specifier is still formatted through bstr::BStr exactly as before.

Level of scrutiny

Low-to-medium. The functional surface is one format_args! string plus dead-branch removal in a private helper with a single caller. I confirmed:

  • when_builtin_not_found has exactly one call site (grep), inside the node: prefix guard, so the removed is_package_path/BundleTarget::Browser arms were dead.
  • add_resolve_error locates .specifier via BabyString::in(&text, specifier_arg) (src/ast/lib.rs:1650); the new message contains import_record.path.text verbatim, so .specifier and the .code derivation (which checks specifier.starts_with(b"node:") at ResolveMessage.rs:109/118) continue to work.
  • BundleTarget is still used by hardcoded_module::get, so no unused-import warning.
  • The handles_import_errors early-return and the defer-to-runtime branch for Require/RequireResolve/Dynamic are preserved unchanged.

Other factors

Tests follow harness conventions (tempDir, bunEnv spread, describe.concurrent, drained pipes via Promise.all, normalizeBunSnapshot), assert exact { code, specifier, importKind, message } shapes rather than substrings, and use one fixture file per load to avoid module-registry caching. The PR description lists the existing tests that were re-run (missing-module.test.js, bundler_browser.test.ts, etc.) and confirms bun build keeps its own message via bundle_v2.rs. The one inline nit — the now-vestigial <const IS_BUN: bool> generic — is a cleanup consistency point, not a correctness issue.

Comment thread src/bundler/linker.rs Outdated
Comment thread src/bundler/linker.rs Outdated
Comment thread src/bundler/linker.rs Outdated
Comment thread src/bundler/linker.rs Outdated

@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 — the earlier nit (dead IS_BUN const generic) and the comment-cop flags are all addressed, and the final diff is a clean message-text unification with thorough coverage.

What was reviewed:

  • when_builtin_not_found is private with a single call site inside link()'s if IS_BUN + node: branch, so the removed browser/non-package/Result arms were provably dead.
  • New format string matches ResolveMessage::fmt (src/jsc/ResolveMessage.rs:175) byte-for-byte; BabyString::r#in in add_resolve_error still locates the specifier, so .specifier/.code keep working (tests assert both).
  • bun build is unaffected — bundle_v2.rs keeps its own "bun install" text and bundler_browser.test.ts:465 still pins it; no imports in linker.rs went dead.
Extended reasoning...

Overview

This PR unifies the error message for a static import "node:<unknown>" so it reads No such built-in module: <specifier> regardless of whether the file was transpiled on the JS thread (via Linker::link) or on the concurrent transpiler (via the runtime resolve hook). It renames the private when_module_not_found → when_builtin_not_found in src/bundler/linker.rs, drops its unreachable branches (target param, browser-target hint, non-package-path arm, IS_BUN const generic, Result return), and swaps the format string to match ResolveMessage::fmt. A new describe.concurrent block in test/js/bun/resolve/resolve-error.test.ts covers require() vs import(), the concurrent-transpiler-disabled path, export * from, plugin onLoad sources, and the entry-point stderr snapshot.

Security risks

None. This changes only the human-readable text of a resolve error for an already-failing import; no parsing, validation, allocation sizing, or trust boundary is touched. The specifier is printed via bstr::BStr exactly as before.

Level of scrutiny

Low-to-medium. The Rust change is a net simplification of a private helper with one caller. I verified: (1) the sole call site is nested inside if IS_BUN after a starts_with(b"node:") check, so every removed branch was dead; (2) the new text is byte-identical to src/jsc/ResolveMessage.rs:175; (3) add_resolve_error derives .specifier via BabyString::r#in(&text, specifier_arg) and the specifier appears verbatim in the new text, so .specifier/.code (which keys on the node: prefix at ResolveMessage.rs:118) are preserved — the tests assert both; (4) bun build uses bundle_v2.rs, which retains its own "Maybe you need to bun install" copies, so bundler_browser.test.ts is unaffected; (5) no use statements went dead (resolver::, options::, BundleTarget are still referenced).

Other factors

All prior review threads are resolved: my earlier nit about the dead <const IS_BUN: bool> generic was applied in a143f8a, and the three comment-cop flags about a paragraph-long doc comment were addressed by removing the comment in f8dedea (the parity requirement is now enforced by the test instead). The new tests follow harness conventions — tempDir with using, {...bunEnv, ...}, concurrent pipe draining via Promise.all, exact-object toEqual assertions, normalizeBunSnapshot for the stderr snapshot, and one fixture file per load to avoid module-registry caching. The PR description states the new tests fail on the released binary and pass with the change, satisfying the "fails for the right reason" requirement.

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