Events
A small, fully-typed event emitter with async handler support, one-shot listeners, wildcard subscriptions, and a fake implementation for tests.
Introduction
@tekir/emitter exports an Emitterclass that accepts a generic event map so every listener and emission is fully typed. Unlike Node's built-in EventEmitter, handlers can be async: emit() awaits each handler in registration order before resolving.
// services.ts
import { service } from '@tekir/core'
import type { Emitter } from '@tekir/emitter'
export const emitter = service<Emitter>('emitter')Register EmitterProvider in your kernel:
// start/kernel.ts
import type { TekirApp } from '@tekir/core'
import { EmitterProvider } from '@tekir/emitter'
export default function({ app }: TekirApp) {
app.registerAll([EmitterProvider])
}Creating an Emitter
Declare an event map type to get TypeScript inference on event names and their payloads, then construct an Emitter with it. The createEmitter factory is a convenience alias.
import { Emitter } from '@tekir/emitter'
// Define your event map for full type safety.
type AppEvents = {
'user:created': { id: number; email: string }
'order:placed': { orderId: string; total: number }
'payment:failed': { orderId: string; reason: string }
}
const emitter = new Emitter<AppEvents>()import { createEmitter } from '@tekir/emitter'
// Factory helper: equivalent to new Emitter()
const emitter = createEmitter<AppEvents>()on / emit
emitter.on(event, handler) registers a persistent listener and returns a cleanup function. emitter.emit(event, data) is async; it resolves only after every registered handler has settled.
// Register a persistent listener.
// on() returns a cleanup function: call it to unsubscribe.
const off = emitter.on('user:created', async (data) => {
// data is typed as { id: number; email: string }
await sendWelcomeEmail(data.email)
})
// Emit the event: all async listeners are awaited in registration order.
await emitter.emit('user:created', { id: 1, email: '[email protected]' })
// Remove the listener when no longer needed.
off()once
emitter.once(event, handler) fires the handler on the next emission of the event, then removes it automatically.
// Register a one-shot listener, fired on the next emission, then removed.
emitter.once('order:placed', async (data) => {
console.log(`First order ever: ${data.orderId}`)
})
await emitter.emit('order:placed', { orderId: 'ORD-001', total: 99.99 })
// Listener fires once.
await emitter.emit('order:placed', { orderId: 'ORD-002', total: 49.99 })
// Listener is gone: nothing fires.off
emitter.off(event, handler) removes a specific listener by function reference. The handler must be the same function object that was passed to on or once. The cleanup function returned by on is often more convenient.
async function handler(data: { id: number; email: string }) {
await sendWelcomeEmail(data.email)
}
// Register
emitter.on('user:created', handler)
// Later, unsubscribe by passing the same function reference.
emitter.off('user:created', handler)
// Alternatively, use the cleanup function returned by on():
const cleanup = emitter.on('user:created', handler)
cleanup()onAny
emitter.onAny(handler) subscribes to every event emitted by this emitter. The handler receives the event name and the data as arguments. Like on, it returns a cleanup function.
// Receives EVERY event regardless of name.
// Useful for logging, tracing, or debugging.
const removeAny = emitter.onAny((event, data) => {
console.log(`[event] ${event}`, data)
})
await emitter.emit('user:created', { id: 1, email: '[email protected]' })
// Logs: [event] user:created { id: 1, email: '[email protected]' }
// Stop receiving all events
removeAny()onError
By default, errors thrown inside handlers propagate out of emit(). Register an error handler with emitter.onError() to intercept and suppress them, for example, to forward errors to a monitoring service without breaking the calling code.
// By default, errors thrown inside handlers are re-thrown and abort emission.
// Register a global error handler to swallow and log instead.
emitter.onError((event, error) => {
console.error(`Error in listener for "${event}":`, error.message)
// Optionally report to your error tracker here.
})emitSync
emitter.emitSync(event, data) fires handlers synchronously without awaiting their return values. Async handlers that reject are routed to the onError handler if one is registered. Use this when you need non-blocking fire-and-forget semantics.
// Fire-and-forget, does not await async handlers.
// If a handler returns a Promise that rejects, the rejection is sent
// to the onError handler (if registered) instead of propagating.
emitter.emitSync('order:placed', { orderId: 'ORD-003', total: 20.00 })
// Execution continues immediately: handlers run concurrently.wait
emitter.wait(event, options?) returns a Promise that resolves with the first emission of the event. Pass timeout (milliseconds) to reject if the event never fires, or a signal for external cancellation via AbortController.
// Suspend until the next emission of an event.
// Returns the event data when it fires.
const data = await emitter.wait('payment:failed')
console.log(data.reason) // typed as string
// With a timeout: throws if the event is not emitted within 5 seconds.
try {
const order = await emitter.wait('order:placed', { timeout: 5000 })
} catch {
console.error('Timed out waiting for order:placed')
}
// With an AbortSignal for external cancellation.
const controller = new AbortController()
setTimeout(() => controller.abort(), 3000)
const result = await emitter.wait('user:created', {
signal: controller.signal
})events (async iterator)
emitter.events(event, options?) returns an AsyncIterable that yields event data each time the event is emitted. The iterable buffers events that arrive before the consumer calls next(). Pass an AbortSignal to stop the stream from outside the loop.
// Consume events as an async iterable stream.
// Useful for processing a sequence of events in a loop.
for await (const data of emitter.events('order:placed')) {
console.log('New order:', data.orderId)
// Break or use an AbortSignal to stop iteration.
}
// With an AbortSignal to stop the stream externally:
const controller = new AbortController()
setTimeout(() => controller.abort(), 10_000)
for await (const data of emitter.events('order:placed', { signal: controller.signal })) {
await processOrder(data)
}clearListeners
Remove listeners for a specific event or all events at once. Also useful in tests to ensure a fresh emitter state between cases.
// Remove all listeners for a specific event.
emitter.clearListeners('user:created')
// Remove all listeners for all events (including onAny).
emitter.clearListeners()
// Inspect counts before clearing.
console.log(emitter.listenerCount('user:created')) // number
console.log(emitter.listenerCount()) // total across all eventsDecorators
@tekir/event-decorators provides class and method decorators for a declarative approach. Decorate a class with @Listener(), then annotate methods with @On(event) for persistent listeners or @Once(event) for one-shot listeners.
import { Listener, On, Once } from '@tekir/event-decorators'
@Listener()
class UserEventListener {
@On('user:created')
async onUserCreated(data: { id: number; email: string }) {
await sendWelcomeEmail(data.email)
}
@Once('user:created')
async onFirstUser(data: { id: number; email: string }) {
console.log('First user ever:', data.email)
}
@On('order:placed')
async onOrderPlaced(data: { orderId: string; total: number }) {
await processOrder(data)
}
}emitter.register()
emitter.register(listener) accepts a decorated listener instance and automatically binds all @On and @Once methods to the emitter.
import { emitter } from '#services'
import { UserEventListener } from '~/listeners/user_event_listener'
// Register a decorated listener class: all @On and @Once methods are bound.
emitter.register(new UserEventListener())emitter.registerDir()
For projects with many listeners, call emitter.registerDir(path) to load and register every file in a directory in one line. Each file is auto-detected: decorator classes go through emitter.register(Class), functional registrars (export default (emitter) => { ... }) are invoked with the emitter, and classes with a register(emitter) method are constructed and called. Files whose default export does not match any pattern are skipped with a console warning.
// core/listeners/user_event_listener.ts
import { OnEvent } from '@tekir/event-decorators'
export default class UserEventListener {
@OnEvent('user:created')
async sendWelcomeEmail(data) { /* ... */ }
}
// start/boot.ts
import { emitter } from '#services'
// Loads every file in core/listeners/ and registers each one. Auto-detects
// decorator classes, functional registrars, and classes with a
// register(emitter) method. Replaces the long list of explicit imports.
await emitter.registerDir('core/listeners')For bun build --compile, install oxc-parser as a dev dependency (bun add -d oxc-parser) and the compile pipeline replaces every literal-string registerDir call with explicit static imports before Bun bundles, so the binary contains every listener.
Testing with FakeEmitter
Emitter.fake<Events>() returns a FakeEmitter that intercepts all emit() calls and records them in memory without firing any handlers. Pass it wherever your production code accepts an Emitter and use the built-in assertion helpers to verify behavior.
import { Emitter } from '@tekir/emitter'
// Create a FakeEmitter for testing: emit() records calls instead of firing handlers.
const fake = Emitter.fake<AppEvents>()
// Inject the fake emitter into the code under test.
await myService.doSomething(fake)
// Assert that an event was emitted.
const wasSent = fake.assertEmitted('user:created')
console.log(wasSent) // true
// Assert the exact number of times an event was emitted.
const correct = fake.assertEmittedCount('user:created', 1)
console.log(correct) // true
// Retrieve the payloads for custom assertions.
const payloads = fake.getEmitted('user:created')
console.log(payloads[0].email) // '[email protected]'
// Reset between tests.
fake.reset()assertEmitted(event): returnstrueif the event was emitted at least once.assertEmittedCount(event, n): returnstrueif the event was emitted exactlyntimes.getEmitted(event): returns the array of payloads for custom assertions.reset(): clears the recorded emissions between tests.