Capture rejections from any thenable in the browser node:events polyfill

RO
1mo ago
+75-6

The node:events polyfill used for --target=browser only captured rejections from a native, same-realm Promise. Any other thenable returned by a listener was dropped silently: no 'error' event, no Symbol.for("nodejs.rejection") hook, no unhandled rejection. captureRejections is the safety net for async listeners, so a rejection that disappears is worse than no net at all.

Separately, a rejection from a .once() listener was dropped for the same reason, even for a native promise.

Repro

// bun build --target=browser entry.js
import { EventEmitter } from "node:events";

const ee = new EventEmitter({ captureRejections: true });
ee.on("error", err => console.log("captured", err.message));
ee.on("boom", () => ({ then(_, reject) { reject(new Error("kaboom")); } }));
ee.emit("boom");
// node:  captured kaboom
// bun:   (nothing)

const once = new EventEmitter({ captureRejections: true });
once.on("error", err => console.log("captured", err.message));
once.once("bang", async () => { throw new Error("kaboom"); });
once.emit("bang");
// node:  captured kaboom
// bun:   unhandled rejection, the script dies

Cause

Two independent gaps in src/node-fallbacks/events.js, both on the path from a listener's return value to addCatch.

emitWithRejectionCapture gated on

result !== undefined && typeof result?.then === "function" && result.then === Promise.prototype.then

The identity check against Promise.prototype.then excludes plain thenables, cross-realm promises, and promise-likes from userland libraries. Node duck-types .then on any non-nullish listener return value instead. A second divergence came out of the same line: the .then read already happened during the check, so a throwing then getter propagated out of emit() rather than reaching the 'error' event, and it could be read twice.

onceWrapper never returned its listener's result, so result was always undefined for a .once() or .prependOnceListener() listener and the gate was never entered at all. Node's onceWrapper and the runtime's own copy (src/js/node/events.ts:314) both return it.

Fix

addCatch now reads .then once, checks it is callable, and calls it as then.call(value, undefined, onRejected). A throw from the getter or from the call itself is routed to the 'error' event. The call site gates on result !== undefined && result !== null. onceWrapper returns the listener's result.

All five behaviors were checked against Node v26.3.0: the then getter is read exactly once (Promises/A+ compat), then receives undefined as onFulfilled, a throwing getter emits 'error' synchronously, emit() still returns true, and a .once() listener's rejection reaches 'error'.

The runtime's own copy of the addCatch bug, src/js/node/events.ts, is already fixed in #32814 (reviewed, CI green, awaiting a maintainer), which does not touch the browser polyfill, so that file is left alone here.

Verification

Three tests in test/bundler/bundler_browser.test.ts, all of which fail against main's src/:

  • browser/NodeEventsCaptureRejectionsThenable : a rejecting thenable reaches the 'error' listener, and onFulfilled is undefined. Before: no output.
  • browser/NodeEventsCaptureRejectionsThrowingThenGetter : a throwing then getter reaches the 'error' listener and emit() returns true. Before: the throw escapes emit() and kills the script.
  • browser/NodeEventsCaptureRejectionsOnceListener : .once() and .prependOnceListener() rejections reach the 'error' listener. Before: unhandled rejection, the script dies.

bun bd test test/bundler/bundler_browser.test.ts is green (16 pass, 1 skip, 1 todo).