Skip to content

@glandjs/emitter@1.1.4

@glandjs/emitter

A zero-dependency event emitter with tree-based routing, wildcard matching and a hot-path listener cache. Three methods — on, off, emit.

@glandjs/emitter exposes three methods — on, off and emit — plus a constructor. Nothing else is public. It is the primitive @glandjs/events is built on, and it is equally usable on its own: in a library, a CLI, a browser bundle or a test harness.

Terminal window
pnpm add @glandjs/emitter
new EventEmitter<T>(splitter?: string, maxCacheSize?: number)

Both arguments are positional.

Parameter Type Default Description
splitter string ':' Character that separates the segments of an event name.
maxCacheSize number 64 How many event names to keep in the hot-path listener cache.
const emitter = new EventEmitter() // 'a:b' namespaces, cache of 64
const dashed = new EventEmitter('-', 128) // 'a-b' namespaces, cache of 128
const slashed = new EventEmitter('/') // 'a/b' namespaces, default cache
on<K extends keyof T & string>(event: K, listener: (payload: T[K]) => void): this

Registers listener for event. The same function may be registered more than once; each registration is invoked separately.

Returns the emitter, so calls can be chained.

emitter.on('user:login', (payload) => {
console.log(payload.username)
})

Registering the same event is order-independent — a name can be used as a leaf (a:b) and as a branch (a:b:c) in any sequence.

emitter.on('a:b:c', handlerDeep)
emitter.on('a:b', handlerLeaf) // fine, in either order
off<K extends keyof T & string>(event: K, listener?: (payload: T[K]) => void): this

Removes a subscription. With no listener, every listener for that event is removed.

emitter.off('user:login', handler) // remove one
emitter.off('user:login') // remove every listener for the event

Removing a listener only affects that exact event name. Sibling branches are left intact:

emitter.on('a:b', handlerLeaf)
emitter.on('a:b:c', handlerDeep)
emitter.off('a:b', handlerLeaf)
emitter.emit('a:b:c', payload) // handlerDeep still runs

If the same function was registered more than once, each off call removes one registration. Empty branches are pruned from the tree, and a path is only pruned when it holds neither a listener nor a child.

Calling off for an event that was never registered, or for a listener that is not registered, is a no-op.

emit<K extends keyof T & string>(event: K, payload: T[K]): this

Calls every listener registered for event, in registration order, passing payload. Returns the emitter.

emitter.emit('user:login', { username: 'alice', timestamp: Date.now() })

If no listener matches, emit does nothing and still returns the emitter.

Event names are split on the splitter and stored as a tree, so a name can be subscribed to at any depth. Only the fully-qualified name matches exactly — the emitter does not fire a parent listener for a child event.

emitter.on('db:query:success', onSuccess)
emitter.on('db:query:error', onError)
emitter.emit('db:query:success', payload) // only onSuccess runs
emitter.emit('db:query', payload) // nothing runs

A * segment matches exactly one segment. It can appear at any position.

emitter.on('user:*', onUserEvent) // user:login, user:logout
emitter.on('data:*:changed', onDataChange) // data:user:changed, data:post:changed
emitter.emit('user:login', payload)
emitter.emit('data:user:changed', payload)

Rules:

  • * matches one segment, never more. on('user:*') does not receive user:login:extra.
  • An exact listener and a wildcard listener can both match; both run.
  • Exact matches run before wildcard matches at the same segment.
  • Emitting a name that literally contains * matches only the exact subscription, once.
// Typed: exact names only
const typed = new EventEmitter<AppEvents>()
// Untyped: wildcards available
const loose = new EventEmitter()
loose.on('db:*', audit)

Resolving a listener means walking the tree. To keep hot events off that path, emit caches the resolved listener array per event name, keyed by LRU, up to maxCacheSize names.

The cache is dropped entirely whenever on or off is called, so a listener added or removed during runtime is always picked up on the next emit.

maxCacheSize matters more than it looks. If the number of live event names exceeds it, entries are evicted and those events fall back to a tree walk on every emit:

Distinct events maxCacheSize: 6 maxCacheSize: 64
10 ~112K ops/sec ~3.6M ops/sec
50 ~110K ops/sec ~10M ops/sec

Set it to at least the number of event names your application emits regularly.

Pass a map of event names to payloads to get type checking on both on and emit.

interface Events {
'user:login': { username: string; timestamp: number }
'user:logout': { username: string; timestamp: number }
'data:update': { key: string; value: unknown; timestamp: number }
}
const emitter = new EventEmitter<Events>()
emitter.on('user:login', (data) => {
data.username // string
data.missing // compile error
})
emitter.emit('user:login', { username: 'alice', timestamp: Date.now() })

For an event that carries no data, type the payload as void and emit undefined.

const emitter = new EventEmitter<{ ping: void }>()
emitter.on('ping', () => console.log('pong'))
emitter.emit('ping', undefined)

Without a type argument, any string is accepted and the payload is any.

A listener that throws propagates the error to the caller of emit, and the remaining listeners for that event are skipped. This matches the behaviour of Node’s own EventEmitter.

emitter.on('risky', () => {
throw new Error('boom')
})
emitter.on('risky', () => console.log('never reached'))
emitter.emit('risky', payload) // throws

Wrap listener bodies in try / catch when one listener must not affect the others.

Event names are stored in null-prototype maps, so names that also exist on Object.prototype — constructor, __proto__, toString, valueOf, hasOwnProperty — are safe to use and never reach the prototype chain.

emitter.on('constructor', handler) // valid
emitter.emit('constructor', payload) // calls handler

The empty string is a valid event name.

  • Synchronous. Listeners run during emit, in registration order. An async listener is allowed, but emit does not await it.
  • Re-entrant safe. Adding or removing listeners during a dispatch is safe: changes take effect on the next emit, and the in-flight loop is not corrupted.
  • Instances are independent. Nothing is global; create as many as you need.
  • CommonJS. One ES2021 entry point with a .d.ts, no subpath exports.
  • Zero dependencies. The dist/ folder is the entire surface.

Not on this emitter. Not on purpose — that, along with error helpers, async modes and listener counts, is exactly what keeps the package this small. Compose what you need above it, or use @glandjs/events, which has all of it.

function once<T>(emitter: EventEmitter<T>, event: string, listener: (p: any) => void) {
const wrapped = (payload: any) => {
emitter.off(event, wrapped)
listener(payload)
}
emitter.on(event as any, wrapped)
}

See the emitter changelog, and the generated API reference for the exhaustive version of this page.