Lockness Events

Lockness Events

VIEW

Modern, type-safe event system for Deno with async support, class-based events, decorator-driven listeners, and full DI integration.

Overview

@lockness/events provides a comprehensive event system with:

  • Type Safety - Generic event types with full TypeScript inference
  • Class-based Events - Extend BaseEvent for structured event data
  • Decorator Listeners - @Listener decorator for auto-discovered event handlers
  • DI Integration - Listeners are managed by the container with full dependency injection
  • Async Support - Native async/await for event listeners
  • Priorities - Execute listeners in priority order
  • Framework Events - Built-in lifecycle events (KernelBooted, RequestStarted, etc.)
  • Testing Utilities - fake() and assertions for testing event-driven code
  • Backward Compatible - String-based events still work via EventEmitter

Installation

typescript
import { BaseEvent, dispatcher, KernelBooted, Listener } from '@lockness/core'

// Or directly from events package
import { BaseEvent, dispatcher, Listener } from '@lockness/events'

Quick Start

1. Define an Event

typescript
// app/events/user_registered.ts
import { BaseEvent } from '@lockness/core'
import type { User } from '#models/user'

export class UserRegistered extends BaseEvent {
    constructor(
        public readonly user: User,
        public readonly ipAddress: string,
    ) {
        super()
    }
}

2. Create a Listener

typescript
// app/listener/user_listener.ts
import { Service } from '@lockness/container'
import { Listener } from '@lockness/core'
import { UserRegistered } from '#events/user_registered'

@Service()
export class UserListener {
    constructor(
        private mail: MailService,
        private analytics: AnalyticsService,
    ) {}

    @Listener(UserRegistered)
    async sendWelcomeEmail(event: UserRegistered) {
        await this.mail.send(event.user.email, 'Welcome!', {
            name: event.user.name,
        })
    }

    @Listener(UserRegistered, { priority: 10 })
    async trackRegistration(event: UserRegistered) {
        await this.analytics.track('user:registered', {
            userId: event.user.id,
            ipAddress: event.ipAddress,
        })
    }
}

3. Emit the Event

typescript
// In a controller or service
import { dispatcher } from '@lockness/core'
import { UserRegistered } from '#events/user_registered'

export class UserController {
    @Post('/register')
    async register(c: Context) {
        const user = await this.userService.create(data)

        // Emit event - all listeners are invoked automatically
        await dispatcher().emit(
            new UserRegistered(user, c.req.header('x-real-ip')),
        )

        return c.json({ user })
    }
}

4. Listeners Are Auto-Discovered

Listeners in app/listener/ are automatically discovered and registered during app bootstrap. No manual registration needed!

API Reference

on(event, listener, config?)

Register an event listener:

typescript
emitter.on('event', (data) => {
    console.log(data)
})

// With priority (higher = executes first)
emitter.on('event', handler, { priority: 10 })

// As one-time listener
emitter.on('event', handler, { once: true })

once(event, listener, config?)

Register a one-time listener (auto-removed after first execution):

typescript
emitter.once('connect', () => {
    console.log('Connected!')
})

await emitter.emit('connect', null) // Fires
await emitter.emit('connect', null) // Doesn't fire (removed)

emit(event, data)

Emit an event to all listeners (async):

typescript
await emitter.emit('user:created', { id: 1, name: 'Alice' })

emitSync(event, data)

Emit without waiting for async listeners:

typescript
emitter.emitSync('log', 'message')
// Returns immediately, listeners run in background

off(event, listener)

Remove a specific listener:

typescript
const handler = (data) => console.log(data)

emitter.on('event', handler)
emitter.off('event', handler) // Removed

removeAllListeners(event?)

Remove all listeners for an event, or all events:

typescript
emitter.removeAllListeners('event') // Remove all for 'event'
emitter.removeAllListeners() // Remove all listeners

onAny(listener, config?)

Listen to all events with wildcard:

typescript
emitter.onAny((payload) => {
    console.log(`Event: ${payload.event}`)
    console.log(`Data:`, payload.data)
})

