fix(node/events): allow EventEmitter to be invoked without new#553
fix(node/events): allow EventEmitter to be invoked without new#553jeswr wants to merge 3 commits into
EventEmitter to be invoked without new#553Conversation
In Node.js `EventEmitter` is a plain function, so it can be called without the `new` keyword. Libraries rely on this to inherit from it using the prototypal `EventEmitter.call(this)` pattern (e.g. `readable-stream` and `N3.js`). unenv implemented `EventEmitter` as an ES class, whose constructor throws `TypeError: Class constructor cannot be invoked without 'new'` when called that way, breaking those consumers. Wrap the class in a `Proxy` whose `apply` trap runs the initialization logic against the provided `this`, so both `new EventEmitter()` and `EventEmitter.call(this)` work, matching Node.js behavior. The constructor body is extracted into a shared `initEventEmitter` function, mirroring Node's `EventEmitter.init`. Closes unjs#552 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughEventEmitter was refactored: initialization moved to a shared initEventEmitter helper, the implementation class became non-exported ChangesEventEmitter Callable Invocation Support
Sequence Diagram(s)sequenceDiagram
participant Caller
participant Proxy__EventEmitter as _EventEmitter (Proxy)
participant initEventEmitter
participant _EventEmitterClass
Caller->>Proxy__EventEmitter: call(thisArg, opts?)
Proxy__EventEmitter->>initEventEmitter: initEventEmitter.call(thisArg, opts)
initEventEmitter->>thisArg: initialize internal state (_events, _eventsCount, _maxListeners, kCapture)
Caller->>Proxy__EventEmitter: new EventEmitter(opts)
Proxy__EventEmitter->>_EventEmitterClass: constructor(opts) -> delegates to initEventEmitter
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds Node-compatible EventEmitter callable behavior (supporting EventEmitter.call(this)) by wrapping the internal class with a Proxy, and introduces Vitest coverage to ensure common inheritance/usage patterns keep working.
Changes:
- Refactors
_EventEmitterinitialization into a sharedinitEventEmitter()helper. - Wraps the internal
_EventEmitterclass in aProxyto support function-style invocation. - Adds a new Vitest suite covering call-without-
new,new, subclassing, and static members.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| test/node-events.test.ts | Adds tests validating Node-compatible EventEmitter invocation and inheritance patterns. |
| src/runtime/node/internal/events/events.ts | Refactors initialization and introduces a Proxy wrapper to allow calling the emitter like a function. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/runtime/node/internal/events/events.ts (1)
79-82:⚠️ Potential issue | 🟠 Major | ⚡ Quick winFix
_maxListenersparity betweennew EventEmitter()andEventEmitter.call(this)
_EventEmitterClassinitializes_maxListenersfromdefaultMaxListenersfornew, while the Proxyapplypath skips class field initializers soinitEventEmitterleaves_maxListenersasundefined;_getMaxListenersthen falls back to the (possibly updated)defaultMaxListeners. This makesnewinstances pinned to the old default whilecall(this)instances track later changes.Suggested fix
class _EventEmitterClass implements NodeEventEmitter { // Internal state _events: any = undefined; _eventsCount: number = 0; - _maxListeners: number | undefined = defaultMaxListeners; + _maxListeners: number | undefined = undefined; [kCapture]: boolean = false; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/runtime/node/internal/events/events.ts` around lines 79 - 82, The Proxy apply path (EventEmitter.call(this)) skips class field initializers so instances created via initEventEmitter may leave _maxListeners undefined and later inherit a changed defaultMaxListeners, causing parity differences with new EventEmitter(); modify initEventEmitter to explicitly initialize this._maxListeners to defaultMaxListeners when it is undefined (or not a number) so both construction paths behave identically; update initEventEmitter and mention _maxListeners, defaultMaxListeners, _EventEmitterClass, and _getMaxListeners as the targeted symbols to locate the change.
🧹 Nitpick comments (1)
test/node-events.test.ts (1)
5-23: ⚡ Quick winConsider adding a test for options parameter in callable invocation.
The current test validates callable invocation without arguments. Consider adding a test case that verifies options (e.g.,
captureRejections) are properly handled when passed through the callable path:EventEmitter.call(this, { captureRejections: true });This would ensure complete parity between the constructor and callable invocation paths for all initialization parameters.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/node-events.test.ts` around lines 5 - 23, Add a new test variant that verifies callable invocation forwards the options object to EventEmitter: change the MyStream constructor in a new it-block to call EventEmitter.call(this, { captureRejections: true }) (instead of EventEmitter.call(this)), create the instance via new (MyStream as any)(), then assert that the instance has the option applied (for example expect((stream as any).captureRejections).toBe(true)) and that normal event wiring still works (listenerCount/emit/received). This ensures the callable path (MyStream -> EventEmitter.call) preserves the options parameter.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/runtime/node/internal/events/events.ts`:
- Around line 79-82: The Proxy apply path (EventEmitter.call(this)) skips class
field initializers so instances created via initEventEmitter may leave
_maxListeners undefined and later inherit a changed defaultMaxListeners, causing
parity differences with new EventEmitter(); modify initEventEmitter to
explicitly initialize this._maxListeners to defaultMaxListeners when it is
undefined (or not a number) so both construction paths behave identically;
update initEventEmitter and mention _maxListeners, defaultMaxListeners,
_EventEmitterClass, and _getMaxListeners as the targeted symbols to locate the
change.
---
Nitpick comments:
In `@test/node-events.test.ts`:
- Around line 5-23: Add a new test variant that verifies callable invocation
forwards the options object to EventEmitter: change the MyStream constructor in
a new it-block to call EventEmitter.call(this, { captureRejections: true })
(instead of EventEmitter.call(this)), create the instance via new (MyStream as
any)(), then assert that the instance has the option applied (for example
expect((stream as any).captureRejections).toBe(true)) and that normal event
wiring still works (listenerCount/emit/received). This ensures the callable path
(MyStream -> EventEmitter.call) preserves the options parameter.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 12f078ad-1b16-4c3c-8c03-8162696bc47d
📒 Files selected for processing (2)
src/runtime/node/internal/events/events.tstest/node-events.test.ts
…e wrapper - Use `_EventEmitterClass` for all internal type-position references so the type/value duality is confined to the single public `EventEmitter` export (whose dual nature mirrors the original `export class`). - Document the Proxy `apply` trap: a no-receiver `EventEmitter()` call throws the same `TypeError` as Node rather than silently returning an instance. - Add tests for `.call(plainObject)` and for the no-receiver Node-parity throw. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…forwarding Addresses CodeRabbit review on unjs#553: - `_maxListeners` was initialized to `defaultMaxListeners` as a class field, which pinned `new EventEmitter()` instances to the value at construction time. Node leaves it unset so `getMaxListeners()` resolves dynamically and tracks later `defaultMaxListeners` changes. This also diverged the two construction paths, since the function-style `EventEmitter.call(this)` path skips class field initializers and already left it `undefined`. Init to `undefined` so both paths match Node. - Add tests covering option forwarding (`captureRejections`) through the `.call` path and `defaultMaxListeners` parity across `new` and `.call`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @coderabbitai — both items from your review are addressed in 2442474: 🟠 🧹 Options forwarding (nitpick): Added a test that invokes
|
|
Tip For best results, initiate chat on the files or code changes.
8/8 with clean tooling — great work! 🎉 (ノ◕ヮ◕)ノ*:・゚✧ If you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! |
Linked issue
Closes #552
Problem
In Node.js,
EventEmitteris a plain function, so it can be invoked without thenewkeyword. Libraries inherit from it using the prototypalEventEmitter.call(this)pattern, for examplereadable-streamandN3.js.unenv implements
EventEmitteras an ES class. ES class constructors cannot be[[Call]]-ed, soEventEmitter.call(this)throws:This breaks any consumer that uses the function-style inheritance pattern.
Reproduction (before this PR)
Fix
The constructor body is extracted into a shared
initEventEmitterfunction (mirroring Node'sEventEmitter.init), and the exportedEventEmitteris wrapped in aProxywhoseapplytrap runs that initialization against the providedthis:This keeps the existing class (and all of its methods, statics, and subclasses such as
EventEmitterAsyncResource) untouched, while making the constructor callable as a plain function — matching Node.js behavior:new EventEmitter()→ unchanged (construct trap is the default).EventEmitter.call(this)→ initializesthisin place.class X extends EventEmitter {}→ unchanged.EventEmitter.once,defaultMaxListeners, …) andinstanceof→ unchanged (forwarded by the Proxy).There is no hot-path overhead: instance method calls (
emitter.emit()) never go through the Proxy — only construction and static access do.Tests
Adds
test/node-events.test.tscovering:EventEmitter.call(this)inheritance (the regression),new EventEmitter(),class extends EventEmitter,pnpm test:typesandpnpm buildpass. The two failures intest/env.test.tsandtest/node-coverage.test.tson my machine are pre-existing and unrelated (they reproduce on a clean checkout under Node 25 — new builtin modules / changedpunycodedeprecation-warning format).🤖 Generated with Claude Code
Summary by CodeRabbit
Refactor
newsemantics, maintain subclassing behavior, and keep options (like capture-rejections) and dynamic defaultMaxListeners in sync across invocation paths.Tests
.callbehavior, strict-mode errors, listener registration, event emission, inheritance, static member accessibility, and option propagation.