Skip to content

bundler: gate @bun pragma and @bun-cjs wrapper on build target, not entry hashbang - #33859

Open
robobun wants to merge 4 commits into
mainfrom
farm/86970922/bun-pragma-build-target
Open

robobun wants to merge 4 commits into
mainfrom
farm/86970922/bun-pragma-build-target

Conversation

@robobun

@robobun robobun commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #25767

What does this PR do?

A #!/usr/bin/env bun hashbang on an entry file sets that one file's per-file target to Target::Bun (via target_from_hashbang), regardless of --target. The linker then emitted the // @bun pragma, and under --format=cjs the // @bun @bun-cjs function-expression wrapper, whenever the entry's per-file target was Bun.

--format=cjs: silent no-op under node

$ printf '#!/usr/bin/env bun\nconsole.log("ALIVE", 42);\n' > cli.ts
$ bun build cli.ts --format=cjs --target=node --outfile=out.cjs
$ head -3 out.cjs
#!/usr/bin/env bun
// @bun @bun-cjs
(function(exports, require, module, __filename, __dirname) {

$ node out.cjs      # exit 0, no output

The wrapper is a function expression statement that only Bun's own module loader (via the @bun-cjs pragma) ever invokes. Under node and every other CommonJS host the module body is parsed and discarded with no build-time or run-time error. Removing the hashbang produces flat CJS that runs correctly.

--format=esm: mojibake / SyntaxError under bun

The printer's ASCII-only escaping policy is decided per file from ast.target: the entry (target=Bun) escapes non-ASCII identifiers, but every bundled dependency follows the build target (node/browser) and keeps them as raw UTF-8. The pragma tells Bun's loader the whole file is safe to read as Latin-1, so it mis-decodes the UTF-8 bytes from dependencies and rejects the bundle it just produced.

SyntaxError: Invalid character '©'

The same mismatch produces mojibake for string literals from dependencies (#25767):

$ bun out.js
Symbols: â â â        # expected: ✓ ✗ ⚠

Node runs the same output file without error.

Repro:

import fs from "node:fs";
const dir = fs.mkdtempSync("/tmp/atbun-");
fs.writeFileSync(`${dir}/lib.ts`, `export class Café { méth(): number { return 7 } }\n`);
fs.writeFileSync(`${dir}/cli.ts`, `#!/usr/bin/env bun\nimport { Café } from "./lib.ts";\nconsole.log(new Café().méth());\n`);
await Bun.build({ entrypoints: [`${dir}/cli.ts`], outdir: `${dir}/out`, target: "node" });
Bun.spawnSync({ cmd: [process.execPath, `${dir}/out/cli.js`] });
// before: SyntaxError: Invalid character '©'
// after:  7

Fix

Gate is_bun in postProcessJSChunk.rs on three conditions so the pragma and the @bun-cjs wrapper are only emitted when the whole chunk was printed for Bun:

  • c.options.target.is_bun(): the build target determines what every non-entry dependency is printed with, and it is the target the output must run under. Fixes both hashbang cases above.
  • ast_targets[entry].is_bun() (the original check): a --target=bun HTML entry produces a browser-printed JS chunk whose entry target is Browser; the flag below is not set on that path.
  • !IS_BROWSER_CHUNK_FROM_SERVER_BUILD: a server build importing HTML produces browser chunks, and a code-split chunk may have a Bun-targeted entry while later files are browser-targeted.

The @bun-cjs wrapper open and close both key off the same is_bun local, so they stay balanced.

--target=bun with a plain JS entry is unchanged: pragma and wrapper are still emitted, identifiers are still escaped, output still runs.

How did you verify your code works?

Added six itBundled cases in test/bundler/bundler_bun.test.ts:

  • bun/HashbangBunNoPragmaFor_node and bun/HashbangBunNoPragmaFor_browser: a #!/usr/bin/env bun entry importing a dependency with a non-ASCII method name, built for node/browser. Assert no // @bun in the output and that the bundle runs in Bun and prints 7. Both fail on the released binary (pragma present, bundle throws SyntaxError).
  • bun/HashbangBunNoCjsWrapperFor_node and bun/HashbangBunNoCjsWrapperFor_browser: a #!/usr/bin/env bun entry built with --format=cjs for node/browser. Assert no @bun-cjs pragma and no function-expression wrapper. The node case additionally runs the output under node and asserts ALIVE 42 on stdout. Both fail on the released binary (wrapper present, node emits nothing).
  • bun/HashbangBunPragmaForBunTarget and bun/HashbangBunCjsWrapperForBunTarget: same inputs with --target=bun. Assert the pragma / wrapper are still present and the bundle runs. Pass before and after (positive controls).

test/bundler/html-import-manifest.test.ts covers the browser-chunk regression (an earlier iteration of this PR that gated only on the build target broke its etag snapshots).

bun bd test test/bundler/bundler_bun.test.ts test/bundler/bundler_banner.test.ts test/bundler/bundler_cjs.test.ts test/bundler/html-import-manifest.test.ts test/bundler/bundler_html.test.ts test/bundler/bundler_html_server.test.ts: pass, 0 fail.


[review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_bun.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (efb476566)

test/bundler/bundler_bun.test.ts:
(pass) bundler > bun/import-bun-format-cjs [1096.02ms]
(pass) bundler > bun/embedded-sqlite-file [687.29ms]
(pass) bundler > bun/sqlite-file [642.78ms]
(pass) bundler > bun/TargetBunNoSourcemapMessage [1011.79ms]
(pass) bundler > bun/TargetBunSourcemapInline [756.91ms]
(pass) bundler > bun/unicode comment [515.02ms]
145 |         "/lib.ts": `export class Café { méth() { return 7 } }\n`,
146 |       },
147 |       onAfterBundle(api) {
148 |         const out = api.readFile("/out.js");
149 |         expect(out).toStartWith("#!/usr/bin/env bun\n");
150 |         expect(out).not.toContain("// @bun");
                              ^
error: expect(received).not.toContain(expected)

Expected t
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (550273c18)

test/bundler/bundler_bun.test.ts:
(pass) bundler > bun/import-bun-format-cjs [27.75ms]
(pass) bundler > bun/embedded-sqlite-file [17.26ms]
(pass) bundler > bun/sqlite-file [16.61ms]
(pass) bundler > bun/TargetBunNoSourcemapMessage [17.87ms]
(pass) bundler > bun/TargetBunSourcemapInline [17.40ms]
(pass) bundler > bun/unicode comment [17.42ms]
(pass) bundler > bun/HashbangBunNoPragmaFor_node [16.16ms]
(pass) bundler > bun/HashbangBunNoPragmaFor_browser [16.97ms]
(pass) bundler > bun/HashbangBunPragmaForBunTarget [17.31ms]
(pass) bundler > bun/HashbangBunNoCjsWrapperFor_node [31.42ms]
(pass) bundler > bun/HashbangBunNoCjsWrapperFor_browser [3.68ms]
(pass) bundler > bun/HashbangBunCjsWrapperForBunTarget [17.16ms]
(pass) bundler > bun/ExportsConditionsDevelopmentAPI [15.74ms]
(pass) bundler > bun/ExportsConditionsDevelopmentInProductionAPI [15.65ms]
(pass) bundler > bun/ExportsConditionsDevelopmentCLI [16.60ms]
(pass) bundler > bun/ExportsConditionsDevelopmentInProductionCLI [16.82ms]

 16 pass
 0 fail
 32 expect() calls
Ran 16 tests across 1 file. [464.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_bun.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (efb476566)

test/bundler/bundler_bun.test.ts:
(pass) bundler > bun/import-bun-format-cjs [1115.22ms]
(pass) bundler > bun/embedded-sqlite-file [617.56ms]
(pass) bundler > bun/sqlite-file [609.55ms]
(pass) bundler > bun/TargetBunNoSourcemapMessage [730.13ms]
(pass) bundler > bun/TargetBunSourcemapInline [753.02ms]
(pass) bundler > bun/unicode comment [663.72ms]
(pass) bundler > bun/HashbangBunNoPragmaFor_node [547.30ms]
(pass) bundler > bun/HashbangBunNoPragmaFor_browser [554.00ms]
(pass) bundler > bun/HashbangBunPragmaForBunTarget [963.28ms]
(pass) bundler > bun/HashbangBunNoCjsWrapperFor_node [124.27ms]
(pass) bundler > bun/HashbangBunNoCjsWrapperFor_browser [83.55ms]
(pass) bundler > bun/HashbangBunCjsWrapperForBunTarget [574.44ms]
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 791ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_paths v0.0.0 (/workspace/bun/src/paths)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m 
... (truncated)
diff hotspot
src/bundler/linker_context/postProcessJSChunk.rs |  8 +++-
 test/bundler/bundler_bun.test.ts                 | 58 ++++++++++++++++++++++++
 2 files changed, 65 insertions(+), 1 deletion(-)

gate history · 4 passed · 0 rejected · iteration 1

evidence per changed file
file                                              reads  edits  tests
src/bundler/linker_context/postProcessJSChunk.rs      6      3      0
test/bundler/bundler_bun.test.ts                      2      2      0

A #!/usr/bin/env bun hashbang on the entry file sets that file's
per-file target to Bun, but every other file in the chunk is printed
according to the build target. When building with --target=node or
--target=browser, dependencies are printed with raw UTF-8 identifiers
while the chunk still carries the // @Bun pragma. Bun's loader trusts
the pragma and reads the bytes as Latin-1, so the bundle it just
produced fails with SyntaxError: Invalid character.

Gate the pragma on c.options.target.is_bun() so it matches the encoding
the printer actually produced for the whole chunk.
@github-actions github-actions Bot added the claude label Jul 9, 2026
@robobun

robobun commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:44 PM PT - Jul 9th, 2026

❌ @robobun, your commit efb4765 has 2 failures in Build #71120 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33859

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

bun-33859 --bun

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Unicode double-encoding when running --target=node bundle under Bun runtime #25767 - Reports unicode double-encoding when running --target=node bundle under Bun runtime, caused by the // @bun pragma being emitted based on the entry file's hashbang rather than the build target

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

Fixes #25767

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This change modifies how the bundler determines whether to emit Bun-specific wrapper output for a chunk, switching from per-file source target inspection to the global build target. Corresponding bundler tests verify hashbang and pragma output across node, browser, and bun targets.

Changes

Bun hashbang/wrapper target logic

Layer / File(s) Summary
Build-target-based is_bun determination and tests
src/bundler/linker_context/postProcessJSChunk.rs, test/bundler/bundler_bun.test.ts
is_bun now derives from c.options.target.is_bun() instead of the entry point's source target; new tests assert /out.js starts with the shebang and excludes // @Bun for node/browser targets, and includes `// `@bun without "méth" for the bun target.
🚥 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 is concise and accurately summarizes the main bundler change.
Description check ✅ Passed The description follows the template and includes both the change summary and verification details.

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

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #33862 fixes the underlying target-propagation bug (514d37b dropped the task.known_target fallback in ParseTask), which makes dependencies inherit the entry's Bun target and get ASCII-escaped by the printer. That resolves #25767 as well, from the other side: the pragma's Latin-1 promise now holds for the whole chunk rather than just the entry.

The two changes touch different files and do not conflict. This PR's approach (gate the pragma on the build target) is still a reasonable belt-and-suspenders in case some other per-file target divergence is introduced later.

Comment thread src/bundler/linker_context/postProcessJSChunk.rs Outdated
robobun added 2 commits July 9, 2026 18:12
The build-target check alone regresses browser JS chunks emitted by a
--target=bun HTML or server-components build: c.options.target is Bun
but every part in the chunk was printed for Target::Browser with raw
UTF-8. Keep the original per-entry target check and also exclude
IS_BROWSER_CHUNK_FROM_SERVER_BUILD so the pragma is only emitted when
the whole chunk was printed ASCII-only.
The same is_bun local gates the @bun-cjs function-expression wrapper.
Before the predicate fix, a #!/usr/bin/env bun entry built with
--format=cjs --target=node emitted that wrapper, which only Bun's own
loader calls. Under node or any other CJS host the module body was
parsed and discarded (exit 0, no output).

Asserts the wrapper is absent for target=node/browser and that the
node build runs under node. target=bun is the positive control.
@robobun robobun changed the title bundler: gate // @bun pragma on build target, not entry hashbang bundler: gate @bun pragma and @bun-cjs wrapper on build target, not entry hashbang Jul 9, 2026

@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 three-way gate strictly narrows the original check, so this can only drop the pragma/wrapper where it was wrong, and the mixed-target regression from the first iteration is resolved.

Extended reasoning...

Overview

Changes one boolean in postProcessJSChunk.rs: is_bun now requires c.options.target.is_bun() && entry_target.is_bun() && !IS_BROWSER_CHUNK_FROM_SERVER_BUILD instead of just entry_target.is_bun(). Six new itBundled cases in test/bundler/bundler_bun.test.ts cover hashbang + {node, browser} × {esm, cjs} plus positive controls for --target=bun.

Security risks

None. This gates emission of a comment pragma and a CJS function-expression wrapper in bundler output; no auth, crypto, filesystem, or network surface.

Level of scrutiny

Bundler linker is a hot/critical path, but this change is strictly narrowing: the new expression ANDs two more predicates onto the original one, so is_bun can only flip true→false, never false→true. That means no chunk gains a pragma or wrapper it lacked before — the only observable effect is dropping them where the build target is not Bun (the reported bug) or where the browser-chunk flag is set (belt-and-suspenders; those chunks already had entry_target=Browser). The wrapper open and close both read the same is_bun local, so they remain balanced.

Other factors

  • I flagged a regression on the first iteration (which replaced rather than narrowed the check); the author addressed it in 5cd61af and CI's html-import-manifest.test.ts snapshot confirmed the fix. Thread resolved.
  • Test coverage is thorough: negative assertions (no // @bun, no wrapper) for node/browser, positive assertions for --target=bun, a runtime execution check under node for the CJS case, and a non-ASCII-identifier dependency that exercises the actual encoding bug.
  • No CODEOWNERS on these paths. No bugs from the bug-hunting pass.
  • robobun noted #33862 fixes the same issue from the other side (target propagation); the two are complementary and non-conflicting.

@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 three-way gate is a strict narrowing of the original condition, so no regression surface, and the mixed-target concern from my earlier review is addressed.

Extended reasoning...

Overview

Changes a single is_bun boolean in postProcessJSChunk.rs from entry_target.is_bun() to c.options.target.is_bun() && entry_target.is_bun() && !IS_BROWSER_CHUNK_FROM_SERVER_BUILD. This gates emission of the // @bun pragma and the @bun-cjs function-expression wrapper. Adds 6 itBundled cases in test/bundler/bundler_bun.test.ts covering node/browser/bun targets × esm/cjs formats, including positive controls that assert --target=bun output is unchanged.

Security risks

None. This is bundler output-header logic with no user input parsing, no auth/crypto/permissions, no external I/O.

Level of scrutiny

Moderate — bundler linker output is a critical path, but the change is a strict narrowing: the original per-entry check is retained and ANDed with two additional constraints. The pragma can therefore only be removed from cases where it was previously emitted, never added. The only behavioral delta is the intended fix (hashbang-derived Bun entry target under a non-Bun build target no longer gets the pragma/wrapper). The wrapper open and close both key off the same is_bun local, so they remain balanced.

Other factors

  • My earlier review flagged that gating solely on c.options.target would regress HTML/server-component browser chunks; the author confirmed via CI (html-import-manifest.test.ts snapshot break) and fixed it in 5cd61af by re-adding the per-entry check plus the browser-chunk flag. That thread is resolved.
  • The bug hunting system found no issues on the current revision.
  • Test coverage is thorough: negative cases (no pragma for node/browser, verified by running the output), positive controls (pragma still present for --target=bun), and a runtime: "node" execution check for the --format=cjs node case that would have caught the original silent-no-op bug.
  • robobun noted PR #33862 fixes the same user-visible symptom via target propagation; the two changes are in different files, don't conflict, and this gate remains correct as belt-and-suspenders.

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI on build 71120 is red on lanes this diff does not touch:

  • test/regression/issue/26030.test.ts on alpine 3.23 x64: MySQL docker container health check timed out (application not healthy after 1m0s).
  • The flaky annotation lists hot-reload, zlib leak threshold, fetch/http connection-refused, napi, and similar tests that passed on retry.

No bundler test is in either list; bundler_bun, bundler_banner, bundler_html, bundler_html_server, and html-import-manifest all pass. The previous run (build 71105) failed the same way on a postgres mock connection-refused on Windows.

The diff itself is green and ready for review.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Re-checked against main at 165dc9f now that #25767 is closed. The four negative tests from this PR (HashbangBunNoPragmaFor_node, HashbangBunNoPragmaFor_browser, HashbangBunNoCjsWrapperFor_node, HashbangBunNoCjsWrapperFor_browser) still fail on main; only the two --target=bun controls pass. postProcessJSChunk.rs still gates is_bun on the entry file's per-file target alone.

#25767 was closed after verifying the issue's literal repro, which has no hashbang. On main, a #!/usr/bin/env bun entry importing a file with non-ASCII content and built with --target=node still gets the // @bun pragma and still prints mojibake under Bun (Symbols: â â â), and the --format=cjs variant still gets the @bun-cjs wrapper and runs as a no-op under node. Keeping this open.

@robobun

robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up from #39715: that PR adds LinkerContext::chunk_has_bun_cjs_wrapper (format is cjs and the chunk entry's target is bun) and uses it for the wrapper header and footer in postProcessJSChunk.rs, for the chunk renamer, and for a printer option. If this PR lands after it, the narrowing of the cjs wrapper belongs in that helper; the pragma-only path still reads the local is_bun. The header block conflicts textually in either order, the resolution is small.

@robobun

robobun commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Verification against current main (731aa92). I applied this PR's six test cases on main without its source change:

  • bun/HashbangBunNoPragmaFor_node, bun/HashbangBunNoPragmaFor_browser, bun/HashbangBunNoCjsWrapperFor_node, bun/HashbangBunNoCjsWrapperFor_browser: fail. The pragma and the wrapper are still emitted.
  • bun/HashbangBunPragmaForBunTarget, bun/HashbangBunCjsWrapperForBunTarget: pass (controls).

With the source change applied on main, all six pass. bundler_bun, html-import-manifest and bundler_banner pass too (35 tests). The branch merges cleanly into main.

#25767 was closed as fixed on main. That verification used the snippet from the issue body, which has no hashbang. That snippet does not reproduce the bug on 1.3.4 or on main. The hashbang case still reproduces on main, so I reopened the issue. This PR is still needed.

#33862 fixes the same hashbang inconsistency from the other side: it makes the dependencies inherit the bun target, so the pragma's promise holds. The two changes touch different files and do not conflict.

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.

Unicode double-encoding when running --target=node bundle under Bun runtime

1 participant