npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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:events EventEmitter - 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 changing on()'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/eventbus

Quick 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, second

prependOnceListener(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 undefined

off(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 entirely

listeners(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 fires

Wildcard 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*name is 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:

  1. Exact listeners fire first, in FIFO (registration) order
  2. 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 consumed

Learning 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. Only onMatch registrations 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 pattern
  • listeners('user.account') returns exact registrations only (does not resolve matching wildcards)
  • listenerCount('user.account') is exact-only; do not use it to predict total emit fan-out
  • eventNames() includes both exact names and registered wildcard patterns
  • listeners(pattern) returns on() and onMatch() handlers indistinguishably - both are bare functions with no marker, so listeners(p).forEach(f => f(payload)) would pass payload as the eventName of a named handler
  • For a name registered via onMatch(concreteName, ...), listeners() and listenerCount() report both stores (exact listeners first), and eventNames() lists the name once
  • off(concreteName, fn) searches the exact store first, then the onMatch store

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 TypeError

How 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:

  1. Existing exact listeners move to an exactMap (source of truth)
  2. A dispatchMap is created as the compiled dispatch cache
  3. Plans are compiled for known exact event names (pinned)
  4. A shared module-level emitCompiled function replaces the prototype emit

For each concrete event name, the compiler produces the cheapest valid representation:

  • No wildcard match: store the exact Stored value 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 optional arg (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 as listeners() (no wrapper functions)
  • captureRejections is 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