@infectedbyjs/emitts
Version:
A type-safe event emitter for TypeScript with priority-based listeners, sequential/parallel execution strategies, and memory leak detection
212 lines (211 loc) • 7.64 kB
JavaScript
import { log, insertSorted, removeFromArray } from "./helpers";
/**
* A type-safe event emitter with priority-based event handling.
*
* Features:
* - Priority-based listeners (higher numbers execute first)
* - Sequential or parallel execution strategies
* - Debugging capabilities with custom loggers
* - Memory leak detection with maxListeners warning
*
* @template Events A map of event names to their payload types
*/
export class EmitTS {
/**
* Creates a new event emitter instance.
*
* @param options Configuration options
* @param options.debug Whether to enable debug logging (defaults to false)
* @param options.logger Custom debug logger (defaults to internal logger)
* @param options.maxListeners Maximum listeners per event before warning (defaults to 10)
*/
constructor(options = {}) {
this.logger = options.debug ? options.logger || log : () => { };
this.subscriptions = new Map();
this.maxListeners = options.maxListeners ?? 10;
}
/**
* Checks whether the emitter has listeners for the given event.
* @param event Event name
* @returns `true` if any listeners are registered
*/
has(event) {
return this.subscriptions.has(event);
}
/**
* Returns the number of listeners for the given event.
* @param event Event name
* @returns The number of listeners
*/
listenersCount(event) {
return this.subscriptions.get(event)?.subscriberCount ?? 0;
}
/**
* Returns a list of all currently registered event names.
*/
get eventNames() {
return Array.from(this.subscriptions.keys());
}
/**
* Checks whether the emitter has no listeners at all.
*/
isEmpty() {
return this.subscriptions.size === 0;
}
/**
* Removes all listeners for all events.
*/
clear() {
this.logger("clear", { message: `CLEARING ${this.subscriptions.size} EVENT TYPES` });
this.subscriptions.forEach((subscription) => subscription.clear());
this.subscriptions.clear();
}
/**
* Subscribes a callback function to an event with optional priority.
* Higher priority listeners are called first.
*/
on(event, callback, priority = 0) {
if (!this.subscriptions.has(event)) {
this.subscriptions.set(event, new EventSubscription(String(event), this.maxListeners));
}
const subscription = this.subscriptions.get(event);
const off = subscription.addSubscriber(callback, priority);
this.logger("subscribe", { event, message: `PRIORITY ${priority}` });
return () => {
this.logger("unsubscribe", { event });
off();
if (subscription.isEmpty)
this.subscriptions.delete(event);
};
}
/**
* Subscribes a one-time callback function that automatically unsubscribes after execution.
*/
once(event, callback, priority = 0) {
this.logger("subscribe:once", { event });
let off = this.on(event, async (data) => {
this.logger("unsubscribe:once", { event });
await callback(data);
off?.();
off = null;
}, priority);
}
/**
* Removes listeners from an event. If no callback is provided, removes all listeners.
* If no event is provided, clears all listeners from all events.
*/
off(event, callback) {
if (!event || !this.subscriptions.has(event)) {
this.clear();
return;
}
const subscription = this.subscriptions.get(event);
if (callback) {
this.logger("unsubscribe", { event });
subscription.removeSubscriber(callback);
}
else {
this.logger("unsubscribe_all", {
event,
message: `REMOVING ${subscription.subscriberCount} SUBSCRIBERS`,
});
subscription.clear();
}
if (subscription.isEmpty)
this.subscriptions.delete(event);
}
/**
* Triggers an event with payload data and executes all listeners.
* Supports both parallel (default) and sequential execution strategies.
* Handles errors to prevent one listener from breaking others.
*/
async emit(event, data, options = { strategy: "parallel" }) {
const subscription = this.subscriptions.get(event);
if (!subscription) {
this.logger("emit", { event, message: `!!! NO EVENTS "${String(event)}" FOUND !!!` });
return;
}
this.logger("emit", { event, data, message: `STRATEGY: ${options.strategy}` });
if (options.strategy === "sequential") {
for (const { callback } of subscription.subscribers) {
try {
await callback(data);
}
catch (err) {
console.error(`[EmitTS] Error in listener for event "${String(event)}":`, err);
}
}
}
else {
const results = [];
for (const { callback } of subscription.subscribers) {
try {
const result = callback(data);
if (result instanceof Promise)
results.push(result);
}
catch (err) {
console.error(`[EmitTS] Error in listener for event "${String(event)}":`, err);
}
}
try {
await Promise.all(results);
}
catch (err) {
console.error(`[EmitTS] Error in listener for event "${String(event)}":`, err);
}
}
}
/**
* Returns a promise that resolves when the given event is emitted.
*
* @param event Event name
* @returns Promise that resolves with the event payload
*/
toPromise(event) {
return new Promise((resolve) => this.once(event, resolve));
}
}
/**
* Internal class that manages subscribers for a single event type.
* Handles priority sorting, duplicate detection, and memory leak warnings.
*
* @template T The event payload type
*/
class EventSubscription {
constructor(type, maxListeners) {
this.type = type;
this.maxListeners = maxListeners;
this.subscribers = [];
}
get isEmpty() {
return this.subscribers.length === 0;
}
get subscriberCount() {
return this.subscribers.length;
}
/**
* Adds a new subscriber with specified priority.
* Handles duplicate detection and maxListeners warnings.
* Orders subscribers by priority (highest first).
*/
addSubscriber(callback, priority = 0) {
if (this.subscribers.some((s) => s.callback === callback)) {
console.warn(`[EmitTS] Duplicate listener detected for "${this.type}". Used the already existing one.`);
return () => this.removeSubscriber(callback);
}
if (this.subscribers.length >= this.maxListeners) {
console.warn(`[EmitTS] MaxListenersExceededWarning: Possible memory leak detected. ` +
`More than ${this.maxListeners} listeners for event "${this.type}".`);
}
const subscriber = { callback, priority };
insertSorted(this.subscribers, subscriber, (s) => s.priority);
return () => this.removeSubscriber(callback);
}
removeSubscriber(callback) {
removeFromArray(this.subscribers, (s) => s.callback === callback);
}
clear() {
this.subscribers = [];
}
}