eventque
Version:
A type-safe, async-friendly, queue-based event emitter for Node.js and TypeScript.
235 lines (209 loc) • 7.14 kB
text/typescript
import { EventEmitter } from 'events'
/**
* Defines the mapping from event names to their argument tuple types.
* Users should provide their own event map interface.
*
* Example:
* interface MyEvents {
* log: [string];
* compute: [number, number];
* }
*/
export interface EventMap {
[event: string]: any[]
}
/**
* The function signature for an EventQue listener.
* The last argument is always an AbortSignal for cancellation support.
*/
export type ListenerArgs<T extends any[]> = [...T, AbortSignal]
export type Listener<T extends any[]> = (...args: ListenerArgs<T>) => any | Promise<any>
/**
* Options for customizing the behavior of emitAsync.
*/
export interface EmitOptions {
parallel?: boolean
stopOnError?: boolean
timeoutMs?: number
}
/**
* Allows configuring default EmitOptions per event type.
*/
export type EventSpecificOptions<E extends EventMap> = {
[K in keyof E]?: EmitOptions
}
/**
* Configuration options for the EventQue constructor.
*/
export interface EventQConfig<E extends EventMap> {
defaultOptions?: EmitOptions
perEventOptions?: EventSpecificOptions<E>
}
/**
* Represents the result of calling emitAsync on one listener.
*/
export interface EmitResult<TArgs extends any[]> {
listener: Listener<TArgs>
value: unknown
error: Error | null
}
/**
* EventQue - A type-safe, async-friendly, queue-based event emitter.
*
* Features:
* - Async emit with Promise results
* - Queueing to guarantee order of emits
* - Parallel or sequential listener execution
* - Per-listener timeout with AbortSignal cancellation
* - Event-specific default options
* - Compatible with Node.js EventEmitter (on, once, off)
*/
export class EventQue<E extends EventMap> {
private readonly emitter = new EventEmitter()
private readonly queue: (() => Promise<void>)[] = []
private processingPromise: Promise<void> | null = null
private readonly defaultOptions: EmitOptions
private readonly perEventOptions: EventSpecificOptions<E>
constructor(config?: EventQConfig<E>) {
this.defaultOptions = config?.defaultOptions ?? {}
this.perEventOptions = config?.perEventOptions ?? {}
}
/**
* Register a listener for the given event.
* The listener must accept AbortSignal as the last parameter.
*/
on<K extends keyof E>(event: K, listener: Listener<E[K]>): void {
this.emitter.on(event as string | symbol, listener)
}
/**
* Register a listener that runs only once.
*/
once<K extends keyof E>(event: K, listener: Listener<E[K]>): void {
this.emitter.once(event as string | symbol, listener)
}
/**
* Remove a registered listener.
*/
off<K extends keyof E>(event: K, listener: Listener<E[K]>): void {
this.emitter.off(event as string | symbol, listener)
}
/**
* Emit an event asynchronously, returning a Promise of all listener results.
* The call is queued to ensure order of emits.
*/
emitAsync<K extends keyof E>(event: K, ...argsAndMaybeOptions: [...E[K], EmitOptions?]): Promise<EmitResult<E[K]>[]> {
// extract options
let callOptions: EmitOptions
let args: E[K]
if (
argsAndMaybeOptions.length &&
typeof argsAndMaybeOptions[argsAndMaybeOptions.length - 1] === 'object' &&
!(argsAndMaybeOptions[argsAndMaybeOptions.length - 1] instanceof Error) &&
!Array.isArray(argsAndMaybeOptions[argsAndMaybeOptions.length - 1])
) {
callOptions = argsAndMaybeOptions.pop() as EmitOptions
args = argsAndMaybeOptions as unknown as E[K]
} else {
callOptions = {}
args = argsAndMaybeOptions as unknown as E[K]
}
// merge options: global -> per-event -> call
const mergedOptions: EmitOptions = {
...this.defaultOptions,
...(this.perEventOptions[event] ?? {}),
...callOptions,
}
return new Promise<EmitResult<E[K]>[]>((resolve) => {
this.queue.push(async () => {
const listeners = this.emitter.listeners(event as string | symbol) as Listener<E[K]>[]
const results: EmitResult<E[K]>[] = []
const invokeListener = async (listener: Listener<E[K]>, signal: AbortSignal): Promise<unknown> => {
return Promise.resolve().then(() => listener(...args, signal))
}
const runWithTimeout = (listener: Listener<E[K]>): Promise<unknown> => {
const controller = new AbortController()
const signal = controller.signal
return new Promise((resolve, reject) => {
let settled = false
const timeout = mergedOptions.timeoutMs
? setTimeout(() => {
if (!settled) {
controller.abort()
settled = true
reject(new Error(`Listener timed out after ${mergedOptions.timeoutMs}ms`))
}
}, mergedOptions.timeoutMs)
: null
invokeListener(listener, signal)
.then((value) => {
if (!settled) {
settled = true
if (timeout) clearTimeout(timeout)
resolve(value)
}
})
.catch((err) => {
if (!settled) {
settled = true
if (timeout) clearTimeout(timeout)
reject(err)
}
})
})
}
if (mergedOptions.parallel) {
await Promise.all(
listeners.map(async (listener) => {
try {
const value = await runWithTimeout(listener)
results.push({ listener, value, error: null })
} catch (err) {
results.push({ listener, value: null, error: err instanceof Error ? err : new Error(String(err)) })
if (mergedOptions.stopOnError) throw err
}
})
)
} else {
for (const listener of listeners) {
try {
const value = await runWithTimeout(listener)
results.push({ listener, value, error: null })
} catch (err) {
results.push({ listener, value: null, error: err instanceof Error ? err : new Error(String(err)) })
if (mergedOptions.stopOnError) break
}
}
}
resolve(results)
})
this.processQueue()
})
}
private async processQueue() {
// If already processing, wait for the current processing to complete
if (this.processingPromise) {
await this.processingPromise
// If queue is empty after processing completes, do nothing
if (this.queue.length === 0) return
}
// Start new processing
this.processingPromise = (async () => {
while (this.queue.length > 0) {
const task = this.queue.shift()
if (task) {
try {
await task()
} catch (_) {
// errors are handled in task
}
}
}
})()
// Cleanup after processing completes
try {
await this.processingPromise
} finally {
this.processingPromise = null
}
}
}