Skip to content

node-fallbacks: bundle the polyfills for the browser target - #41524

Open
robobun wants to merge 4 commits into
mainfrom
robobun/44155f9f/crypto-polyfill-browser-field
Open

robobun wants to merge 4 commits into
mainfrom
robobun/44155f9f/crypto-polyfill-browser-field

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • In a bun build --target=browser bundle, createHash, createHmac, randomBytes, pbkdf2Sync and createCipheriv from node:crypto throw TypeError: createHash2 is not a function. Only the WebCrypto pass-throughs work. The build reports success.
  • The cause is src/node-fallbacks/build-fallbacks.ts. It bundled each polyfill with --target=node. That ignores the package.json browser field, so the crypto-browserify dependencies randombytes, create-hash, create-hmac and pbkdf2 resolved to their node entry: module.exports = require("crypto").X. In the user's browser bundle that require("crypto") is the crypto polyfill itself, so each function reads itself during its own initialization.

Fix

  • The script calls Bun.build() with target: "browser". The npm packages now resolve through their browser field, which is how crypto-browserify is meant to be bundled.
  • A plugin returns external: true for every node builtin (buffer, stream, util, ...). With the browser target the resolver picks the polyfill before it reads the external list (resolver: check user externals before browser node builtin polyfills #35212), so the list alone cannot keep them external. The user's build still maps each builtin to one shared polyfill.
  • The __require shim that the browser output carries is removed after the __require( to require( rewrite. A leftover __require fails the build.
  • Verified: test/bundler/bundler_browser.test.ts (browser/NodeCryptoPolyfill fails on the old polyfills). Also bundler_edgecase, bundler_regressions, bundler_loader, bun-build-api, and the browser regression tests.

Background

  • src/node-fallbacks/*.js are the modules that bun build --target=browser substitutes for node builtins. build-fallbacks.ts bundles each one at build time into codegen/node-fallbacks. Release builds embed them.
  • The browser field in a package.json names a file to use in place of main in browsers, or maps a dependency to another file or to false. A bundler honors it for the browser target only.
  • The polyfills must keep require("buffer") and friends as bare specifiers. If one polyfill inlined its own copy of the Buffer polyfill, a browser bundle would carry two Buffer classes.
Notes
  • Repro: import { createHash } from "node:crypto"; console.log(createHash("sha256").update("abc").digest("hex")), then bun build --target=browser e.mjs --outfile=out.js && bun out.js. Before: TypeError: createHash2 is not a function. After: ba7816bf....
  • Other output differences with the browser target: punycode resolves through its module entry and gains node's named exports (decode, encode, toASCII, ...). The NodePolyfills snapshot is updated for that. http.js no longer contains require("http").STATUS_CODES (a self reference from builtin-status-codes) and stream.js no longer contains require("stream") from readable-stream's node entry. The set of bare specifiers in every other polyfill is unchanged.
  • crypto.js grows from 478 KB to 744 KB because it now carries the real hash, HMAC, PBKDF2 and cipher implementations instead of require("crypto").X.
  • createECDH and createSign use brorand, which reads self.crypto. They work in a browser and under bun. Under node, which has no self, they fall back to require("crypto") and hit the same self reference. Not changed here.
  • assert(), util.format and zlib in browser bundles are separate problems, covered by node-fallbacks: fix assert() and new EventEmitter() in browser bundles #41328 and node-fallbacks: fix the util polyfill and lint the polyfills for undeclared names #40072.
  • build: bundle the node fallbacks with esbuild #41315 moves this script to esbuild. The same fix there is platform: "browser" with an onResolve plugin for the builtins, or mainFields: ["browser", "module", "main"].
  • Two bytecode tests in bun-build-api.test.ts time out at 5 s in the debug build with and without this change.

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

fails on main (without fix)
ASAN without fix: 2 failed, 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_browser.test.ts
bun test v1.4.3 (f42e98025)

test/bundler/bundler_browser.test.ts:
(pass) bundler > browser/NodeBuffer#21522 [1784.59ms]
(pass) bundler > browser/NodeBuffer#12272 [897.09ms]
(pass) bundler > browser/NodeFS [504.09ms]
(pass) bundler > browser/NodeTTY [466.71ms]
(pass) bundler > browser/NodeEventsOnce [803.25ms]
1885 |           }
1886 |         } else if (!success) {
1887 |           if (run.exitCode) {
1888 |             expect([exitCode, signalCode]).toEqual([run.exitCode, undefined]);
1889 |           } else {
1890 |             throw new Error(prefix + "Runtime failed\n" + stdout!.toUnixString() + "\n" + stderr!.toUnixString());
                             ^
error: Runtime failed

18136 | 
18137 | // entry.js
18138 | init_crypto();
18139 | init_buffer();
18140 | var results = [];
18141 | results.push(createHash2("sha256").update("abc").digest("hex"));
                     ^
TypeError: createHash2 is not a function. (In 'createHash2("sha256")', 'createHash2' is undefined)
      at <anonymous> (/tm
... (truncated)

release without fix: 2 failed, 2 skipped
bun test v1.4.3-canary.1 (f42e98025)

test/bundler/bundler_browser.test.ts:
(pass) bundler > browser/NodeBuffer#21522 [131.24ms]
(pass) bundler > browser/NodeBuffer#12272 [44.77ms]
(pass) bundler > browser/NodeFS [40.80ms]
(pass) bundler > browser/NodeTTY [39.90ms]
(pass) bundler > browser/NodeEventsOnce [56.30ms]
1885 |           }
1886 |         } else if (!success) {
1887 |           if (run.exitCode) {
1888 |             expect([exitCode, signalCode]).toEqual([run.exitCode, undefined]);
1889 |           } else {
1890 |             throw new Error(prefix + "Runtime failed\n" + stdout!.toUnixString() + "\n" + stderr!.toUnixString());
                             ^
error: Runtime failed

18136 | 
18137 | // entry.js
18138 | init_crypto();
18139 | init_buffer();
18140 | var results = [];
18141 | results.push(createHash2("sha256").update("abc").digest("hex"));
                     ^
TypeError: createHash2 is not a function. (In 'createHash2("sha256")', 'createHash2' is undefined)
      at <anonymous> (/tmp/bun-build-tests/bun-KsJ9aY/browser/NodeCryptoPolyfill/out.js:18141:14)

Bun v1.4.3-canary.1+f42e98025 (Linux x64)

      at <anonymous> (/workspace/bun/test/bundle
... (truncated)
passes on PR (with fix)
ASAN with fix: 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_browser.test.ts
bun test v1.4.3 (f42e98025)

test/bundler/bundler_browser.test.ts:
(pass) bundler > browser/NodeBuffer#21522 [1668.77ms]
(pass) bundler > browser/NodeBuffer#12272 [1044.22ms]
(pass) bundler > browser/NodeFS [541.32ms]
(pass) bundler > browser/NodeTTY [477.03ms]
(pass) bundler > browser/NodeEventsOnce [602.95ms]
(pass) bundler > browser/NodeCryptoPolyfill [7695.37ms]
(pass) bundler > browser/NodeUrlProtocolTablesIgnorePrototype [552.79ms]
(pass) bundler > browser/NodePolyfills [10105.75ms]
(todo) bundler > browser/NodePolyfillExternal
(pass) bundler > browser/BrowserFieldDisablesPolyfilledBuiltin#4928 [619.49ms]
(pass) bundler > browser/BrowserFieldDisablesPolyfilledBuiltinNodePrefix#4928 [444.39ms]
(pass) bundler > browser/BrowserFieldRemapsPolyfilledBuiltin#4928 [431.63ms]
(pass) bundler > browser/BrowserFieldDisabledBuiltinStillPolyfillsOutsideScope [532.68ms]
(pass) bundler > browser/EntryPointDisabledByBrowserField [247.17ms]
(pass) bundler > browser/EntryPointDisabledByBrowserFieldNextToLiveEntr
... (truncated)

release with fix: 2 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1455ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/8] gen node-fallbacks/*.js
[1/8] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_resolver v0.0.0 (/workspace/bun/src/resolver)
�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_router v0.0.0 (/workspace/bun/src/router)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
�[1m�[92m   Compiling�[0m bun_bunfig v0.0.0 (/workspace/bun/src/bunfig)
�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/workspace/bun/src/install)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_jsc)
�[1m�[92m   Compiling�[0m bun_ast_jsc v0.0.0 (/workspace/bun/src/ast_jsc)
�[1m�[92m   Compiling�[0m bun_patch_jsc v0.0.0 (/workspace/bun/src/patch_jsc)
�[1m�[92m   Compiling�[0m bun_http_jsc v0.0.0 (/workspace/bun/src/http_jsc)
�[1m�[92m   Compiling�[0m
... (truncated)
diff hotspot
src/node-fallbacks/build-fallbacks.ts | 65 ++++++++++++++++++-----------------
 test/bundler/bundler_browser.test.ts  | 33 +++++++++++++++++-
 2 files changed, 65 insertions(+), 33 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                   reads  edits  tests
src/node-fallbacks/build-fallbacks.ts      5      6      8
test/bundler/bundler_browser.test.ts       2      2      8

root cause · written by the author bot

The browser fallbacks for Node built-ins were bundled through a pipeline that did not target the browser, so the embedded polyfills still carried a shared __require shim and unresolved references to Node modules, which made APIs like node:crypto fail at runtime in bun build --target=browser output. The fix rebuilds the fallbacks with direct Bun.build calls using the browser target, externalizes the remaining Node built-ins, and removes the __require shim so no Node-only resolution path survives in the emitted code. The build step now rejects any leftover reference to a Node built-…

@coderabbitai

coderabbitai Bot commented Sep 6, 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: Essentials

Run ID: cd8a9f1d-30a2-4a99-b161-f766cb1ab2d1

📥 Commits

Reviewing files that changed from the base of the PR and between 16eb85a and 8b2ea73.

📒 Files selected for processing (2)
  • src/node-fallbacks/build-fallbacks.ts
  • test/bundler/bundler_browser.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

Changes

Browser fallback builds

Layer / File(s) Summary
Direct browser fallback build pipeline
src/node-fallbacks/build-fallbacks.ts
The builder uses direct Bun.build calls, externalizes Node built-ins, removes the shared __require shim, and rejects remaining references.
Node crypto browser validation
test/bundler/bundler_browser.test.ts
Browser tests cover crypto operations and update the expected NodePolyfills output.

Suggested reviewers: jarred-sumner

Merge Risk: ⚪ Minimal · up to 33ca3

Browser fallback bundles now use browser-targeted polyfills, fixing Node crypto APIs in browser builds with coverage for hashing, HMAC, random bytes, PBKDF2, and AES-CBC. No merge-blocking risk is currently identified.

🚥 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 and concisely identifies the main change: bundling Node fallbacks for the browser target.
Description check ✅ Passed The description explains the problem, fix, technical background, and verification results. It does not use the exact template headings, but it provides the required information and is substantially co…

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

@github-actions github-actions Bot added the claude label Sep 6, 2026
@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:36 AM PT - Sep 6th, 2026

❌ @robobun, your commit 33ca3a3 has 4 failures in Build #111065 (All Failures):

  • test/js/bun/udp/udp_socket.test.ts - 1 failing in the parallel batch on 🍎 any x64
  • test/js/node/dgram/node-dgram.test.js - 1 failing in the parallel batch on 🍎 any x64
  • test/js/node/test/parallel/test-crypto-dh-leak.js - code 1 on 🐧 13 x64-asan
  • ❌ CPU instruction violation on Linux x64 — 1 check(s) failed
  • The baseline build contains instructions not available on Nehalem (SSE4.2, no AVX/AVX2/AVX512).

    • Static instruction scan

    Static scan violations

    llint_op_wide16  [INVLPGB]  (1 insns)
    

    If these are runtime-dispatched behind a CPUID gate: add each symbol to scripts/verify-baseline-static/allowlist-x64.txt with a comment pointing at the gate.

    If there's no gate: this is a real bug — a -march leaked into a subbuild.

  • ❌ CPU instruction violation on Linux x64 — 1 check(s) failed
  • The baseline build contains instructions not available on Nehalem (SSE4.2, no AVX/AVX2/AVX512).

    • Static instruction scan

    Static scan violations

    llint_op_wide16  [INVLPGB]  (1 insns)
    

    If these are runtime-dispatched behind a CPUID gate: add each symbol to scripts/verify-baseline-static/allowlist-x64.txt with a comment pointing at the gate.

    If there's no gate: this is a real bug — a -march leaked into a subbuild.


🧪   To try this PR locally:

bunx bun-pr 41524

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

bun-41524 --bun

@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced with bun build --target=browser on a file that imports createHash from node:crypto and runs the output under bun (TypeError: createHash2 is not a function on 1.4.3). The fix is in this PR. The new test browser/NodeCryptoPolyfill fails with the old polyfills and passes with the new ones.

CI: 176 of 181 jobs pass on the rebased branch. The bundler suites (bundler_browser, bundler_edgecase, bundler_regressions, bundler_loader) are green on every lane. The 5 red jobs are on main and do not touch this change: verify-baseline on x64 and x64-musl (an INVLPGB byte pattern in JSC's llint_op_wide16, also red on main build #110907 after #41330), test-crypto-dh-leak RSS threshold under ASAN, and udp_socket / node-dgram on darwin x64. Ready for review.

@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 reviewed this PR and didn't find any bugs. Because it changes how every embedded --target=browser polyfill is produced (target node→browser, new regex-based __require stripping, +266 KB to crypto.js), a human look at the generated output would still be worthwhile.

What was reviewed:

  • keepBuiltinsExternal onResolve filter — matches bare and node:-prefixed builtins, falls through for npm packages; entry files themselves aren't intercepted (onResolve fires on imports, not entrypoints).
  • Post-processing: __require( → require( runs before the shim regex, and the leftover-__require guard hard-fails the build if the shim shape drifts.
  • Snapshot diff in bundler_browser.test.ts — only punycode changed (gained named exports), no other module's export set was weakened.
  • New crypto test uses known-answer vectors (SHA-256 of "abc", HMAC-SHA1, PBKDF2) rather than self-round-trip alone.
Extended reasoning...

Overview

The PR rewrites src/node-fallbacks/build-fallbacks.ts, the build-time script that pre-bundles the ~30 Node polyfills embedded into release binaries for --target=browser. It swaps per-file Bun.$-spawned bun build --target=node invocations for in-process Bun.build({ target: "browser" }) with an onResolve plugin that keeps node builtins external. Post-processing now strips the browser __require shim via regex and hard-fails on any surviving __require. In test/bundler/bundler_browser.test.ts, a new browser/NodeCryptoPolyfill case exercises createHash/createHmac/randomBytes/pbkdf2Sync/createCipheriv with known-answer vectors, and the all-polyfills snapshot is updated because punycode now resolves through its module entry and gains named exports.

Security risks

None identified. This is build-time tooling operating on vendored npm packages under src/node-fallbacks/node_modules/; no untrusted input reaches it at runtime. The crypto polyfill is crypto-browserify — the standard browser shim — and the change makes its hash/HMAC/PBKDF2/cipher paths actually work (they previously self-recursed and threw). The known-answer test vectors are correct (verified SHA-256("abc") = ba7816bf…). No auth, permissions, or network surface is touched.

Level of scrutiny

Moderate-to-high. The script's output is zstd-compressed and embedded into every release binary, so a subtle regression here reaches every user who bundles for the browser. The target switch changes resolution semantics for every polyfill simultaneously — the PR description enumerates the observed differences (punycode exports, http.js/stream.js self-references removed, crypto.js 478 KB → 744 KB), and the NodePolyfills snapshot pins the export shapes, but a human diffing a few of the generated .js files (particularly crypto.js, http.js, stream.js) against the old output would catch anything the snapshot misses. The requireShim regex is intentionally brittle and depends on the exact minified shape of Bun's browser __require prelude; the follow-up includes("__require") guard turns drift into a build failure rather than silent breakage, which is the right design.

Other factors

The change is well-motivated (fixes a real user-facing bug where createHash et al. throw at runtime after a successful build), well-commented (the plugin comment explains why external alone can't work), and the test uses external known-answer vectors per REVIEW.md's codec guidance. The snapshot update is additive only — I diffed the old vs new lines and only punycode changed. No CODEOWNERS entry covers these paths. The PR description references #41315 (a parallel move to esbuild for this same script), which is a coordination point a maintainer should be aware of but not a blocker. The bug hunt exited on dry_streak with no findings and no ruled-out candidates.

Comment thread src/node-fallbacks/build-fallbacks.ts Outdated
Comment on lines +9 to +19
// Every node builtin stays a bare `require("x")` / `import "x"` in the output.
// The user's `--target=browser` build resolves it to the matching polyfill, so
// one `Buffer` (or `EventEmitter`, or `Stream`) is shared by all of them.
//
// The polyfills are bundled with `target: "browser"` so the npm packages
// resolve through their `browser` field. `crypto-browserify` depends on it:
// the node `main` of `randombytes`, `create-hash`, `create-hmac` and `pbkdf2`
// is `require("crypto").X`, which in a browser bundle is the polyfill itself.
//
// `external` cannot express this: with the browser target the resolver picks
// the polyfill before it checks the externals list. A plugin runs first.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/node-fallbacks/build-fallbacks.ts Outdated
Comment on lines +33 to +34
// `require` in the browser output is the runtime's `__require` shim. The text
// is rewritten to a plain `require(...)` below, which leaves the shim unused.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/node-fallbacks/build-fallbacks.ts Outdated
Comment on lines +9 to +10
// A plugin, not `external`: with the browser target the resolver picks the
// polyfill before it reads the externals list.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

build-fallbacks.ts bundled each polyfill with --target=node. That
ignores the package.json "browser" field, so the crypto-browserify
dependencies randombytes, create-hash, create-hmac and pbkdf2 resolved
to their node entry: require("crypto").X. In a browser bundle that is
the crypto polyfill itself, and createHash, createHmac, randomBytes,
pbkdf2Sync and createCipheriv threw "is not a function".

The script now calls Bun.build() with target "browser". A plugin keeps
every node builtin external, because with the browser target the
resolver picks the polyfill before it reads the externals list.
@robobun
robobun force-pushed the robobun/44155f9f/crypto-polyfill-browser-field branch from 8b2ea73 to 33ca3a3 Compare September 6, 2026 10:11

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

Code review found no issues

No high-confidence issues detected in this change.

7flash pushed a commit to 7flash/bun that referenced this pull request Oct 14, 2026
When `Bun.$.braces("")` is called with an empty string, the tokenizer
produces zero tokens. `flattenTokens` then accesses
`self.tokens.items[0]` unconditionally, causing an index-out-of-bounds
panic. Additionally, `Parser.advance()` unconditionally calls `prev()`
which underflows when `current == 0`.

The fix adds an early return when the token list is empty in
`flattenTokens`, and guards `advance()` to return `peek()` instead of
underflowing `prev()` when `current == 0`.

---
Verification (robobun, iteration 4): Build oven-sh#41524 (commit 7045ab1)
completed — 5 failing tests are all unrelated (bun-upgrade, issue/8254,
webview timeout, issue/24364, bun-types), none in shell/braces. Build
oven-sh#41555 (CI retry commit 477200b, empty) is pending. Diff: 2 files, 8
added / 1 removed — `flattenTokens` early-return guard at
braces.zig:591, `current > 0` guard in `advance()` at braces.zig:310,
regression test at brace.test.ts:53-57 covering default, parse:true, and
tokenize:true modes. Test would crash on main due to unchecked
`self.tokens.items[0]` and usize underflow in `self.current - 1`. No
TODO/FIXME/HACK in diff, no unrelated changes. CodeRabbit: no actionable
comments.

---------

Co-authored-by: SUZUKI Sosuke <sosuke@bun.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.

2 participants