Skip to content

Only strip bun: from an import when the rest is a Node.js builtin - #44879

Open
robobun wants to merge 1 commit into
mainfrom
robobun/8860442d/keep-unknown-bun-prefix
Open

robobun wants to merge 1 commit into
mainfrom
robobun/8860442d/keep-unknown-bun-prefix

Conversation

@robobun

@robobun robobun commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A string-literal bun:X import, with X not a builtin, runs the package X: import sql from "bun:sql" loads node_modules/sql. With no node_modules, auto-install downloads sql and runs it (exit 0, no message).
  • bun build --target=bun prints import sql from "sql". bun add --analyze installs sql.
  • Cause: three loops cut bun: off an import record on a builtin-table miss: src/bundler/linker.rs:409, src/jsc/RuntimeTranspilerStore.rs:1030, src/bundler/bundle_v2.rs:6681.

Fix

  • The loops now cut bun: only before the bare name of a Node.js builtin. bun:fs still loads node:fs: public code uses it (Promises returned from bun:fs/promises functions are not instances of Promise #16883, authTagLength in createCipheriv does not work #14991).
  • Every other bun: specifier stays as written. A colon is not valid in an npm name, so auto-install never asks. The runtime reports Cannot find package 'bun:sql', and the bundler keeps the import external.
  • Transpiler cache version 34 to 35: a cached module keeps its printed import path.
  • Verified: test/js/node/missing-module.test.js and five more files (Notes) fail on main, pass here. Self-reviewed: 5 concerns raised, 5 addressed.

Background

Downsides

  • bun:ws, bun:node:fs and bun:./file.js now fail. A code search found no public use.
  • Still open: a directory named node_modules/bun:X loads for bun:X (POSIX, no registry request).
  • Cost: +512 bytes of text, +1 instruction per import on the JS-thread path, one re-transpile per cached source.
Notes

Repro (loopback registry that records each request; the script runs in a directory with no node_modules):

// main.mjs
import m from "bun:madeupx";
console.log(m.who);
main e655c58032 this PR
registry requests /madeupx, /madeupx/-/madeupx-1.0.0.tgz none
result the package runs, exit 0 error: Cannot find package 'bun:madeupx', exit 1

The same holds for await import("bun:madeupx") and require("bun:madeupx").

Measurements (release builds of the merge base e655c58032 and of this PR, linux-x64)

  • literal bun:sql (import, import(), require): 0 registry requests (was 2), exit 1 (was 0). Controls: node:sql, a non-literal bun:sql and bun:sqlite are unchanged. With --no-install the error now names bun:sql, not sql.
  • A 64-row matrix of import forms with node_modules/zzz present: 16 rows run node_modules/zzz on main, 0 here. The 26 node: rows are identical.
  • bun: test per non-builtin import record (objdump, path from the table miss to the next record): JS thread 14 -> 15 instructions (one register reload), worker 8 -> 8, bundler 11 -> 11. A bun: import that is not in the table pays one call to Alias::is_node_builtin_after_bun_prefix (one hash lookup).
  • release binary text: +512 bytes (size: 89,002,375 -> 89,002,887 for bun-profile).
  • transpiler cache: 20 of 20 entries rewritten on the first run after the bump, 0 on the second (find -newer). A cache entry that main wrote for a 7.7 KiB module with import "bun:zzz" is rejected here. With the version header of that entry set to 35 by hand, the same binary loads node_modules/zzz from it, so the bump is needed.
  • bun build --target=bun output for inputs without such an import: 0 of 622 bundles differ (311 entries from test/bundler/fixtures, bench/snippets and scripts, esm and cjs, 266 output files, sha256sum).
  • literal bun:zzz with node_modules/zzz present: 0 lookups of node_modules/zzz (was 2), 0 connects, 1 openat of node_modules/bun:zzz (ENOENT) left (ptrace syscall count).

Tests (each file has a test that fails on main)

  • test/js/node/missing-module.test.js: a literal require, require.resolve and import() get the errors that the file already pins for a non-literal bun:missing. One child process runs 15 forms with node_modules/missing present (entry point, imported module, required module, .ts, .cjs, Worker) and loads nothing. An entry point with a static import exits 1. The bun:fs forms still load.
  • test/js/bun/resolve/resolve-autoinstall-invalid-name.test.ts: a loopback registry, by default and with --install=force. The only request is for the control package.
  • test/bundler/bundler_bun.test.ts: esm, cjs, a #!/usr/bin/env bun entry, and --compile --bytecode.
  • test/cli/install/bun-add.test.ts: bun add --analyze installs bar and not boba for import "bun:boba".
  • test/js/bun/plugin/plugins.test.ts and test/js/bun/test/mock/mock-module.test.ts: build.module, onResolve and mock.module see the literal specifier.

What changes, by form

  • Error instead of the package, the file or the other builtin: import, export ... from, import(), require(), require.resolve() of bun:X, in an entry point, an imported module, a required module, a Worker, bun -e and a bun test file. Also bun:sql@1.0.0, bun:@scope/pkg, bun:ws, bun:bun, bun:node:fs, bun:./local.mjs, and require("bun:bundle").
  • Unchanged: every builtin in the table, and bun: plus the bare name of a Node.js builtin (bun:fs, bun:fs/promises, bun:crypto, bun:assert, ...). bun:stream/iter and bun:zlib/iter are excluded in both states of --experimental-stream-iter: the flag is not in the transpiler cache key.
  • bun build --target=bun (esm and cjs), a #!/usr/bin/env bun entry in a default-target build, --compile and --bytecode: the output names bun:X as written. On main a compiled executable loads node_modules/X from the directory it runs in.
  • mock.module("bun:x"), build.module("bun:x") and onResolve({ namespace: "bun" }) now see a literal import as it is written.

Self-review. The first version deleted the cut at all three sites. A review found that bun:<Node builtin> imports are in use: import { randomUUID } from 'bun:crypto' in TryGhost/ActivityPub, bun:fs in public test files, and the repros of #16883 (bun:fs/promises) and #14991 (require("bun:crypto")). Those names resolve to a builtin and never reach node_modules or the registry, so the cut stays for them. The five concerns: the allowance is bun: plus a bare Node.js builtin and nothing else. The bundler rewrites at bundle time, so a bundle with bun:fs still runs. The allowance is one function with three callers, not new table rows. A --compile --bytecode row exists. The still-open items are named below. The compile row is in bundler_bun.test.ts, with the other bun-target rows. The cut still writes the bare name (fs), as on main, so nothing changes for the imports it keeps.

Still open, not changed here

Suites run (debug build, linux-x64): the six files above, test/js/bun/resolve/resolve.test.ts, import-meta-resolve.test.mjs, test/regression/issue/25707.test.ts, test/regression/issue/30887.test.ts, bundler_feature_flag.test.ts, bundler_browser.test.ts, test/bundler/esbuild/default.test.ts (its two TopLevelAwaitForbiddenRequireSourceMap tests need more than the 5 s default on a debug build and pass with a longer timeout), test/bake/dev/esm.test.ts, test/bake/dev/bundle.test.ts, test/bake/dev/production.test.ts (with a longer timeout), test/bake/dev-and-prod.test.ts. Windows and macOS were not run.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/install/bun-add.test.ts

A literal `bun:X` specifier, with X not a builtin, lost its prefix while
the file was transpiled. The runtime then resolved X as a package: it
loaded node_modules/X, or asked the registry for X when auto-install
was on. `bun build --target=bun` printed `import "X"`, and
`bun add --analyze` installed X for a `#!/usr/bin/env bun` script.

Three loops over the import records cut the prefix after the builtin
lookup missed: Linker::link, TranspilerJob::run and
BundleV2::resolve_import_records. They now cut it only when the rest is
the bare name of a Node.js builtin, so `bun:fs` still loads `node:fs`.
Every other `bun:` specifier stays as it is written. The runtime reports
it as not found, and the bundler keeps it external under that name.

The transpiler cache version goes from 34 to 35, because a cached
module keeps the import path it was printed with.
@robobun

robobun commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on 1.4.3-canary.1+367d939d9 and on a release build of main e655c58032. The script runs in a directory with no node_modules, against a loopback registry that records each request. import m from "bun:madeupx" asks for /madeupx and /madeupx/-/madeupx-1.0.0.tgz, and the package runs with exit 0. await import("bun:madeupx") and require("bun:madeupx") do the same.
  • With this PR the registry gets no request. Each form fails with error: Cannot find package 'bun:madeupx' and exit 1.
  • PR: Only strip bun: from an import when the rest is a Node.js builtin #44879

@github-actions github-actions Bot added the claude label Oct 9, 2026
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 2164707c-4bb3-4817-a3f9-7d5a667d5423

📥 Commits

Reviewing files that changed from the base of the PR and between e655c58 and 84c4ed1.


📒 Files selected for processing (11)
  • src/bundler/bundle_v2.rs
  • src/bundler/linker.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • src/resolve_builtins/HardcodedModule.rs
  • test/bundler/bundler_bun.test.ts
  • test/cli/install/bun-add.test.ts
  • test/js/bun/plugin/plugins.test.ts
  • test/js/bun/resolve/resolve-autoinstall-invalid-name.test.ts
  • test/js/bun/test/mock/mock-module.test.ts
  • test/js/node/missing-module.test.js

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.



Walkthrough

For Bun-target imports, bun: is stripped only when the remaining name is a recognized Node.js builtin. Other bun: imports continue through normal resolution. The changes also update the transpiler cache version and add bundler and runtime tests.

Changes

bun: Import Resolution

Layer / File(s) Summary
Builtin classification and conditional rewriting
src/resolve_builtins/HardcodedModule.rs, src/bundler/bundle_v2.rs, src/bundler/linker.rs, src/jsc/RuntimeTranspilerStore.rs, src/jsc/RuntimeTranspilerCache.rs
A new alias check identifies eligible Node.js builtins. Bundler and transpiler paths strip bun: only for those imports. The transpiler cache version changes from 34 to 35.
Bundler output coverage
test/bundler/bundler_bun.test.ts
Tests cover prefixed builtin and non-builtin imports in ESM and CJS output, plus shebang and compiled executable cases.
Runtime resolution coverage
test/js/node/missing-module.test.js, test/js/bun/resolve/resolve-autoinstall-invalid-name.test.ts, test/cli/install/bun-add.test.ts, test/js/bun/plugin/plugins.test.ts, test/js/bun/test/mock/mock-module.test.ts
Tests cover missing-module behavior, auto-install and dependency analysis, plugin resolution, and mocking for bun: imports.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 84c4e

No PR-introduced issue remains identified; the known bundler limitation predates this change. The PR is mergeable after normal checks.

Pre-merge checks | Passed 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 main change: stripping the bun: prefix only when the remaining specifier is a Node.js builtin.
Description check Passed The description explains the problem, implementation, compatibility behavior, known limitations, cache-version change, reproduction results, and test verification. It does not use the exact template h…


  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment on lines +947 to +951
pub fn is_node_builtin_after_bun_prefix(rest: &[u8]) -> bool {
// `--experimental-stream-iter` is not in the transpiler cache key, so its two names never count.
!rest.starts_with(b"node:")
&& !is_stream_iter_alias(rest)
&& lookup(&BUN_ALIAS_MAP, rest).is_some_and(|alias| alias.node_builtin)

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.

🟡 (optional) Code importing bun:ws, bun:undici, bun:node-fetch, bun:bun or bun:node:fs now fails with Cannot find package where the base loaded Bun's built-in override. The new gate at src/resolve_builtins/HardcodedModule.rs:951 requires alias.node_builtin, but the same table also holds Bun's own overrides (ws, undici, node-fetch at HardcodedModule.rs:747-752, bun at :704) that never touch node_modules or the registry. Rejecting them serves no part of the stated goal. Fix: accept any rest that resolves in the Bun alias table (any Alias::get(rest, Target::Bun, cfg) hit, with the stream/iter exclusion), not only node_builtin entries, so every name the base routed to a builtin keeps working.

Why this was flagged

A source file containing import WebSocket from "bun:ws", require("bun:undici"), import "bun:node-fetch" or import { file } from "bun:bun" is transpiled at src/jsc/RuntimeTranspilerStore.rs:1030-1038, src/bundler/linker.rs:409-414 or src/bundler/bundle_v2.rs:6681-6685. On the base commit the bun: prefix was cut unconditionally, the bare name hit BUN_EXTRA_ALIAS_KVS (HardcodedModule.rs:747-752 ws/undici/node-fetch, :704 bun) on the next resolve, and the builtin loaded with zero filesystem or registry traffic. After the change is_node_builtin_after_bun_prefix at HardcodedModule.rs:947-952 returns false because those entries carry node_builtin: false (:708, :388), so the specifier is left as bun:ws and the resolver walks node_modules for a package literally named bun:ws, ending in Cannot find package 'bun:ws'. Under bun test the same applies to bun:vitest and bun:@ jest/globals (:805-823).

Verification: nit — acknowledged in diff: the PR description's "Downsides" says "bun:ws, bun:node:fs and bun:./file.js now fail. A code search found no public use," and test/js/node/missing-module.test.js:126-127,169-170 pins import("bun:ws") -> "Cannot find package 'bun:ws'" and import("bun:node:fs") -> "Cannot find package 'bun:node:fs'". The author's bound (no known public use) is plausible but unverifiable; the regression is real and sits beside, not inside, the stated purpose.

Comment on lines +948 to +950
// `--experimental-stream-iter` is not in the transpiler cache key, so its two names never count.
!rest.starts_with(b"node:")
&& !is_stream_iter_alias(rest)

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.

🟡 nit (optional): users running with --experimental-stream-iter now get Cannot find package 'bun:stream/iter' where the base branch loaded the builtin, and the comment's cache-key reason does not hold. The !is_stream_iter_alias(rest) clause at src/resolve_builtins/HardcodedModule.rs:950 guards against flag-dependent output, but lookup(&BUN_ALIAS_MAP, rest) on the next line never reads the flag, so the cut would print stream/iter in both flag states. Fix: drop the clause and the comment so bun:stream/iter and bun:zlib/iter are cut like every other bare Node builtin, leaving the flag check to the runtime resolver of the printed bare name.

Why this was flagged

A file containing import "bun:stream/iter" is transpiled by any of the three callers while bun --experimental-stream-iter is in effect. is_node_builtin_after_bun_prefix at src/resolve_builtins/HardcodedModule.rs:947-952 returns false because of !is_stream_iter_alias(rest), so the specifier stays bun:stream/iter and the runtime resolver reports Cannot find package 'bun:stream/iter'. On the base branch the prefix was cut unconditionally, the printed import was stream/iter, and Alias::get at src/resolve_builtins/HardcodedModule.rs:931 then honoured the flag and loaded the builtin. The comment at line 948 says the clause exists because --experimental-stream-iter is not in the transpiler cache key, but lookup(&BUN_ALIAS_MAP, rest) at line 951 is a plain hash lookup with no flag gating, so without the clause the cached output would be stream/iter in both flag states and no cache hazard exists; the clause only removes behaviour.

Verification: Triggering condition: a literal import/require of bun:stream/iter run under bun --experimental-stream-iter. At src/resolve_builtins/HardcodedModule.rs:947-952, lookup (line 909-914) is a plain map.get(name).copied() with no flag read. After this PR, the three callers leave the specifier as bun:stream/iter, so the runtime reports Cannot find package 'bun:stream/iter' even with the flag on.

Comment thread src/bundler/linker.rs
Comment on lines +409 to 414
if let Some(rest) = import_record.path.text.strip_prefix(b"bun:")
&& bun_resolve_builtins::Alias::is_node_builtin_after_bun_prefix(rest)
{
import_record.path = PFs::Path::init(rest);
import_record.path.namespace = b"bun";
}

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.

🟡 (optional) A literal import "bun:/../sql" (or bun:../sql) now loads node_modules/sql, which the base never did for a literal, defeating the PR's own goal for that spelling. The strip at src/bundler/linker.rs:409-414 (and src/jsc/RuntimeTranspilerStore.rs:1030-1038) is skipped, so bun:/../sql reaches the resolver as a bare package path; is_package_path at src/paths/lib.rs:482-496 accepts it and the node_modules join normalizes .. away. On the base the cut produced /../sql, an absolute path that never touched node_modules. Fix: before the package walk, reject any specifier that still carries a bun: or node: prefix (or whose first segment contains :), at the resolver so every entry point is covered.

Why this was flagged

Input: a string-literal import whose text is bun:/../sql or bun:../sql, arriving through any of the three rewrite loops (src/bundler/linker.rs:409, src/jsc/RuntimeTranspilerStore.rs:1030, src/bundler/bundle_v2.rs:6681). is_node_builtin_after_bun_prefix returns false for /../sql, so the text is left as written. On the base commit the loop cut the prefix, leaving /../sql, which is_package_path (src/paths/lib.rs:482-483) classifies as absolute and resolves to /sql, never node_modules. Now bun:/../sql has no leading / or ./, so src/paths/lib.rs:488-496 calls it a package path, load_node_modules joins it under each node_modules directory and the .. collapses to node_modules/sql. The package loads and runs, exactly the outcome the PR exists to stop. The dismissal said the exposure already existed on the base for a non-literal bun:X; that is a different population, and the author's own 'Still open' list confirms the literal form now reaches node_modules/sql. Remedy: have the resolver refuse bare-package lookups whose first path segment contains a colon.

Verification: Trigger: a literal import "bun:/../sql". lookup(&BUN_ALIAS_MAP, "/../sql") misses, so the specifier stays bun:/../sql; is_package_path (src/paths/lib.rs:482-496) accepts it, so load_node_modules (src/resolver/resolver.rs:2608-2611) collapses node_modules/bun:/../sql to node_modules/sql. On the base, the cut produced /../sql, never node_modules. bun:../sql is NOT affected.

Comment thread src/bundler/bundle_v2.rs
Comment on lines +6681 to 6688
if let Some(rest) = import_record.path.text.strip_prefix(b"bun:") {
if bun_resolve_builtins::HardcodedModule::Alias::is_node_builtin_after_bun_prefix(rest) {
import_record.path = bun_paths::fs::Path::init(rest);
import_record.path.namespace = b"bun";
}
import_record.source_index = Index::INVALID;
import_record
.flags

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.

🟣 pre-existing, not blocking: Users bundling with --target=bun who write a side-effect-only import "bun:sql" get a bundle with that line silently deleted, so the runtime never reports Cannot find package 'bun:sql' as the PR promises for every other form. At src/bundler/bundle_v2.rs:6686-6689 the record is still flagged IS_EXTERNAL_WITHOUT_SIDE_EFFECTS even when the name was not stripped, and src/bundler/LinkerContext.rs:3171-3173 drops such statement imports during tree shaking. Fix: only set the flag when is_node_builtin_after_bun_prefix was true; for an unknown bun:X mark the record external but with side effects so the import survives into the output and fails at load time like the ESM/CJS named-import rows.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: bun build --target=bun (also --compile) of a file containing import "bun:sql"; with no bindings, reaching src/bundler/bundle_v2.rs:6681. The new inner if at :6682 leaves the text as bun:sql, but :6686-6689 still run and insert IS_EXTERNAL_WITHOUT_SIDE_EFFECTS. In src/bundler/LinkerContext.rs:3148-3174 a Stmt record with invalid source_index and that flag is continued, so the part is removable and the import vanishes from the output bundle. The user ships a binary that silently skips a module they asked for. The dismissal called it pre-existing because the base dropped the same import as import "sql"; but the PR changes the contract to 'the bundler keeps bun:X as written and the runtime reports Cannot find package', and the new bundler tests only cover the default-import form, so the side-effect form is the one spelling that still loses the error. The runtime loop at src/jsc/RuntimeTranspilerStore.rs:1030-1038 no longer sets the flag for non-builtins, so the two paths diverge. Remedy: move the flag insertion inside the inner if at bundle_v2.rs:6682.

Verification: Pre-existing; acknowledged in diff. Triggering condition: bun build --target=bun of a file containing a bare import "bun:sql";. src/bundler/bundle_v2.rs:6686-6689 still run unconditionally, inserting IS_EXTERNAL_WITHOUT_SIDE_EFFECTS. In src/bundler/LinkerContext.rs:3171-3173 that record hits continue, so the import part is tree-shaken out. The base branch fails the same way by the same route.

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.

2 participants