@invilite/eventbus
v1.1.0
Published
High-performance drop-in replacement for Node.js EventEmitter with wildcard pattern support
Readme
@invilite/eventbus
A high-performance drop-in replacement for Node.js EventEmitter with wildcard pattern support.
Features
- Drop-in compatible with
node:eventsEventEmitter- same API, same semantics - Wildcard patterns -
*matches one segment,**matches zero or more trailing segments onMatch()- wildcard listeners that also receive the concrete event name, without changingon()'s calling convention- Copy-on-write snapshots - reentrancy-safe listener arrays without per-emit
slice()allocation - Compiled dispatch cache - wildcard patterns compiled to dispatch plans, cached for O(1) steady-state emit
- Zero runtime dependencies - pure TypeScript, zero deps in the published package
- V8 optimized - stable hidden classes, monomorphic call sites, TURBOFAN-optimized hot paths
Performance
Benchmarked against Node.js native EventEmitter on Node 24 / V8 13.6:
| Scenario | EventBus | Native | Speedup | |---|---|---|---| | emit 1 listener | 140M ops/sec | 76M ops/sec | +86% | | emit 10 listeners | 55M ops/sec | 26M ops/sec | +112% | | once + emit | 19M ops/sec | 14M ops/sec | +45% | | activated cached exact (no wildcard match) | 142M ops/sec | - | Same as exact-only | | activated wildcard emit | 108M ops/sec | - | - | | activated combined (exact + wildcard) | 84M ops/sec | - | - |
V8 diagnostics: emit, emitCompiled and every dispatch closure reach TURBOFAN_JS. emit and the dispatch closures show zero deoptimizations. emitCompiled is shared by all activated emitters, so a process that puts several different plan shapes through it shows a small, fixed number of one-time inline-cache widenings there (a single-shape emitter shows none, and the count does not grow with emit volume - it is the same at 200k and at 2M emits).
Cost of onMatch name injection
Delivering the concrete event name costs one extra call frame, so onMatch is measurably slower than on - which is exactly why it is a separate method rather than a change to on. Measured in the same bench:wild runs (medians of 3), so the pairs are directly comparable:
| Scenario | on | onMatch | Cost |
|---|---|---|---|
| 1 wildcard listener, cached | 105M ops/sec | 70M ops/sec | -33% |
| combined (exact + wildcard), cached | 80M ops/sec | 63M ops/sec | -22% |
| dynamic miss + compile | 2.02M ops/sec | 1.24M ops/sec | -38% |
The miss path is the widest gap because each newly seen name also allocates its name-bound wrapper. Plans containing no onMatch registration are unaffected: they execute byte-identical code to the version before onMatch existed, which is a structural property of the two-factory design, not a benchmark result.
onMatch('**') (any-event) measures 63M ops/sec on a cached name, and onMatch under a concrete name 62M ops/sec.
Installation
npm install @invilite/eventbusQuick start
import EventBus from '@invilite/eventbus';
// Drop-in replacement for EventEmitter
const eventBus = new EventBus();
eventBus.on('data', (payload) => { console.log(payload); });
eventBus.emit('data', { value: 42 });API
new EventBus()
Creates a new emitter instance.
const eventBus: EventBus = new EventBus();on(eventName, listener)
Registers a listener for eventName. Returns this for chaining.
eventBus.on('user.created', (user) => { /* ... */ });once(eventName, listener)
Registers a one-time listener. Removed before the callback runs, so reentrant emits cannot re-fire it.
eventBus.once('init', () => { console.log('called once'); });onMatch(pattern, listener)
Registers a listener that also receives the concrete emitted event name: listener(eventName, arg). This is how a wildcard listener learns which event fired. Returns this for chaining.
eventBus.onMatch('user.*', (eventName, payload) => {
console.log(eventName); // 'user.created', 'user.deleted', ...
});
eventBus.emit('user.created', { id: 1 });Remove it with off() / removeListener() - there is no separate offMatch, because removal is by listener identity.
onceMatch(pattern, listener)
One-shot onMatch(). Consumed by the first matching concrete event globally, and removed before the callback runs.
addListener(eventName, listener)
Alias for on().
prependListener(eventName, listener)
Adds the listener to the beginning of the listener array for eventName.
eventBus.on('e', () => { console.log('second'); });
eventBus.prependListener('e', () => { console.log('first'); });
eventBus.emit('e', 1); // Output: first, secondprependOnceListener(eventName, listener)
Adds a one-time listener to the beginning of the listener array.
emit(eventName, arg?)
Emits eventName with a single optional argument. Returns true if any listener was invoked, false otherwise.
eventBus.emit('data', payload);
eventBus.emit('ping'); // arg is undefinedoff(eventName, listener)
Alias for removeListener().
removeListener(eventName, listener)
Removes the most recently added matching listener. Searches from the end (performance optimization).
removeAllListeners(eventName?)
Removes all listeners for eventName, or all listeners if called without arguments.
eventBus.removeAllListeners('data'); // removes all listeners for 'data'
eventBus.removeAllListeners(); // removes all listeners entirelylisteners(eventName)
Returns a copy of the listener functions registered for eventName.
rawListeners(eventName)
Returns the same as listeners() - EventBus stores once as a flag, not a wrapper, so there are no raw wrappers to expose.
listenerCount(eventName)
Returns the number of listeners for eventName.
eventNames()
Returns an array of all event names with registered listeners.
setMaxListeners(n) / getMaxListeners()
Stores/returns the max listeners value. Like EventEmitter, EventBus does not enforce the limit or emit warnings - this is a storage-only no-op.
static getChannel(name?)
Returns a shared, process-lifetime instance for name. Same name always returns the same instance. Omit name for a default channel keyed by an internal symbol (distinct from any string).
const channel = EventBus.getChannel('my-app');
channel.on('event', handler);
EventBus.getChannel('my-app').emit('event', data); // same instance, handler firesWildcard patterns
EventBus supports wildcard patterns for flexible event matching:
*matches exactly one segment**matches zero or more trailing segments (only valid as the final segment)- Delimiter is
.(fixed) - Only a complete segment equal to
*or**is a wildcard;user*nameis a literal event name
const eventBus = new EventBus();
// * matches one segment
eventBus.on('user.*.created', (data) => { /* matches user.account.created, user.profile.created */ });
eventBus.emit('user.account.created', { id: 1 }); // fires
// ** matches zero or more trailing segments
eventBus.on('user.**', (data) => { /* matches user, user.account, user.account.created, etc. */ });
eventBus.emit('user', data); // fires
eventBus.emit('user.account', data); // fires
eventBus.emit('user.a.b.c', data); // fires
// ** alone matches everything
eventBus.on('**', (data) => { /* fires on any event */ });Listener ordering
For a single emission:
- Exact listeners fire first, in FIFO (registration) order
- Wildcard listeners fire second, in global registration order (sequence number)
Exact and wildcard listeners are never interleaved.
Wildcard once
once() with a wildcard pattern is consumed by the first matching concrete event globally, not per cached event name. The registration is removed before the callback runs, so reentrant emits cannot re-fire it.
eventBus.once('user.*', handler);
eventBus.emit('user.account', 1); // fires, consumed
eventBus.emit('user.profile', 1); // does NOT fire - already consumedLearning which event fired
A listener registered with on() receives only the emit argument, so a pattern listener cannot tell user.created from user.deleted. Use onMatch() / onceMatch() to get the concrete name:
eventBus.on('user.*', (payload) => { /* no way to know which event this was */ });
eventBus.onMatch('user.*', (eventName, payload) => { /* eventName is the concrete name */ });The two conventions coexist on the same pattern and fire in registration order:
eventBus.on('user.*', (payload) => {}); // called as fn(payload)
eventBus.onMatch('user.*', (eventName, payload) => {}); // called as fn(eventName, payload)Notes:
on()/once()are unchanged. Their listeners still receive exactly one argument. This is deliberate: a single plain wildcard listener is dispatched as the user's own function with no wrapper, which is the fastest wildcard path. OnlyonMatchregistrations pay for name injection.- The delivered name is exactly the string passed to
emit(), not a normalized form. Empty segments are ignored when matching but preserved in the name:emit('user..account.')delivers'user..account.'. onMatch()also accepts a wildcard-free name. The listener then fires for that name only, in the wildcard phase - i.e. after all exact listeners for it, regardless of registration order - and it permanently activates the wildcard dispatch path on that emitter.
onMatch('**') as an any-event hook
** matches every event, so onMatch('**', handler) is the sanctioned "listen to everything":
eventBus.onMatch('**', (eventName, arg) => { log(eventName, arg); });One caveat for high-cardinality names (user.<uuid>.updated, request ids, ...): because ** matches everything, no event name is ever negatively cached. Every distinct name compiles and caches a plan, so the 1024-entry dynamic cache churns and each cache miss allocates. Memory stays bounded (batch eviction), but throughput drops to the dynamic-miss rate and GC pressure rises. For a bounded set of names this does not apply.
Introspection with wildcards
listeners('user.*')returns registrations stored under that literal patternlisteners('user.account')returns exact registrations only (does not resolve matching wildcards)listenerCount('user.account')is exact-only; do not use it to predict totalemitfan-outeventNames()includes both exact names and registered wildcard patternslisteners(pattern)returnson()andonMatch()handlers indistinguishably - both are bare functions with no marker, solisteners(p).forEach(f => f(payload))would passpayloadas theeventNameof a named handler- For a name registered via
onMatch(concreteName, ...),listeners()andlistenerCount()report both stores (exact listeners first), andeventNames()lists the name once off(concreteName, fn)searches the exact store first, then theonMatchstore
prependListener on wildcard patterns
prependListener('user.*', fn) treats the call as on('user.*', fn) - wildcard listeners use global registration sequence, not positional ordering, so "prepend" has no meaningful effect. The listener is added with the current sequence number.
Invalid patterns
Patterns with ** not in the final position throw TypeError:
eventBus.on('user.**.created', fn); // throws TypeError
eventBus.on('**.created', fn); // throws TypeErrorHow it works
Copy-on-write (COW) snapshots
Listener arrays are never mutated in place. When a listener is added or removed, a new packed array is created and replaces the map value. emit iterates the array reference directly - no slice() allocation per emit. This preserves Node's snapshot semantics:
- Listeners added during emission do not fire this round
- Listeners removed during emission still fire from the snapshot
- Nested emissions observe the newest map value
Compiled dispatch cache
When the first wildcard pattern is registered, the emitter activates:
- Existing exact listeners move to an
exactMap(source of truth) - A
dispatchMapis created as the compiled dispatch cache - Plans are compiled for known exact event names (pinned)
- A shared module-level
emitCompiledfunction replaces the prototypeemit
For each concrete event name, the compiler produces the cheapest valid representation:
- No wildcard match: store the exact
Storedvalue directly (same shape as exact-only) - One persistent wildcard, no exact: store the bare listener function (fast path)
- Combined: store a compiled dispatch closure with the immutable plan
onMatch registrations are marked at registration time, so the choice of dispatch representation happens at compile time, not per emit. A plan with no onMatch registration uses exactly the representations above; a plan containing one uses a name-injecting variant. Plans without named registrations therefore execute the same code they did before onMatch existed.
Subsequent emits of the same name are a single Map.get cache hit - no string parsing, no trie traversal, no allocation.
Bounded cache
Dynamic event names (first emit of an unseen name) are compiled and cached. The cache is bounded to 1024 entries. When full, batch eviction removes all dynamic entries while keeping pinned (registered via on) entries. This has zero cost on cache hits.
Activation is one-way
Once an emitter registers a wildcard, it stays activated even if all wildcards are removed. removeAllListeners() clears all state but retains the activated dispatch path. An emitter that never registers a wildcard uses the untouched prototype emit - zero overhead.
Compatibility with node:events
EventBus is a drop-in replacement with documented intentional divergences:
emit()accepts a single optionalarg(no spread)setMaxListeners()is a storage-only no-op (no enforcement, no warning)removeListener()searches from the end (removes the last matching occurrence, not the first)rawListeners()returns the same aslisteners()(no wrapper functions)captureRejectionsis not supported- Async iterators (
events.on) are not supported
onMatch() / onceMatch() are EventBus extensions with no native counterpart (native has no wildcard patterns at all), which is why their two-argument calling convention is free. The single-argument contract of on() / once() is unchanged.
License
GPL-3.0