await emitter.emit('anything', 'data')
// Logs: Event: anything, Data: data

offAny(listener)

Remove a wildcard listener:

typescript
const handler = (payload) => console.log(payload)

emitter.onAny(handler)
emitter.offAny(handler) // Removed

Inspection Methods

typescript
// Get listener count for event
emitter.listenerCount('user:created') // 5

// Get all registered event names
emitter.eventNames() // ['user:created', 'user:deleted']

// Get all listeners for event
emitter.listeners('user:created') // [fn1, fn2, fn3]

// Set/get max listeners warning threshold
emitter.setMaxListeners(20)
emitter.getMaxListeners() // 20

Listener Priorities

Execute listeners in priority order (higher = first):

typescript
emitter.on('load', () => console.log('3rd'), { priority: 0 })
emitter.on('load', () => console.log('1st'), { priority: 10 })
emitter.on('load', () => console.log('2nd'), { priority: 5 })

await emitter.emit('load', null)
// Output: 1st, 2nd, 3rd

Helper Functions

Quick operations on global emitter:

typescript
import { emit, emitSync, off, on, once } from '@lockness/events'

on('event', handler)
await emit('event', data)
off('event', handler)

Utilities

Event Bus

Create isolated event emitters:

typescript
import { createEventBus } from '@lockness/events'

const bus1 = createEventBus()
const bus2 = createEventBus()

bus1.on('event', () => console.log('Bus 1'))
bus2.on('event', () => console.log('Bus 2'))

await bus1.emit('event', null) // Only bus1 listeners fire

Wait for Event

Wait for an event and return its data:

typescript
import { waitForEvent } from '@lockness/events'

// Wait for event
const promise = waitForEvent<string>(emitter, 'result')

// Somewhere else...
emitter.emit('result', 'success')

const data = await promise // 'success'

// With timeout (5 seconds)
try {
    const data = await waitForEvent(emitter, 'result', 5000)
} catch (error) {
    console.log('Timeout!')
}

Event Stream

Convert events to async iterables:

typescript
import { eventStream } from '@lockness/events'

const stream = eventStream<number>(emitter, 'tick')

for await (const value of stream) {
    console.log(value)
    if (value >= 10) break
}

Use Cases

Domain Events

typescript
interface DomainEvents {
    'order:placed': { orderId: string; total: number }
    'order:shipped': { orderId: string; trackingNumber: string }
    'order:delivered': { orderId: string }
}

const emitter = new EventEmitter<DomainEvents>()

// Order service
emitter.on('order:placed', async (order) => {
    await sendConfirmationEmail(order)
})

// Inventory service
emitter.on('order:placed', async (order) => {
    await updateInventory(order)
})

// Notifications service
emitter.on('order:shipped', async (order) => {
    await sendShippingNotification(order)
})

// Place an order
await emitter.emit('order:placed', {
    orderId: 'ORD-123',
    total: 99.99,
})

Pub/Sub Pattern

typescript
import { createEventBus } from '@lockness/events'

const messageBus = createEventBus()

// Subscriber 1
messageBus.on('message', (msg) => {
    console.log('Sub1:', msg)
})

// Subscriber 2
messageBus.on('message', (msg) => {
    console.log('Sub2:', msg)
})

// Publisher
await messageBus.emit('message', 'Hello, World!')
// Both subscribers receive the message

Application Lifecycle

typescript
interface AppEvents {
    'app:init': null
    'app:ready': null
    'app:shutdown': null
}

const app = new EventEmitter<AppEvents>()

// Module 1
app.on('app:init', async () => {
    await connectDatabase()
}, { priority: 100 })

// Module 2
app.on('app:init', async () => {
    await loadConfig()
}, { priority: 90 })

// Module 3
app.on('app:ready', () => {
    console.log('Application started successfully')
})

// Startup sequence
await app.emit('app:init', null)
await app.emit('app:ready', null)

Event Sourcing

typescript
interface AccountEvents {
    'account:deposited': { amount: number }
    'account:withdrawn': { amount: number }
}

