UNPKG

@nasriya/atomix

Version:

Composable helper functions for building reliable systems

604 lines (603 loc) 26.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.EventEmitter = void 0; const valueIs_1 = __importDefault(require("../../valueIs")); const utils_1 = __importDefault(require("../../domains/utils/utils")); const records_utils_1 = __importDefault(require("../../domains/data-types/record/records-utils")); /** * A flexible and lightweight event emitter class with full TypeScript support. * * Features: * - Strongly typed event names and handler arguments using generics (`EventsMap`). * - Global `'*'` handlers that receive the event name as the first argument, followed by the event's arguments. * - `beforeAll` and `afterAll` handler types for lifecycle-style hooks. * - One-time handlers via `on` with `{ once: true }` option (removed automatically after first execution). * - Handler limits with overflow detection via `maxTotalHandlers` and custom overflow behavior. * - Backward-compatible `maxHandlers` property (deprecated in favor of `maxTotalHandlers`). * * This implementation allows both standard event handlers and advanced lifecycle hooks, * while supporting both typed and untyped usage. * * @example * ```ts * const emitter = new atomix.tools.EventEmitter(); * * // Regular handler (called in the order added) * emitter.on('data', (value) => console.log('Received:', value)); * * // One-time handler * emitter.on('data', (value) => console.log('Once:', value), { once: true }); * * // Setup logic before all regular handlers * emitter.on('data', () => console.log('Before All'), { type: 'beforeAll' }); * * // Cleanup logic after all regular handlers * emitter.on('data', () => console.log('After All'), { type: 'afterAll' }); * * // Global handler receives the event name first * emitter.on(`'*'`, (event, ...args) => console.log(`Global: ${event}`, args)); * * // Emit the event * await emitter.emit('data', 42); * ``` * * @example * ```ts * const emitter = new atomix.tools.EventEmitter(); * * // Option 1: Throw an Error immediately when the limit is exceeded * emitter.maxTotalHandlers = 2; * emitter.onMaxHandlers(new Error('Handler limit exceeded')); * * emitter.on('load', () => {}); * emitter.on('load', () => {}); * emitter.on('load', () => {}); // throws immediately * * // Option 2: Use a custom function to handle overflows (debounced) * emitter.onMaxHandlers((eventName) => { * console.warn(`Handler overflow on event: ${eventName}`); * }); * ``` * * @template EventsMap - Optional type map for event names and handler signatures. Defaults to `{}` for untyped usage. * * @since v1.0.8 */ class EventEmitter { #_events = new Map(); #_stats = { handlers: { number: 0, max: 10 }, }; #_helpers = { getEvents: (eventName) => { if (this.#_events.has(eventName)) { return this.#_events.get(eventName); } const events = { name: eventName, handlersNumber: 0, handlers: { before: undefined, after: undefined, normal: { index: 0, handlers: new Map(), onceHandlers: new Map(), }, } }; this.#_events.set(eventName, events); return events; }, onMaxHandlers: { builtInHandler: utils_1.default.debounceSync(() => { if (this.#_stats.handlers.number > this.#_stats.handlers.max) { console.warn(`[WARN] The maximum number of handlers has been reached (${this.#_stats.handlers.max}). Current number of handlers: ${this.#_stats.handlers.number}.`); } }, 1000), customHandler: null, check: (eventName) => { const customHandler = this.#_helpers.onMaxHandlers.customHandler; if (customHandler instanceof Error) { if (this.#_stats.handlers.number > this.#_stats.handlers.max) { const newErr = new Error(); newErr.message = `[WARN] ${customHandler.message}`; newErr.cause = customHandler.cause; throw newErr; } return; } ; const builtInHandler = this.#_helpers.onMaxHandlers.builtInHandler; const toInvoke = customHandler !== null ? customHandler : builtInHandler; toInvoke(eventName); } }, updateEventHandlersNum: (events, delta) => { events.handlersNumber += delta; this.#_stats.handlers.number += delta; if (events.handlersNumber === 0) { events.handlers.normal.index = 0; } }, }; #_processor = { process: async (events, ...args) => { const { emittedBy, data, isGlobal } = events; if (data.handlers.before) { await this.#_processor.processHandler(data.handlers.before, { triggeredBy: emittedBy, isGlobal }, ...args); } // Handle normal events for (let i = 0; i < data.handlers.normal.index; i++) { const handler = (data.handlers.normal.handlers.get(i) || data.handlers.normal.onceHandlers.get(i)); if (!handler) { continue; } await this.#_processor.processHandler(handler, { triggeredBy: emittedBy, isGlobal }, ...args); } if (data.handlers.after) { await this.#_processor.processHandler(data.handlers.after, { triggeredBy: emittedBy, isGlobal }, ...args); } const onceHandlersCount = data.handlers.normal.onceHandlers.size; this.#_helpers.updateEventHandlersNum(data, -onceHandlersCount); data.handlers.normal.onceHandlers.clear(); }, processHandler: async (handler, meta, ...args) => { const triggeredBy = meta.triggeredBy; const isGlobal = meta.isGlobal; try { if (isGlobal) { await handler(triggeredBy, ...args); } else { await handler(...args); } } catch (error) { this.#_processor.onError(error, meta); } }, onError(error, meta) { const errMsg = `[Atomix][EventEmitter:UserHandlerError]: An error occurred while processing event '${meta.triggeredBy}'.`; console.error(errMsg, error); } }; /** * Emits an event, triggering all registered handlers for the given event name. * * This method supports both **typed** and **untyped** emitters: * * - In a **typed emitter** (`EventEmitter<{ load: () => void; data: (value: number) => void }>`), * the `eventName` parameter is restricted to the keys of the provided map, * and the `args` are automatically typed according to the event's parameters. * * - In an **untyped emitter** (`EventEmitter` with default `{}`), `eventName` can be any string, * and `args` are of type `any[]`. * * Global (`'*'`) handlers are triggered on every emit and receive the actual event name as the first argument. * * Handlers are invoked in the following order for each `emit` call: * 1. The `beforeAll` handler (if set) runs before all `normal` handlers. * 2. All `normal` handlers (in the order they were registered). * 3. The `afterAll` handler (if set) runs after all `normal` handlers. * * `beforeAll` and `afterAll` are singleton handlers — only one of each may be set per event name. * They are not removed automatically and are executed on every `emit` call. * * `normal` handlers may be marked as `once`, in which case they are removed after their first invocation. * * @template E - The event name type; inferred automatically. * @param eventName - The name of the event to emit, or `'*'` for global handlers. Must be a non-empty string. * @param args - Arguments to pass to all applicable handlers. Automatically typed for typed emitters. * * @throws {TypeError} If the event name is not a string. * @throws {RangeError} If the event name is an empty string. * * @example * // Untyped emitter * const emitter = new EventEmitter(); * emitter.on('*', (event, ...args) => console.log(`Global: ${event}`, args)); * await emitter.emit('load', true); * * @example * // Typed emitter * type MyEvents = { load: (ok: boolean) => void; data: (value: number) => void }; * const typedEmitter = new EventEmitter<MyEvents>(); * typedEmitter.on('data', (value) => console.log('Data:', value)); * typedEmitter.on('*', (event, ...args) => console.log(`Global: ${event}`, args)); * await typedEmitter.emit('data', 42); // value typed as number * * @since v1.0.8 */ async emit(eventName, ...args) { if (!valueIs_1.default.string(eventName)) { throw new TypeError(`The provided event name (${eventName}) is not a string.`); } if (eventName.length === 0) { throw new RangeError(`The provided event name (${eventName}) is an empty string.`); } const globalEvents = this.#_helpers.getEvents('*'); const namedEvents = eventName === '*' ? undefined : this.#_helpers.getEvents(eventName); if (namedEvents) { await this.#_processor.process({ emittedBy: eventName, data: namedEvents }, ...args); } if (globalEvents) { await this.#_processor.process({ emittedBy: eventName, data: globalEvents, isGlobal: true }, ...args); } } /** * Adds a handler to an event. * * This method supports both **typed** and **untyped** event emitters. * * ### Typed vs untyped emitters * - In a **typed emitter** (`EventEmitter<{ load: () => void; data: (value: number) => void }>`), * `eventName` is restricted to the keys of the provided map, and `handler` is fully type-safe. * - In an **untyped emitter** (`EventEmitter` with the default `{}`), `eventName` may be any string. * * ### Global handlers (`'*'`) * Passing `'*'` as the event name registers a **global handler** that listens to all events. * The handler receives the actual event name as the first argument, followed by the event arguments. * * ### Handler types * - `"normal"`: Runs on every emit (default) * - `"beforeAll"`: Runs before all normal handlers for the event * - `"afterAll"`: Runs after all normal handlers for the event * * ### Intentional no-op handlers * This method explicitly supports passing `atomix.utils.noop` as the handler. * * Doing so **preserves the semantic intent of attaching a handler** without: * - allocating a new function, * - storing it in memory, * - or creating misleading empty implementations. * * No-op handlers are ignored internally while keeping the event registration intentional and explicit. * * @template E - The event name type (inferred automatically). * @param eventName - The name of the event to add the handler to, or `'*'` for a global handler. * @param handler - The handler function for the event, or `atomix.utils.noop` to intentionally register a no-op. * @param options - Optional configuration for the handler. Defaults to `{ once: false, type: 'normal' }`. * @param options.once - If true, the handler is removed after being called once. * @param options.type - The type of handler. One of `"normal"`, `"beforeAll"`, or `"afterAll"`. * * @throws {TypeError} If `eventName` is not a string, or `handler` is not a function. * @throws {RangeError} If `eventName` is an empty string, or `options.type` is invalid. * * @returns The EventEmitter instance, allowing method chaining. * * @example * // Intentional placeholder handler * emitter.on('ready', atomix.utils.noop); * * @example * // Typed emitter * type Events = { ready: () => void }; * const typedEmitter = new EventEmitter<Events>(); * typedEmitter.on('ready', atomix.utils.noop); * * @since v1.0.8 */ on(eventName, handler, options) { const configs = { once: false, type: 'normal', }; if (!valueIs_1.default.string(eventName)) { throw new TypeError(`The provided event name (${eventName}) is not a string.`); } if (eventName.length === 0) { throw new RangeError(`The provided event name (${eventName}) is an empty string.`); } if (typeof handler !== 'function') { throw new TypeError(`The provided handler (${handler}) is not a function.`); } if (handler === utils_1.default.noop) { return this; } if (options !== undefined) { if (!valueIs_1.default.record(options)) { throw new TypeError(`The provided options (${options}) is not a record.`); } if (records_utils_1.default.hasOwnProperty(options, 'once')) { if (typeof options.once !== 'boolean') { throw new TypeError(`The "once" property of the provided options (${options}) is not a boolean.`); } configs.once = options.once; } if (records_utils_1.default.hasOwnProperty(options, 'type')) { if (!valueIs_1.default.string(options.type)) { throw new TypeError(`The "type" property of the provided options (${options}) is not a string.`); } if (!['beforeAll', 'afterAll', 'normal'].includes(options.type)) { throw new RangeError(`The "type" property of the provided options (${options}) is not a valid event type.`); } configs.type = options.type; } } const events = this.#_helpers.getEvents(eventName); if (configs.type === 'beforeAll' || configs.type === 'afterAll') { if (configs.type === 'beforeAll') { events.handlers.before = handler; } if (configs.type === 'afterAll') { events.handlers.after = handler; } } else { const id = events.handlers.normal.index++; events.handlers.normal[configs.once ? 'onceHandlers' : 'handlers'].set(id, handler); } this.#_helpers.updateEventHandlersNum(events, 1); this.#_helpers.onMaxHandlers.check(eventName); return this; } /** * Registers a custom handler for the "max handlers exceeded" event. * * When the number of handlers for an event exceeds the maximum allowed, this handler is called with the event name as an argument. * * - If a **function** is provided, it is debounced (100ms) to prevent excessive calls during rapid handler additions. * - If an **Error** object is provided, it will be thrown immediately when the limit is exceeded. * * @param handler - A function to call when the maximum number of handlers is exceeded, or an Error to throw. * @returns The EventEmitter instance, allowing method chaining. * * @throws {TypeError} If the handler is not a function or an Error instance. * * @example * ```ts * const emitter = new atomix.tools.EventEmitter(); * * // Throw an error when the max handlers limit is exceeded * emitter.onMaxHandlers(new Error('Handler limit exceeded')); * * // Or provide a custom function * emitter.onMaxHandlers((eventName) => { * console.warn(`Handler overflow on event: ${eventName}`); * }); * ``` * * @since v1.0.8 */ onMaxHandlers(handler) { const isFunction = typeof handler === 'function'; const isError = handler instanceof Error; if (!isFunction && !isError) { throw new TypeError(`The provided handler (${handler}) is not a function or an error.`); } if (isError) { this.#_helpers.onMaxHandlers.customHandler = handler; } if (isFunction) { this.#_helpers.onMaxHandlers.customHandler = utils_1.default.debounceSync((eventName) => { if (this.#_stats.handlers.number <= this.#_stats.handlers.max) { return; } handler(eventName); }, 100, { onError: (err) => { throw err; } }); } return this; } /** * Cleans up any internal resources used by this instance. * * Specifically, cancels any pending debounced handlers (such as * warnings about maximum handlers) to prevent timers from * keeping the process alive or causing side effects after disposal. * * This method should be called when the instance is no longer needed, * such as during test teardown or object cleanup. * * @since v1.0.15 */ dispose() { const { builtInHandler, customHandler } = this.#_helpers.onMaxHandlers; builtInHandler?.cancel(); if (typeof customHandler !== 'function') { return; } customHandler?.cancel(); } /** * Retrieves the maximum number of event handlers allowed for this EventEmitter instance. * * The value is a positive integer or Infinity. If set to Infinity, there is no limit to the number of handlers. * * @returns {number} The maximum number of handlers. A positive integer or Infinity. * @since v1.0.24 */ get maxTotalHandlers() { return this.#_stats.handlers.max; } /** * Sets the maximum number of event handlers allowed for this EventEmitter instance. * * The value must be a positive integer or Infinity. If set to Infinity, there is no limit to the number of handlers. * * @param {number} value The maximum number of handlers. Must be a positive integer or Infinity. * @throws {TypeError} If the provided value is not a number or not an integer. * @throws {RangeError} If the provided value is not greater than 0. * @since v1.0.24 */ set maxTotalHandlers(value) { if (!valueIs_1.default.number(value)) { throw new TypeError('maxTotalHandlers must be a number'); } if (value === Infinity) { this.#_stats.handlers.max = value; return; } if (value <= 0) { throw new RangeError('maxTotalHandlers must be greater than 0'); } if (!valueIs_1.default.integer(value)) { throw new TypeError('maxTotalHandlers must be an integer'); } this.#_stats.handlers.max = value; } /** * Retrieves the maximum number of event handlers allowed for this EventEmitter instance. * * The value is a positive integer or Infinity. If set to Infinity, there is no limit to the number of handlers. * * @returns {number} The maximum number of handlers. A positive integer or Infinity. * @deprecated Use {@link maxTotalHandlers} getter/setter instead. * @since v1.0.8 */ get maxHandlers() { return this.maxTotalHandlers; } /** * Sets the maximum number of event handlers allowed for this EventEmitter instance. * * The value must be a positive integer or Infinity. If set to Infinity, there is no limit to the number of handlers. * * @param {number} value The maximum number of handlers. Must be a positive integer or Infinity. * @throws {TypeError} If the provided value is not a number or not an integer. * @throws {RangeError} If the provided value is not greater than 0. * @deprecated Use {@link maxTotalHandlers} getter/setter instead. * @since v1.0.8 */ set maxHandlers(value) { this.maxTotalHandlers = value; } /** * Retrieves the total number of event handlers registered with this EventEmitter instance. * @returns {number} The total number of event handlers. * @since v1.0.8 */ get handlersCount() { return this.#_stats.handlers.number; } /** * Retrieves the names of all registered events for this EventEmitter instance. * * @returns {string[]} An array of event names. * @since v1.0.8 */ get eventNames() { return Array.from(this.#_events.keys()).filter(name => name !== '*'); } /** * A collection of methods to remove event handlers. * * This allows removing specific handlers or clearing all handlers from one or more events. * * @example * ```ts * const emitter = new EventEmitter(); * const handler = () => console.log('Hello'); * * emitter.on('greet', handler); * emitter.remove.handler('greet', handler); // Removes just this one * ``` * * @example * ```ts * emitter.on('load', () => {}); * emitter.on('error', () => {}); * emitter.remove.allHandlers(); // Removes everything * ``` * * @example * ```ts * emitter.remove.allHandlers('load'); // Removes all "load" handlers only * ``` * * @since v1.0.8 */ remove = { /** * Removes a specified event handler for a given event name. * * @param eventName - The name of the event for which the handler should be removed. * @param handler - The handler function to remove. * @returns {boolean} True if the handler was successfully removed, otherwise false. * @throws {TypeError} Throws if the event name is not a string or the handler is not a function. * @since v1.0.8 */ handler: (eventName, handler) => { if (!valueIs_1.default.string(eventName)) { throw new TypeError(`The provided event name (${eventName}) is not a string.`); } if (eventName.length === 0) { throw new RangeError(`The provided event name (${eventName}) is an empty string.`); } if (typeof handler !== 'function') { throw new TypeError(`The provided handler (${handler}) is not a function.`); } const events = this.#_events.get(eventName); if (!events) { return false; } const removed = (() => { const isBefore = events.handlers.before === handler; const isAfter = events.handlers.after === handler; if (isBefore || isAfter) { if (isBefore) { events.handlers.before = undefined; } if (isAfter) { events.handlers.after = undefined; } return true; } for (let i = 0; i < events.handlers.normal.index; i++) { const storedHandler = events.handlers.normal.handlers.get(i); if (storedHandler === handler) { events.handlers.normal.handlers.delete(i); return true; } const storedOnceHandler = events.handlers.normal.onceHandlers.get(i); if (storedOnceHandler === handler) { events.handlers.normal.onceHandlers.delete(i); return true; } } return false; })(); if (removed) { this.#_helpers.updateEventHandlersNum(events, -1); } return removed; }, /** * Removes all handlers for a given event name or all events if no name is specified. * * @param eventName - (Optional) The name of the event for which handlers should be removed. If not provided, handlers for all events are removed. * @returns {boolean} True if any handlers were removed, otherwise false. * @throws {TypeError} Throws if the event name is not a string. * @throws {RangeError} Throws if the event name is an empty string. * @since v1.0.8 */ allHandlers: (eventName) => { if (eventName !== undefined) { if (!valueIs_1.default.string(eventName)) { throw new TypeError(`The provided event name (${eventName}) is not a string.`); } if (eventName.length === 0) { throw new RangeError(`The provided event name (${eventName}) is an empty string.`); } } const events = eventName ? [this.#_events.get(eventName)] : Array.from(this.#_events.values()); let anyRemoved = false; for (const event of events) { if (!event) { continue; } let removed = 0; if (event.handlers.before) { removed++; } if (event.handlers.after) { removed++; } removed += event.handlers.normal.handlers.size + event.handlers.normal.onceHandlers.size; this.#_events.delete(event.name); this.#_helpers.updateEventHandlersNum(event, -removed); anyRemoved = removed > 0; } return anyRemoved; } }; } exports.EventEmitter = EventEmitter; exports.default = EventEmitter;