Skip to content

node-fallbacks: build the browser polyfills with --keep-names - #48629

Open
robobun wants to merge 3 commits into
mainfrom
robobun/8966fb65/node-fallbacks-keep-names
Open

robobun wants to merge 3 commits into
mainfrom
robobun/8966fb65/node-fallbacks-keep-names

Conversation

@robobun

@robobun robobun commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • Pass --keep-names to the polyfill build, as bundle-modules.ts and bundle-functions.ts do. The build fails if an output calls __name() (bundler: implement --keep-names with __name helper #40503). Minified bundles keep today's names.
  • EventEmitter is a function declaration, and events.js, util.js and http.js export 7 functions through other bindings. The flag alone prints them as EventEmitter2, once2, get2. They can go when the bundler stops numbering.
  • Verified: test/bundler/bundler_browser.test.ts (5 new tests fail on Bun 1.4.3). Other suites: Notes.
  • Self-reviewed: 22 concerns raised, 17 addressed. Rejected: a bundler fix first (separate change), a wait for node-fallbacks: bundle the polyfills for the browser target, fix the crypto default export #41524 (one option to carry).

Background

  • src/node-fallbacks/*.js replace Node builtins in a browser build. The bun that builds bun bundles them. Releases embed the result.
  • require("events") gives the bundle the module's exports object. The bundler then numbers function expressions named like an export, in user modules too.
  • Considered: no --minify-syntax (+39,774 B), or Object.defineProperty name lines (one call per name).

Downsides

  • Size: polyfill text +11,345 B (+1.14%), release binary +4,096 B, an events bundle +170 B (+3 B with --minify).
  • Stack traces of unminified bundles change: V8 prints Uint8Array.fill for a Buffer frame (Buffer.fill today).
  • JSC's LLInt and Baseline run one more mov per call of a function with a kept name (emit: 112 to 113 bytecodes).
Notes

How it was found. Node's test/parallel/test-event-emitter-method-names.js asserts E[name].name === name for each EventEmitter.prototype method. Run by hand against a default browser bundle, 13 of 13 checks fail (emit reads emitWithoutRejectionCapture). Bun's runtime passes that test. #48481 lists the gap under "Not covered". There is no issue and no user report.

import { EventEmitter } from "events";
console.log(JSON.stringify([EventEmitter.prototype.on.name, EventEmitter.prototype.once.name, EventEmitter.prototype.removeListener.name]));

History (release binaries, bun build --target=browser, named imports from node:events):

EventEmitter.name once, listenerCount exports prototype methods
1.3.1 EventEmitter2 once2, listenerCount2 node's names
1.3.2 to 1.4.1 EventEmitter once2, listenerCount2 ""
1.4.3 EventEmitter node's names ""
this PR EventEmitter node's names node's names

Only the prototype method names are a regression from 1.3.1. No file under src/node-fallbacks changed between 1.3.1 and 1.3.2. #22492 (2025-09-09) added the name removal to --minify-syntax, with --keep-names as the opt-out. bundle-modules.ts and bundle-functions.ts got keepNames on 2025-10-18 (e9e9ca4, bd15fce). #24053 (2025-10-25) moved the build to bun 1.3.1, and build-fallbacks.ts did not have the flag.

What the user's flags give (the file above):

flags main this PR
none, --minify-whitespace "" node's names
--minify-syntax --keep-names "" node's names
--minify-syntax, --minify, --production "" ""
--minify-identifiers, --minify --keep-names "" one letter

Polyfill text (wc -c of the 23 outputs of build-fallbacks.ts. Host bun 1.4.4, the CI pin, and 1.4.3 write identical files.):

file main PR plain zstd
crypto.js 477,676 486,047 +8,371 +568
assert.js 106,957 107,970 +1,013 +210
buffer.js 41,492 42,216 +724 +152
http.js 75,793 76,282 +489 +119
events.js 11,418 11,811 +393 +68
stream.js 136,143 136,357 +214 +64
util.js 10,908 11,006 +98 +17
zlib.js 80,793 80,820 +27 +3
querystring.js 9,680 9,696 +16 +7
other 14 files +0 +0
total 997,639 1,008,984 +11,345 (+1.14%) +1,208 (+0.63%)
  • The flag adds 1,400 function names and 2 class names. With all function and class names removed from both texts, only events.js, util.js and http.js differ (the declaration and the export bindings).
  • __name( in the 23 outputs: 0. No call is added at module evaluation. The 7 export bindings are plain copies, and a bundle that does not use them drops them.
  • crypto.js has 74% of the added bytes. It stays under the flag: one rule for all 23 files, and an unminified bundle with crypto grows 1.0%.

Release binary (readelf -S and size -A on two release builds in one build directory that differ only in the polyfill outputs. bloaty is not installed.): .rodata 27,239,404 to 27,243,500 B (+4,096), file 143,284,496 to 143,288,592 B (+4,096). The zstd data round up to one 4 KiB page. No other section changes size. These two builds are from before the export bindings in events.js and util.js, which add 54 B of zstd data.

User bundles (debug build of main, main's polyfill text against this PR's, bytes of the output):

entry flags main PR delta
the file above (events) none 13,779 13,949 +170
--minify-whitespace 11,506 11,678 +172
--minify-syntax 13,628 13,629 +1
--minify-syntax --keep-names 13,628 13,806 +178
--minify-identifiers 9,523 9,544 +21
--minify 7,081 7,084 +3
--minify --keep-names 7,081 7,112 +31
all 23 polyfills none 1,365,215 1,376,598 +11,383 (+0.83%)
--minify-syntax 1,356,537 1,356,907 +370
--minify-identifiers 1,105,294 1,108,034 +2,740
--minify 755,390 755,281 -109
import * as m from "node:crypto" none 920,957 930,548 +9,591
--minify 530,449 530,704 +255
the same for http none 387,110 388,816 +1,706
--minify 199,568 199,598 +30
stream none 253,312 254,424 +1,112
--minify 129,205 129,222 +17
assert none 150,400 151,521 +1,121
--minify 78,756 78,787 +31
buffer none 52,963 53,687 +724
--minify 28,289 28,289 +0
util none 14,465 14,549 +84
--minify 8,463 8,476 +13
events none 14,519 14,867 +348
--minify 7,384 7,403 +19

Names against node v26.3.0. A script walks .name of each function that the 23 polyfills export, to depth 3, for import * as and for require(). 964 of those paths also exist in node.

  • Equal to node: 539 on main, 672 with this PR.
  • Right on main and wrong with this PR: 0 of the paths where node has a name.
  • 19 paths that node 26 leaves anonymous now have the name from the polyfill source (Buffer.prototype.readUInt8 and 14 more read methods and aliases, crypto.privateDecrypt and 3 more). Bun's own runtime names them too.
  • Not changed: functions that are anonymous in the polyfill source stay anonymous (Buffer.from, Buffer.alloc, Buffer.allocUnsafe). bun build bundling of CJS require("node:assert") does not properly expose exports. and returns an empty {} object #19808 is not changed.
  • Numbered names (ok2): 73 paths on main, the same 73 with this PR (assert 62, querystring 11).

Stack frames. The kept names also show in stack traces of unminified bundles. JSC: at <anonymous> becomes at fill. V8: at EventEmitter.emitWithoutRejectionCapture [as emit] becomes at EventEmitter.emit, as in node. For Buffer methods V8 prints at Uint8Array.fill where it prints at Buffer.fill today.

The numbered names. src/bundler/linker_context/doStep5.rs:626 gives each export in an exports object a use with no scope. src/js_printer/renamer.rs:1345 then treats that symbol as seen in every nested scope, so a function expression with the same name gets a number. This PR does not change the bundler.

  • A plain user module does the same: export function once() {} with o.once = function once() {} prints once2 behind import * as.
  • It is not only a name. On main, assert.rejects(Promise.resolve(1)) in a browser bundle rejects with Missing expected exception. and operator: "rejects2". Node and Bun 1.4.0 give Missing expected rejection. and "rejects". This PR's text gives the same result as main's.
  • With the flag alone, 8 names that the flag restores meet this rule: EventEmitter2 (right on main), get2 for http.get and 13 getters of stream-http (the getters are right on main), and once2, listenerCount2, getMaxListeners2, setMaxListeners2, inspect2, promisify2 ("" on main). EventEmitter2 shows in each bundle that has node:stream.
  • So EventEmitter is a declaration, which has one binding. The other 7 are exported through a second binding (var httpGet = http.get; export { httpGet as get }), so the export and the function expression do not share a name. When the bundler stops numbering these, the bindings can be removed. The new tests do not depend on them.
  • bun build: class renamed on name collision changes .name, even with --keep-names #44121 is a different cause: two modules that declare one top-level name.

JSC bytecode (BUN_JSC_dumpGeneratedBytecodes=1, a bundle that calls on, once, emit, removeListener): emit 112 to 113 instructions, addListener 58 to 59, once 21 to 22, removeListener 51 to 52, listenerCount 14 to 15. The added instruction is mov loc, callee at function entry. perf and valgrind are not installed, so machine instructions are not measured.

#40503. That PR makes --keep-names wrap functions in __name() calls, and --minify-syntax then drops the inline names again. With a host bun that has it, the new check fails this build. The build that keeps the names inline is then --minify-whitespace alone: 28,774 B more text than this PR (2,692 B zstd). bundle-modules.ts:240 passes the same two flags, so both scripts need that decision together.

Other open PRs. None of them keeps the names, and this PR does not wait for them.

Self-review. 22 concerns. 4 ask for the bundler change above to land first, as its own PR, and for this PR to drop the source edits then. That change is not part of this PR. The edits stay until it exists, and the section above gives the cause. 1 asks to wait for #41524, #41533 and #40503. The other 17 changed the check's message, the tests and this text.

Tests. bun bd test test/bundler/bundler_browser.test.ts: 28 pass. With USE_SYSTEM_BUN=1 (1.4.3) the 5 new tests fail. Without the flag 5 fail. With the named function expression back in events.js 2 fail (EventEmitter2). Without the export bindings of http.js, events.js or util.js 1 fails for each (get2, once2, inspect2). Also on the debug build: bundler_regressions (9 pass), esbuild/packagejson (94 pass), bundler_plugin (68 pass), bundler_splitting (209 pass), bundler_minify -t KeepNames (3 pass). bundler_edgecase, esbuild/default and bun-build-api pass except 5 tests that miss their deadlines on a debug build (DeepImportDiamondDAG, TopLevelAwaitForbiddenRequireSourceMap CLI and API, two Bun.build stress tests). They bundle no polyfill, and the first three fail the same way with main's polyfill text.

build-fallbacks.ts minifies each polyfill with --minify-syntax. That
pass drops the name of a function expression that nothing refers to. So
in a `bun build --target=browser` bundle,
`EventEmitter.prototype.once.name` is "". Node gives "once", and so did
Bun 1.3.1.

Pass --keep-names to the polyfill build, as bundle-modules.ts and
bundle-functions.ts do. The build fails if an output contains a
__name() call.

When a bundle has the exports object of a polyfill, the bundler numbers
each function expression that has the name of an export of that
polyfill. EventEmitter is now a function declaration, and http.js
exports `get` through a binding with another name. So
`EventEmitter.name` and the getters of stream-http keep the names that
they have today.
@coderabbitai

coderabbitai Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

No actionable comments were generated in the recent review.

Walkthrough

The fallback build now enables function-name preservation and checks for __name( in its output. Polyfill declarations and exports use local bindings for selected functions. Browser bundler tests check names in ESM and CommonJS bundles.

Changes

Browser polyfill function names

Layer / File(s) Summary
Fallback build name preservation
src/node-fallbacks/build-fallbacks.ts
The fallback build adds --keep-names and throws an error if the output contains __name(.
Polyfill declarations and exports
src/node-fallbacks/events.js, src/node-fallbacks/http.js, src/node-fallbacks/util.js
The modules use local declarations or aliases to export selected functions under their existing names. EventEmitter is now a function declaration.
Browser bundle name checks
test/bundler/bundler_browser.test.ts
Tests check selected function names in browser bundles, including CLI and API builds with syntax minification and keepNames, and a CommonJS require bundle.

Possibly related PRs

  • oven-sh/bun#41524: Both PRs change the browser Node-polyfill build pipeline; this PR adds --keep-names and checks the build output.
  • oven-sh/bun#41533: Both PRs change browser Node-polyfill exports, including CommonJS behavior in events.js and util.js.

Suggested reviewers: alii

Merge Risk: Minimal · up to 2f3c7

This change preserves function names in browser polyfill bundles and adds tests for it. No actionable merge-blocking risk was identified in the supplied evidence.

Pre-merge checks: Passed 4

Check name Status Explanation
Title check Passed The title clearly and concisely identifies the main change: building browser polyfills with --keep-names.
Description check Passed The description explains the problem, fix, tradeoffs, and verification results. It does not use the template headings exactly, but it provides the required information in equivalent sections.
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.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Recent review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 447ff87 and 2f3c7b1.

Files selected for processing: 5

  • src/node-fallbacks/build-fallbacks.ts
  • src/node-fallbacks/events.js
  • src/node-fallbacks/http.js
  • src/node-fallbacks/util.js
  • test/bundler/bundler_browser.test.ts

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

@robobun

robobun commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:09 PM PT - Nov 11th, 2026

@robobun, your commit 2f3c7b1 is building: #140305

@robobun

robobun commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on bun 1.4.3 and on main 447ff87 with this file:

// names.mjs
import { EventEmitter } from "events";
console.log(JSON.stringify([EventEmitter.prototype.on.name, EventEmitter.prototype.once.name, EventEmitter.prototype.removeListener.name]));
  • node names.mjs and bun names.mjs print ["addListener","once","removeListener"].
  • bun build --target=browser names.mjs --outfile=out.mjs && bun out.mjs prints ["","",""].
  • With this PR, the same bundle prints ["addListener","once","removeListener"].

PR: #48629

@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 thread src/node-fallbacks/build-fallbacks.ts Resolved
Comment thread src/node-fallbacks/events.js Resolved
Comment thread src/node-fallbacks/http.js Resolved
…ough other bindings

The bundler numbers a function expression that has the name of an export
when the bundle has the exports object of the polyfill. The four
EventEmitter.prototype methods and util's inspect and promisify read
`once2`, `inspect2` and so on in that case. Export the six names through
bindings with other names, as http.js does for `get`.

The comments are one line each.

@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 1aabe2d and found no bugs; the aliased once/listenerCount/setMaxListeners/getMaxListeners/inspect/promisify exports and the shortened comments address what I raised on events.js and the comment style. Because --keep-names changes the embedded polyfill bytes in every release binary and the output size of every browser bundle (crypto.js alone +8 KB of text), a maintainer should still weigh that size-for-names tradeoff.

What was reviewed:

  • events.js/util.js aliasing: the four events names are hoisted function declarations and util's inspect/promisify consts are declared above the alias lines, so no TDZ or shadowing; util's default export object is unchanged.
  • The __name( guard in build-fallbacks.ts: no tracked polyfill source contains that text, so it can only trip on bundler output.
  • The new itBundled cases use backend, keepNames, and .options as expectBundled.ts defines them; they assert exact stdout.
  • Not run: USE_SYSTEM_BUN=1 bun test to confirm the five new tests fail without the build change, and bun bd test to confirm they pass.
Extended reasoning...

The change adds --keep-names to the bun build invocation in src/node-fallbacks/build-fallbacks.ts that prebuilds the 23 browser polyfills embedded in release binaries, adds a post-build guard against __name() helpers, restructures events.js, http.js and util.js so exports whose names collide with function expressions go through aliased bindings, and adds five itBundled cases in test/bundler/bundler_browser.test.ts. It touches no security-sensitive surface. The follow-up commit addressed two of the three optional inline comments from the prior run; the PATH-resolved bun concern in build-fallbacks.ts predates this PR and remains as an open thread. Deferring rather than approving because the flag changes release artifact size and every user's browser bundle output, which is a product tradeoff for a maintainer, and because the fail-without-fix test check could not be made by reading.

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