const events = new EventEmitter<AccountEvents>()
let balance = 0

events.on('account:deposited', (data) => {
    balance += data.amount
})

events.on('account:withdrawn', (data) => {
    balance -= data.amount
})

await events.emit('account:deposited', { amount: 100 })
await events.emit('account:withdrawn', { amount: 30 })

console.log(balance) // 70

Plugin System

typescript
interface PluginEvents {
    'plugin:load': { name: string }
    'plugin:unload': { name: string }
}

const plugins = new EventEmitter<PluginEvents>()

// Core plugins load first
plugins.on('plugin:load', initCorePlugins, { priority: 100 })

// User plugins load after
plugins.on('plugin:load', initUserPlugins, { priority: 50 })

await plugins.emit('plugin:load', { name: 'my-plugin' })

Request/Response Pattern

typescript
const bus = createEventBus()

// Responder
bus.on('request:data', (requestId) => {
    bus.emit(`response:${requestId}`, { data: 'result' })
})

// Requester
const requestId = crypto.randomUUID()
const promise = waitForEvent(bus, `response:${requestId}`, 5000)

await bus.emit('request:data', requestId)
const response = await promise

Best Practices

Use Type-Safe Events

Always define event types for better DX:

typescript
interface AppEvents {
    'user:created': { id: number; email: string }
    'user:deleted': { id: number }
}

const emitter = new EventEmitter<AppEvents>()

Handle Errors in Listeners

Listeners should catch their own errors:

typescript
emitter.on('risky-operation', async (data) => {
    try {
        await dangerousOperation(data)
    } catch (error) {
        console.error('Operation failed:', error)
    }
})

Clean Up Listeners

Remove listeners when no longer needed:

typescript
const handler = (data) => console.log(data)

emitter.on('event', handler)

// Later...
emitter.off('event', handler)

Use Priorities Wisely

Order operations with priorities:

typescript
emitter.on('startup', loadConfig, { priority: 100 })
emitter.on('startup', connectDB, { priority: 90 })
emitter.on('startup', startServer, { priority: 80 })

Avoid Memory Leaks

Use once() for one-time listeners:

typescript
emitter.once('connect', () => {
    console.log('Connected!')
})

Namespace Events

Use colon-separated namespaces:

typescript
// User events
emitter.on('user:created', handler)
emitter.on('user:updated', handler)
emitter.on('user:deleted', handler)

// Order events
emitter.on('order:placed', handler)
emitter.on('order:shipped', handler)

Error Handling

Errors in listeners are caught automatically:

typescript
emitter.on('event', () => {
    throw new Error('Oops!')
})

emitter.on('event', () => {
    console.log('This still runs!')
})

await emitter.emit('event', null)
// Error is logged, but second listener still executes

Performance

  • Listeners sorted by priority once at registration
  • O(1) event lookup with Map
  • Minimal overhead for type casting
  • Async listeners execute in parallel

Comparison to Node.js EventEmitter

Feature@lockness/eventsNode EventEmitter
TypeScript✅ Full generics❌ Basic types
Async/await✅ Native⚠️ Via wrapper
Priorities✅ Yes❌ No
Wildcards✅ onAny()❌ No
Event Streams✅ Yes❌ No
Wait for Event✅ Built-in⚠️ Manual promise

Migration from Node.js

typescript
// Node.js EventEmitter
const EventEmitter = require('events')
const emitter = new EventEmitter()

emitter.on('event', (data) => {
    console.log(data)
})

emitter.emit('event', 'data')

// @lockness/events
import { EventEmitter } from '@lockness/events'
const emitter = new EventEmitter()

emitter.on('event', (data) => {
    console.log(data)
})

await emitter.emit('event', 'data') // Now async!

Class-Based Events

BaseEvent Class

All events should extend BaseEvent for proper integration with the framework:

typescript
import { BaseEvent } from '@lockness/core'

export class OrderPlaced extends BaseEvent {
    constructor(
        public readonly orderId: string,
        public readonly userId: string,
        public readonly total: number,
        public readonly items: number,
    ) {
        super()
    }
}

