Skip to content

bundler: default the build target to bun from entry point hashbangs - #38598

Open
robobun wants to merge 5 commits into
mainfrom
farm/df40ca9f/bundler-hashbang-default-target
Open

robobun wants to merge 5 commits into
mainfrom
farm/df40ca9f/bundler-hashbang-default-target

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • docs/bundler/index.mdx ("target") says: if any entrypoint contains a #!/usr/bin/env bun shebang, the bundler defaults to target: "bun". The implementation did something narrower, and the parts it did do were only half applied:
    • target_from_hashbang (src/bundler/bundle_v2.rs) only accepted \n or a space after #!/usr/bin/env bun, so a CRLF file (or a file that is only the hashbang line) was built for the browser. bun build ./crlf.js produced no // @bun pragma while the same file with LF endings did.
    • ParseTask.rs only consulted it for source index 1, i.e. the first entry point. bun build plain.js cli.js ignored the hashbang in cli.js.
    • What it did apply was a per-file target: only the entry file's own AST was marked Target::Bun. Its dependencies were still resolved and printed for the configured target (browser by default), so for a script with a bun shebang and no --target:
      • import { readFileSync } from "node:fs" in a dependency became the browser stub (typeof readFileSync is undefined at runtime), while the same import in the entry file worked.
      • import "bun" in a dependency failed with Browser build cannot import Bun builtin: "bun".
      • The output still carried the // @bun pragma, which promises Bun the whole file was printed for Bun.
    • The per-file target also overrode an explicit --target=node / --target=browser for that one file. That is the trigger behind bundler: gate @bun pragma and @bun-cjs wrapper on build target, not entry hashbang #33859 (// @bun / @bun-cjs wrapper emitted into a node or browser build) and bundler: propagate known_target so a hashbang-bun entry does not split the dedup graph #33862 (the entry registers its imports in the Target::Bun dedup graph while dependencies use the configured target's graph, so a module imported by both is bundled twice and Shared === ReExported is false).