BaseEvent Properties:

  • createdAt: Date - Timestamp when event was created (set automatically)
  • eventName: string - The event class name (e.g., "OrderPlaced")

EventDispatcher

The EventDispatcher wraps the EventEmitter to provide class-based event support:

typescript
import { dispatcher, EventDispatcher } from '@lockness/core'

// Use global dispatcher
const d = dispatcher()

// Or create isolated dispatcher
const myDispatcher = new EventDispatcher()

// Emit class-based events
await d.emit(new OrderPlaced('ORD-123', 'user-1', 99.99, 3))

// Listen to class-based events
d.on(OrderPlaced, (event) => {
    console.log(`Order ${event.orderId} placed`)
})

EventDispatcher Methods:

  • emit(event: BaseEvent) - Emit a class-based event
  • on(EventClass, listener, options?) - Register listener
  • once(EventClass, listener, options?) - Register one-time listener
  • off(EventClass, listener) - Remove listener
  • onAny(listener, options?) - Listen to all events
  • listenerCount(EventClass) - Get listener count
  • removeAllListeners(EventClass?) - Remove all listeners

Decorator-Based Listeners

@Listener Decorator

Mark service methods as event handlers with the @Listener decorator:

typescript
import { Service } from '@lockness/container'
import { Listener } from '@lockness/core'
import { OrderPlaced } from '#events/order_placed'

@Service()
export class OrderListener {
    constructor(
        private mail: MailService,
        private inventory: InventoryService,
    ) {}

    @Listener(OrderPlaced)
    async sendConfirmation(event: OrderPlaced) {
        await this.mail.send(event.userId, 'Order Confirmation', {
            orderId: event.orderId,
            total: event.total,
        })
    }

    @Listener(OrderPlaced, { priority: 100 })
    async updateInventory(event: OrderPlaced) {
        await this.inventory.decrement(event.items)
    }
}

Listener Options:

  • priority?: number - Higher priority executes first (default: 0)

Auto-Discovery

Listeners in app/listener/ are automatically discovered during app bootstrap:

text
app/
├── listener/
│   ├── order_listener.ts      # Auto-discovered
│   ├── user_listener.ts       # Auto-discovered
│   └── payment_listener.ts    # Auto-discovered

Configure the directory in your kernel (defaults to ./app/listener):

typescript
@Kernel({
    listenersDir: './app/listener', // Optional, this is the default
})
export class AppKernel {
    // ...
}

Registering Package Event Listeners

Packages can export event listener classes. Register them in config/listeners.ts:

typescript
// config/listeners.ts
import type { ListenerClass } from '@lockness/core'
import { DevtoolsListener } from '@lockness/devtools'
import { CacheInvalidationListener } from '@lockness/cache'

export const listeners: ListenerClass[] = [
    DevtoolsListener,
    CacheInvalidationListener,
]

The kernel references this config:

typescript
// app/kernel.ts
import { config } from '../config/mod.ts'

@Kernel({
    listenersDir: './app/listener',
    listeners: config.listeners,
})
export class AppKernel {}

Both listenersDir and listeners work together - auto-discovered listeners from the directory are registered alongside explicit listener classes.

Multiple Listeners per Event

You can have multiple methods listening to the same event:

typescript
@Service()
export class NotificationListener {
    constructor(
        private mail: MailService,
        private sms: SMSService,
        private push: PushService,
    ) {}

    @Listener(PaymentReceived, { priority: 100 })
    async sendEmail(event: PaymentReceived) {
        await this.mail.send(event.userId, 'Payment Received')
    }

    @Listener(PaymentReceived, { priority: 90 })
    async sendSMS(event: PaymentReceived) {
        await this.sms.send(event.phone, 'Payment confirmed')
    }

    @Listener(PaymentReceived, { priority: 80 })
    async sendPush(event: PaymentReceived) {
        await this.push.send(event.userId, 'Payment successful')
    }
}

Framework Lifecycle Events

The framework emits lifecycle events at critical execution points:

KernelBooted

Emitted when the application kernel has finished bootstrapping:

typescript
import { Service } from '@lockness/container'
import { KernelBooted, Listener } from '@lockness/core'

@Service()
export class CacheWarmer {
    constructor(private cache: CacheService) {}

    @Listener(KernelBooted)
    async warmCache(event: KernelBooted) {
        console.log(`App "${event.appName}" booted in ${event.environment}`)
        await this.cache.warmup()
    }
}

RequestStarted

Emitted at the start of each HTTP request:

typescript
@Service()
export class RequestLogger {
    @Listener(RequestStarted)
    logRequest(event: RequestStarted) {
        console.log(`[${event.method}] ${event.path} - ID: ${event.requestId}`)
    }
}

ControllerExecuting

Emitted before controller action execution:

typescript
@Service()
export class ControllerLogger {
    @Listener(ControllerExecuting)
    logController(event: ControllerExecuting) {
        console.log(`→ ${event.controller}.${event.action}()`)
    }
}

ResponsePrepared

Emitted after controller returns response:

typescript
@Service()
export class ResponseLogger {
    @Listener(ResponsePrepared)
    logResponse(event: ResponsePrepared) {
        console.log(`← Status: ${event.statusCode}`)
    }
}

RequestCompleted

Emitted after response sent to client:

typescript
@Service()
export class MetricsCollector {
    constructor(private metrics: MetricsService) {}

    @Listener(RequestCompleted)
    async collectMetrics(event: RequestCompleted) {
        await this.metrics.record({
            path: event.path,
            method: event.method,
            statusCode: event.statusCode,
            duration: event.duration,
        })
    }
}

ExceptionOccurred

Emitted when unhandled exception occurs:

typescript
@Service()
export class ErrorReporter {
    constructor(private sentry: SentryService) {}

    @Listener(ExceptionOccurred)
    async reportError(event: ExceptionOccurred) {
        if (event.error.name !== 'HttpException') {
            await this.sentry.captureException(event.error, {
                path: event.path,
                method: event.method,
            })
        }
    }
}

KernelTerminating

Emitted when application is shutting down:

typescript
@Service()
export class DatabaseService {
    constructor(private db: Database) {}

    @Listener(KernelTerminating)
    async closeConnections(event: KernelTerminating) {
        console.log(`Shutting down: ${event.reason}`)
        await this.db.close()
    }
}

Testing Events

Faking Events

Use fake() to capture events during tests:

typescript
import { dispatcher, fake, restore } from '@lockness/core'
import { UserRegistered } from '#events/user_registered'

Deno.test('user registration emits event', async () => {
    const fakeBuffer = fake()

    await userService.register({ email: 'test@example.com' })

    fakeBuffer.assertEmitted(UserRegistered)
    fakeBuffer.assertEmittedCount(UserRegistered, 1)

    restore()
})

Event Assertions

assertEmitted(EventClass, predicate?)

Assert an event was emitted:

typescript
fakeBuffer.assertEmitted(UserRegistered)

// With predicate
fakeBuffer.assertEmitted(UserRegistered, (event) => {
    return event.user.email === 'test@example.com'
})

assertNotEmitted(EventClass)

Assert an event was NOT emitted:

typescript
fakeBuffer.assertNotEmitted(OrderPlaced)

assertEmittedCount(EventClass, count)

Assert exact emission count:

typescript
fakeBuffer.assertEmittedCount(UserRegistered, 2)

Inspecting Recorded Events

typescript
const fakeBuffer = fake()

// ... emit events ...

// Get all recorded events
const all = fakeBuffer.all()

// Get events of specific type
const userEvents = fakeBuffer.allOfType(UserRegistered)

// Get counts
console.log(fakeBuffer.count()) // Total events
console.log(fakeBuffer.countOfType(UserRegistered)) // Type count

// Clear recorded events
fakeBuffer.clear()

restore()

CLI Commands

Generate Event

bash
deno task cli make:event UserRegistered

Creates: app/events/user_registered.ts

Generate Listener

bash
deno task cli make:listener UserListener

Creates: app/listener/user_listener.ts

See Also