Fix

  • options::any_entry_point_has_bun_hashbang (src/bundler/options.rs) reads the first 19 bytes of each entry point (the Bun.build files map is consulted first, then disk) and accepts LF, CRLF, EOF, or arguments after bun.
  • bun build (build_command.rs) and Bun.build() (JSBundler.rs) call it when the user gave no target and set the build target to bun. --compile, --bytecode and an explicit target are untouched: an explicit setting wins over the inferred one. Nothing else disables it; in particular --format=cjs / format: "cjs" on a hashbang entry still gives a bun build (// @bun @bun-cjs) on both paths, as it did before, and a test pins that. (The documented cjs to node CLI default is not applied on main today, Arguments.rs writes it into a copy of the args that parse() discards; that is tracked separately and, when fixed, needs to keep running after this check.)
  • The per-file override in ParseTask.rs and target_from_hashbang are removed. With the default applied at the options layer it is redundant when no target is given (the whole build is already bun) and only harmful when one is (it was the trigger for bundler: gate @bun pragma and @bun-cjs wrapper on build target, not entry hashbang #33859 and bundler: propagate known_target so a hashbang-bun entry does not split the dedup graph #33862; both of their repros pass with this change, see the tests below). This does not change known_target handling or the server-components branch.
  • FileMap::resolve is split into lookup (returns the matched key and contents) plus a thin resolve, so the hashbang check matches entry points against files exactly the way the bundler itself does.
  • Why this is the right contract: it is the one the docs (and the BuildConfig JSDoc, updated here) describe, it applies the target to the whole graph instead of one file, and it makes the CLI and the JS API behave the same. The cost is one small read per entry point, only when no target was given.
  • Behavior changes to be aware of: a bun-shebang entry built with an explicit non-bun target is now built purely for that target (no // @bun pragma, no @bun-cjs wrapper). The entry path is read as written, so an entry that only exists through resolution (bun build ./cli without an extension, ./cli.js naming a cli.ts, a package name) does not trigger the default; the removed override read the parsed file, but only for the first entry. Only the documented #!/usr/bin/env bun spelling is recognized, as before; env -S bun and absolute interpreter paths are not.
  • Verified with test/bundler/bundler_bun.test.ts (bundler hashbang target default), run through both bun build and Bun.build(): LF / CRLF / hashbang-only / bun --flags files select bun and #!/usr/bin/env node / bunx do not; a dependency's node:fs import survives; a hashbang on the second entry point applies to the build; --target=node and --target=browser are honored with the shared module bundled once and identity preserved; format: "cjs" alone keeps the bun default; a files entry is checked instead of the file on disk. 12 of the 23 cases fail on the released binary (CRLF, hashbang-only, dependency, later entry, and both explicit-target cases, on both backends); all pass with this change.
  • Also run: bundler_banner, bundler_files, bundler_plugin, bundler_naming, bundler_browser, bundler_edgecase, bundler_cjs, bun-build-api, cli, html-import-manifest, bundler_html_server, bake/dev/bundle, bake/dev-and-prod, integration/bun-types, cargo clippy on bun_bundler and bun_runtime, cargo check of bun_bundler for x86_64-pc-windows-msvc (the FileMap change has a Windows-only branch).

Background

  • Build target: browser / bun / node. It selects package.json export conditions, whether node:* and bun imports are kept or stubbed, the runtime helpers, and how files are printed. It is fixed when the options are built (BundleOptions::from_api), before any file is parsed, which is why this check has to happen in the two option-building entry points rather than in the parser.
  • // @bun pragma: the first comment of a bun-target bundle; it tells Bun to load the file without transpiling it again. It is emitted when the entry file's per-file target is bun, so it was the visible symptom of the old per-file override.
  • Per-file target (ast.target): the bundler keeps one path-to-module map per target so that, for example, a server build can also bundle browser code. A file whose target differs from its importers' therefore lives in a different map, which is how the removed override duplicated modules (bundler: propagate known_target so a hashbang-bun entry does not split the dedup graph #33862).
  • Bun.build({ files }): in-memory files that can be used as entry points or imports and shadow files on disk with the same path; the hashbang check has to read those instead of the disk.

When no target is given, `bun build` and `Bun.build()` now check every
entry point (in-memory `files` first, then disk) for a `#!/usr/bin/env bun`
first line, accepting LF, CRLF and EOF terminators, and default the whole
build to target bun. An explicit target is left alone.

This replaces the per-file override in ParseTask, which only looked at the
first entry point, missed CRLF files, and retargeted that one file while
its dependencies were still built for the configured target.
@robobun
robobun requested a review from alii as a code owner August 14, 2026 16:51
@coderabbitai

coderabbitai Bot commented Aug 14, 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: 1 minute

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: 61f00169-27e8-4b54-ba90-6d618650d938

📥 Commits

Reviewing files that changed from the base of the PR and between eabb96d and 8ad0d30.

📒 Files selected for processing (9)
  • docs/bundler/index.mdx
  • packages/bun-types/bun.d.ts
  • src/bundler/ParseTask.rs
  • src/bundler/bundle_v2.rs
  • src/bundler/lib.rs
  • src/bundler/options.rs
  • src/runtime/api/JSBundler.rs
  • src/runtime/cli/build_command.rs
  • test/bundler/bundler_bun.test.ts

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on the released 1.4.0 binary and on main with printf '#!/usr/bin/env bun\r\nconsole.log(1);\r\n' > crlf.js; bun build ./crlf.js (no // @bun, browser build), with bun build plain.js cli.js --outdir out (hashbang on the second entry ignored), and with a hashbang entry whose dependency imports node:fs (dependency gets the browser stub). All three are covered by the bundler hashbang target default block in test/bundler/bundler_bun.test.ts; 12 of its 21 cases fail before this change and pass after it.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. bundler: propagate known_target so a hashbang-bun entry does not split the dedup graph #33862 - Fixes the same root cause (a #!/usr/bin/env bun entry hashbang flipping only that file's per-file target and splitting the dedup graph) in the same two files, src/bundler/ParseTask.rs and src/bundler/bundle_v2.rs, by propagating known_target instead of removing the override.
  2. bundler: gate @bun pragma and @bun-cjs wrapper on build target, not entry hashbang #33859 - Fixes the same symptom — // @bun / @bun-cjs emitted into a --target=node/browser build because of the entry hashbang — by gating the pragma on the build target in the linker, and adds overlapping cases to test/bundler/bundler_bun.test.ts.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Related, but not a duplicate of either:

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

Beyond the inline nit, I also checked whether Plugin::create(..., this.target) running before the new hashbang check in JSBundler.rs mis-tags the plugin's BunPluginTarget — it does read the pre-inference target, but the old per-file override ran even later (in ParseTask), so plugins never saw the hashbang-derived target before either; not a regression.

Extended reasoning...

The bug-hunting pass raised one candidate beyond the reported nit: that bundler plugins in Config::from_js are created with BunPluginTarget derived from this.target before the hashbang inference at line 833 runs. Verified this is not a regression — the removed target_from_hashbang override ran inside ParseTask (well after plugin setup and after BundleOptions were built), so plugin-visible target was never hashbang-aware. The actual build target (this.target) is what feeds BundleOptions, and that is now correctly set. Noting so a later pass doesn't re-explore.

Comment thread src/runtime/cli/build_command.rs
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/options.rs Outdated
Comment thread src/bundler/options.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/options.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.

I re-reviewed after the follow-up commits and didn't find any bugs. My earlier --format=cjs concern turned out to rest on dead code in Arguments.rs and is now pinned by a test on both backends — thanks for checking. Because this changes user-facing target-inference semantics (explicit --target now fully wins over a bun hashbang) and supersedes the approach in open PRs #33859 / #33862, a maintainer should confirm the contract before merging.

What was reviewed:

  • has_bun_hashbang terminator handling (LF/CRLF/EOF/space/tab) and the bunx / node negative cases — correctly rejected.
  • FileMap::resolve → lookup split preserves the Windows normalization branch and the arena-copy of the matched key.
  • Placement of the check in build_command.rs (after --compile/--bytecode force bun, before options build) and in JSBundler.rs (after entrypoints/files are read, gated on !did_set_target).
  • Removal of the per-file override in ParseTask.rs leaves the ServerComponentsSsr branch intact.
Extended reasoning...

Overview

Moves the #!/usr/bin/env bun → target: "bun" default from a per-file parse-time override (source index 1 only, LF/space only) to a build-wide options-layer check applied in both bun build and Bun.build(). Removes target_from_hashbang and its call site in ParseTask.rs; adds any_entry_point_has_bun_hashbang in options.rs (reads first 19 bytes via bun_sys::File, or the files map); refactors FileMap::resolve into lookup + a thin wrapper so the hashbang check reuses the bundler's own key-matching; updates docs and bun.d.ts; adds 23 test cases across CLI and API backends.

Security risks

None identified. The new file read is a bounded 19-byte read of user-specified entry-point paths that the bundler is about to open anyway; errors are silently treated as no-match. No untrusted-input parsing beyond a fixed prefix compare.

Level of scrutiny

Medium-high. The mechanical pieces are small and well-tested, but the change is a deliberate contract shift: an explicit --target=node/browser on a bun-hashbang entry now builds purely for that target (previously the entry file itself was still marked bun and got // @bun), and hashbang detection no longer sees the resolved file (so bun build ./cli without an extension won't trigger it). The PR description is transparent about both trade-offs, and the test suite pins them, but they're the kind of behavior change a maintainer should sign off on — especially given two other open PRs (#33859, #33862) fix downstream symptoms of the mechanism this PR removes.

Other factors

  • My earlier inline nit (--format=cjs CLI/API divergence) was investigated by the author, shown to be based on dead code in Arguments.rs, and a regression test was added in 76bdd6a covering both backends. That thread is resolved.
  • The comment-cop bot flagged long doc comments; commits 6a28b96 and 4897b1d trimmed them.
  • Test coverage is thorough: LF/CRLF/hashbang-only/flags/node/bunx variants, dependency target propagation, later-entry-point detection, explicit-target override with module-dedup assertion, format: cjs interaction, and files-map shadowing — all run through both the CLI and Bun.build().
  • The FileMap refactor keeps the Windows path-normalization branch and the arena-copy invariant; I checked that resolve's only behavioral change is delegating key lookup to the new lookup/entry helpers.

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