// ============================================================================ // EventEmitter // Homegrown for that lean feeling. // ============================================================================ /** * A special value that, when returned from an event listener, will cause the * listener to be removed and no longer receive events. */ export const Detach = Symbol('Detach'); /** * A special value that, when returned from an event listener, will stop * iteration and prevent any additional event listeners from receiving the * event themselves. */ export const StopPropagation = Symbol('StopPropagation'); declare global { interface String { toSlug(separator: string): string; toSnakeCase(): string; } } const SNAKE_CAPS = /([a-z])([A-Z])/g, SNAKE_SPACE = /[ \t\W]/g, SNAKE_TRIM = /^_+|_+$/g; String.prototype.toSlug = function(this: string, separator: string = '-') { let result = this; if (result.normalize) result = result.normalize('NFD'); return result .replace(/[\u0300-\u036f]/g, '') .trim() .toLowerCase() .replace(/[^a-z0-9 ]/g, '') .replace(/\s+/g, separator); } String.prototype.toSnakeCase = function(this: string) { let result = this; if (result.normalize) result = result.normalize('NFD'); return result .replace(/[\u0300-\u036f]/g, '') .trim() .replace(SNAKE_CAPS, '$1_$2') .replace(SNAKE_SPACE, '_') .replace(SNAKE_TRIM, '') .toLowerCase(); } export type ListenerReturnType = void | typeof Detach | typeof StopPropagation | Promise; export type EventListener = (...args: TArgs) => ListenerReturnType; export type ListenerInfo = [ listener: EventListener, context: any, ttl: number | false, priority: number, prepend: boolean ]; export interface EventMap { [event: string]: any[]; }; export type EventKey = string & keyof TEvent; export type AddEventKeyNamespace< TNamespace extends string, TKey extends string > = TKey extends `:${infer Rest}` ? `${TNamespace}${TKey}` : TKey; export type RemoveEventKeyNamespace< TNamespace extends string, TKey extends string > = TKey extends `${TNamespace}:${infer Rest}` ? `:${Rest}` : TKey; export type NamespacedEvents = { [K in keyof TEventMap & string as AddEventKeyNamespace]: TEventMap[K] }; export type NamespacedEventKey = string & keyof TEventMap | RemoveEventKeyNamespace; export type NamespacedEventArgs< TKey extends string, TNamespace extends string, TEventMap extends EventMap > = TKey extends keyof TEventMap ? TEventMap[TKey] : AddEventKeyNamespace extends keyof TEventMap ? TEventMap[AddEventKeyNamespace] : never; /** * A custom event emitter implementation with support for priorities, * event listeners that only receive a certain number of events, * event listeners that can remove themselves by returning a special * symbol, and other useful behaviors. * * You'll likely not be using this class directly, instead relying * on {@link Module}s directly. Modules are subclasses of * {@link HierarchicalEventEmitter} which itself is a subclass of * this {@link EventEmitter}. */ export class EventEmitter< TEventMap extends EventMap = {}, TNamespace extends string = '' > { static Detach = Detach; static StopPropagation = StopPropagation; /** @private */ protected __listeners: Record; protected __running: Set; private __dead_events: number; constructor() { this.__listeners = {}; this.__running = new Set; this.__dead_events = 0; } private __cleanListeners() { if ( ! this.__dead_events ) return; const new_listeners: Record = {}, old_listeners = this.__listeners; for(const [key, val] of Object.entries(old_listeners)) { if ( val?.length ) new_listeners[key] = val; } this.__listeners = new_listeners; this.__dead_events = 0; } private __sortListeners(event: string) { // Don't bother sorting while an event is running, since // we'll need to sort it at the end when the lists are // recombined. if ( this.__running.has(event) ) return; const list = this.__listeners[event]; if ( list ) list.sort((a, b) => a[3] - b[3]); } /** * Transfer all the event listeners from this EventEmitter to another * EventEmitter instance, removing them from this EventEmitter in * the process. * * @param other The EventEmitter to transfer our listeners to. */ transferListeners(other: EventEmitter) { if ( !(other instanceof EventEmitter) ) throw new Error('other must also be EventEmitter'); // If the existing listener has no live topics, we can just go ahead // and copy the listeners object directly. const live_topics = other.events().length + other.__running.size; if ( ! live_topics ) { other.__listeners = this.__listeners; other.__dead_events = this.__dead_events; this.__listeners = {}; this.__dead_events = 0; return; } // Unfortuantely, since we got here, we'll need to do things the // old fashioned way. for(const [key, val] of Object.entries(this.__listeners)) { if ( ! val || ! val.length ) continue; let other_val = other.__listeners[key]; if ( Array.isArray(other_val) ) { other_val.push(...val); other.__sortListeners(key); } else other.__listeners[key] = val; } // Reset our state before we leave. this.__listeners = {}; this.__dead_events = 0; } // ======================================================================== // Public Methods // ======================================================================== /** * Create a new {@link FFZEvent} instance. This is a convenience method that * wraps {@link FFZEvent.makeEvent} */ makeEvent = {}>(data: TData): TypedFFZEvent { return FFZEvent.makeEvent(data); } /** * Create a new {@link FFZWaitableEvent} instance. This is a convenience * method that wraps {@link FFZWaitableEvent.makeEvent} */ makeWaitableEvent = {}, TReturn = void>(data: TData): TypedFFZWaitableEvent { return FFZWaitableEvent.makeEvent(data); } /** * Register an event listener for a given event. * * @param event The event to listen for. * @param fn The event listener function. * @param ctx A context (aka `this`) to call the function with. * @param priority The priority of the event listener. Default is 0. * @param prepend Whether the listener should be added to the start of the * list or the end, as a less direct method of establishing priority. */ on>( event: K, fn: EventListener>, ctx?: any, priority?: number, prepend: boolean = false ) { if ( typeof fn !== 'function' ) throw new TypeError('fn must be a function'); const info: ListenerInfo = [ fn as EventListener, ctx, false, priority ?? 0, prepend ]; const list = this.__listeners[event]; if ( list ) { if ( prepend ) list.unshift(info); else list.push(info); this.__sortListeners(event); } else this.__listeners[event] = [info]; } /** * Register an event listener for a given event that will only fire * once before being automatically removed. * * @see {@link many} * * @param event The event to listen for. * @param fn The event listener function. * @param ctx A context (aka `this`) to call the function with. * @param priority The priority of the event listener. Default is 0. * @param prepend Whether the listener should be added to the start of the * list or the end, as a less direct method of establishing priority. */ once>( event: K, fn: EventListener>, ctx?: any, priority?: number, prepend: boolean = false ) { return this.many(event, 1, fn, ctx, priority, prepend); } /** * Register an event listener for a given event that will only fire * a specific number of times before being automatically removed. * * @param event The event to listen for. * @param ttl The number of times the listener should fire. * @param fn The event listener function. * @param ctx A context (aka `this`) to call the function with. * @param priority The priority of the event listener. Default is 0. * @param prepend Whether the listener should be added to the start of the * list or the end, as a less direct method of establishing priority. */ many>( event: K, ttl: number, fn: EventListener>, ctx?: any, priority?: number, prepend: boolean = false ) { if ( typeof fn !== 'function' ) throw new TypeError('fn must be a function'); if ( typeof ttl !== 'number' || isNaN(ttl) || ! isFinite(ttl) || ttl < 1 ) throw new TypeError('ttl must be a positive, finite number'); const info: ListenerInfo = [ fn as EventListener, ctx, ttl, priority ?? 0, prepend ]; const list = this.__listeners[event]; if ( list ) { if ( prepend ) list.unshift(info); else list.push(info); this.__sortListeners(event); } else this.__listeners[event] = [info]; } /** * Wait for the given event to fire and return its value. * * Internally, this works by registering a temporary event listener * with a `ttl` of `1` that, when called, calls the `resolve` method * of the promise returned by this function. * * @param event The event to listen for. * @param ctx A context (aka `this`) to associate this with. While the * context is not used for calling any functions, it's used to track the * source of this event listener so that it can be removed easily by * context if necessary. * @param priority The priority of the event listener. Default is 0. * @param prepend Whether the listener should be added to the start of the * list or the end, as a less direct method of establishing priority. * @returns A {@link Promise} that resolves with the first event of * this type to fire. */ waitFor>( event: K, ctx?: any, priority?: number, prepend: boolean = false ) { return new Promise>(resolve => { const info: ListenerInfo = [ ((...args: NamespacedEventArgs) => resolve(args)) as EventListener, ctx, 1, priority ?? 0, prepend ]; const list = this.__listeners[event]; if ( list ) { if ( prepend ) list.unshift(info); else list.push(info); this.__sortListeners(event); } else this.__listeners[event] = [info]; }); } /** * Remove one or more event listeners, across one or more events. * You must provide at least one of `event`, `fn`, and `ctx`. All * event listeners that match your provided criteria will be removed. * * This can be used to remove all event listeners for a given event * or to remove all event listeners registered by a given context, * in addition to the obvious ability to remove a specific * event listener. * * @param event Optional. The event to remove listener(s) of. * @param fn Optional. The event listener function to remove. * @param ctx Optional. The context to remove listeners of. */ off>( event?: K, fn?: EventListener, ctx?: any ) { if ( event == null ) { if ( ! fn && ! ctx ) throw new Error('you must provide at least one constraint when removing listeners'); for(const evt in Object.keys(this.__listeners)) { if ( ! this.__running.has(evt) ) this.off(evt as any, fn, ctx); } return; } if ( this.__running.has(event) ) throw new Error(`concurrent modification: tried removing event listener while event is running`); let list = this.__listeners[event]; if ( ! list ) return; // If fn and ctx were both not provided, then clear the list. if ( ! fn && ! ctx ) list = null; else { // Remove any entries from the list where: // 1. fn and ctx both match if both were provided // 2. fn matches if only fn was provided // 3. ctx matches if only ctx was provided list = list.filter(([f, c]) => !((! fn || f === fn) && (!ctx || ctx === c))); if ( ! list.length ) list = null; } this.__listeners[event] = list; // We don't use delete since that triggers performance issues // when used on objects. Instead, we record that we have a // dead event so we can clean it up later. if ( ! list ) this.__dead_events++; } /** * Remove all event listeners registered by a specific context. * This can now be handled by calling {@link off} directly and * it should be avoided. * @deprecated */ offContext>(event: K, ctx?: any) { return this.off(event, undefined, ctx); } /** * Return a list of all event keys with at least one listener. */ events() { this.__cleanListeners(); return Object.keys(this.__listeners); } /** * Return a list of all listeners for a given event. This * includes metadata including the listener's `ttl`. * @param event * @returns */ listeners>( event: K) : ListenerInfo> { const list = this.__listeners[event]; return list ? Array.from(list) as any : []; } /** * Determine whether there are currently any listeners for * the specified event. */ hasListeners>(event: K) { return !! this.__listeners[event] } /** * Emit an event to all its listeners. * * This will call each listener of an event in order, and it handles * event listener lifetimes with `ttl` and `Detach`. It also supports * the `StopPropagation` return value or, if the event data is an * instance of {@link FFZEvent}, the {@link FFZEvent.propagationStopped} * property. * * If the event data is an instance of {@link FFZWaitableEvent} and * the event listener returns a {@link Promise}, that {@link Promise} * will automatically be waited for with {@link FFZWaitableEvent.waitFor}. * * @param event The event to emit. * @param data The data for the event. This will vary depending on * the event being emitted. */ emit>( event: K, ...data: NamespacedEventArgs ) { let list = this.__listeners[event]; if ( ! list ) return; if ( this.__running.has(event) ) throw new Error(`concurrent access: tried to emit event while event is running`); // Track removals separately to make iteration over the event list // much, much simpler. const removed = new Set; // Set the current list of listeners to null because we don't want // to enter some kind of loop if a new listener is added as the result // of an existing listener. this.__listeners[event] = null; this.__running.add(event); for(const item of list) { const [fn, ctx, ttl] = item; let ret: ListenerReturnType = undefined; try { ret = fn.apply(ctx, data); } catch(err) { // Abusing as any so we can have log as a getter/setter // on Module without complaint. if ( (this as any).log ) { (this as any).log.capture(err, {tags: {event}, extra:{args: data}}); (this as any).log.error(err); } } if ( ret === Detach ) removed.add(item); else if ( ttl !== false ) { if ( ttl <= 1 ) removed.add(item); else item[2] = ttl - 1; } // Automatically wait for a promise, if the return value is a promise // and we're dealing with a waitable event. if ( ret instanceof Promise ) { if ( (data[0] instanceof FFZWaitableEvent) ) data[0].waitFor(ret); } if ( (data[0] instanceof FFZEvent && data[0].propagationStopped) || ret === StopPropagation ) break; } // Remove any dead listeners from the list. if ( removed.size ) { for(const item of removed) { const idx = list.indexOf(item); if ( idx !== -1 ) list.splice(idx, 1); } } let need_sort = false; // Were more listeners added while we were running? Just combine // the two lists if so. const new_items = this.__listeners[event]; if ( new_items ) { list = list.concat(new_items); need_sort = true; } // If we have items, store the list back. Otherwise, mark that we // have a dead listener. if ( list.length ) this.__listeners[event] = list; else { this.__listeners[event] = null; this.__dead_events++; } this.__running.delete(event); // Finally, now that running is off, sort our listeners if we // have need. if ( need_sort ) this.__sortListeners(event); } } /** * FFZEvent is a convenience class for use when emitting events when you would * like to potentially receive feedback from event listeners. The event * object may be changed by event listeners, with the emitter checking the * event object for changes once the event is done. * * This also includes support for the standard {@link Event} interfaces of * {@link stopPropagation} and {@link preventDefault}. * * @param TData The custom data type the event should be constructed with. */ export class FFZEvent { /** * Create a new {@link FFZEvent} with proper type inheritence from the * supplied data. This should always be used to construct an event * instance, rather than creating the event manually. */ static makeEvent = {}>(data: TData): TypedFFZEvent { return new FFZEvent(data) as TypedFFZEvent; } /** Whether or not {@link preventDefault} has been called. */ defaultPrevented: boolean; /** Whether or not {@link stopPropagation} has been called. */ propagationStopped: boolean; /** Create a new FFZEvent instance with the provided data. */ constructor(data: TData) { this.defaultPrevented = false; this.propagationStopped = false; Object.assign(this, data); } /** * Intended for emitter use only. * * Reset this FFZEvent instance. This does not reset custom data, * but only returns {@link defaultPrevented} and {@link propagationStopped} * to their original values. * * Subclasses of FFZEvent may override this method to extend * its behavior. */ _reset() { this.defaultPrevented = false; this.propagationStopped = false; } /** * Stop the propagation of this event, ensuring that no further * event listeners receive it. */ stopPropagation() { this.propagationStopped = true; } /** * Prevent whatever default behavior is associated with this event. */ preventDefault() { this.defaultPrevented = true; } } /** * TypedFFZEvent is a convenience type returned by {@link FFZEvent.makeEvent} * so that custom data passed to the event can be accessed in a * type safe way. */ export type TypedFFZEvent = FFZEvent & { [K in keyof TData]: TData[K]; } /** * TypedFFZWaitableEvent is a convenience type returned by {@link FFZWaitableEvent.makeEvent} * so that custom data passed to the event can be accessed in a * type safe way. */ export type TypedFFZWaitableEvent = FFZWaitableEvent & { [K in keyof TData]: TData[K]; } /** * FFZWaitableEvent is a subclass of FFZEvent that adds a system for supporting * asynchronous return values. * * Event listeners may return a {@link Promise} or call {@link waitFor} directly * to register their promises, and the emitter is then responsible for calling * the {@link _wait} method after the event has been emitted. * * @param TData The custom data type the event should be constructed with. * @param TReturn The expected return type of {@link Promise}s used with * {@link waitFor} or by listeners returning {@link Promise}s. */ export class FFZWaitableEvent extends FFZEvent { /** * Create a new {@link FFZWaitableEvent} with proper type inheritence from the * supplied data. This should always be used to construct an event * instance, rather than creating the event manually. */ static makeEvent = {}, TReturn = void>(data: TData): TypedFFZWaitableEvent { return new FFZWaitableEvent(data) as TypedFFZWaitableEvent; } private __waiter?: Promise[]> | null; private __waiter_results: Awaited[] | null = null; private __promises?: Promise[] | null; /** * Intended for emitter use only. * * Wait for all registered {@link Promise}s to return. * * @returns */ _wait(): Promise[]> | null { // If we're already waiting, keep on waiting. if ( this.__waiter ) return this.__waiter; // If we have no promises, just return // any pending results. if ( ! this.__promises ) { const out = this.__waiter_results; this.__waiter_results = null; // Make sure to return the results as a promise. return out ? Promise.resolve(out) : null; } // We had promises, so we need to wait some more. const promises = this.__promises; this.__promises = null; return this.__waiter = Promise.all(promises).then(results => { // Store the results for later return. if ( this.__waiter_results ) this.__waiter_results.push(...results); else this.__waiter_results = results; // Now call _wait() again. this.__waiter = null; return this._wait() ?? []; }).catch(err => { // But if there was an error, oh no. this.__waiter = null; throw err; }); } /** * Reset the FFZWaitableEvent instance. In addition to calling * the super method {@link FFZEvent._reset}, this also clears * the Promise-related data stored on the event. */ _reset() { super._reset(); this.__waiter = null; this.__waiter_results = null; this.__promises = null; } /** * Wait for a {@link Promise} to complete before considering the event * as completed. * * Event listeners should either call this method with their Promise * if performing asynchronous work, or else return the Promise directly * from their event listener. * * @param promise The Promise to wait for. */ waitFor(promise: Promise) { if ( ! this.__promises ) this.__promises = [promise]; else this.__promises.push(promise); } } /** * HierarchicalEventEmitter is a subclass of {@link EventEmitter} that allows * you to create a tree of event emitters that all share the same pool of * event listeners. * * This is useful because the event emitter can then emit events using * a simplified name while other emitters can listen to that event using * its full name. * * For example, an emitter with the path `chat.emotes` could call {@link emit} * with the event key `:update-default-sets` while other emitters would then * have access to listen to that event with the full key * `chat.emotes:update-default-sets`. * * This behavior powers the {@link Module} system. */ export class HierarchicalEventEmitter< TNamespace extends string, TEventMap extends EventMap = {} > extends EventEmitter { /** The local name of this event emitter, not including the names of its parents. */ name: string; /** @private */ protected __path?: string; private __path_parts: string[]; /** The parent of this event emitter, if it has one. */ parent?: HierarchicalEventEmitter; /** The root event emitter. */ root: HierarchicalEventEmitter; constructor(name?: string, parent?: HierarchicalEventEmitter) { super(); this.name = name || (this.constructor.name || '').toSnakeCase(); this.parent = parent; if ( parent ) { this.root = parent.root; this.__listeners = parent.__listeners; this.__running = parent.__running; this.__path = name && parent.__path ? `${parent.__path}.${name}` : name; } else { this.root = this as any; this.__path = undefined; } this.__path_parts = this.__path ? this.__path.split('.') : []; } // ======================================================================== // Public Properties // ======================================================================== /** * The full path of this event emitter, including not just its own name * but its ancestors' names as well, separated by periods. */ get path(): TNamespace { return this.__path as TNamespace; } // ======================================================================== // Public Methods // ======================================================================== transferListeners(other: EventEmitter): void { if ( other instanceof HierarchicalEventEmitter && other.root === this.root ) return; throw new Error('Transfering listeners from a HierarchicalEventEmitter is not supported.'); } /** * Take an event path and convert it to an absolute path. For example, * if this emitter's path is `chat.emotes` and you call this with the * path `:update-default-sets` it will return `chat.emotes:update-default-sets`. * * You can also traverse to specific parents by using periods (`.`) at the * start of the path. A single period (`.`) is equal to the current emitter's * path, while each additional period moves up by one ancestor. For example, * to get this emitter's parent, you could use `..`. This can be combined * with other strings to access siblings, as well as events on ancestors * or siblings. * * For example, if this emitter's path is `chat.emotes` and you call this * with the path `..:update-line-tokens` it will return `chat:update-line-tokens`. * * As another example, assuming the same emitter path and you call this with * the path `..emoji:populated` it will return `chat.emoji:populated`. * * @param path The path to resolve. * @returns The resolved path. */ abs_path(path: string): string { if ( typeof path !== 'string' || ! path.length ) throw new TypeError('path must be a non-empty string'); let i = 0, chr; const parts = this.__path_parts, depth = parts.length; do { chr = path.charAt(i); if ( path.charAt(i) === '.' ) { if ( i > depth ) throw new Error('invalid path: reached top of stack'); continue; } break; } while ( ++i < path.length ); const event = chr === ':'; if ( i === 0 ) return event && this.__path ? `${this.__path}${path}` : path; const prefix = parts.slice(0, depth - (i-1)).join('.'), remain = path.slice(i); if ( ! prefix.length ) return remain; else if ( ! remain.length ) return prefix; else if ( event ) return prefix + remain; return `${prefix}.${remain}`; } on>( event: K, fn: EventListener>, ctx?: any, priority?: number, prepend: boolean = false ) { return super.on(this.abs_path(event) as K, fn, ctx, priority, prepend); } once>( event: K, fn: EventListener>, ctx?: any, priority?: number, prepend: boolean = false ) { return super.once(this.abs_path(event) as K, fn, ctx, priority, prepend); } many>( event: K, ttl: number, fn: EventListener>, ctx?: any, priority?: number, prepend: boolean = false ) { return super.many(this.abs_path(event) as K, ttl, fn, ctx, priority, prepend); } waitFor>( event: K, ctx?: any, priority?: number, prepend: boolean = false ) { return super.waitFor(this.abs_path(event) as K, ctx, priority, prepend); } off>( event?: K, fn?: EventListener, ctx?: any ) { return super.off(event && this.abs_path(event) as K, fn, ctx); } listeners>(event: K) { return super.listeners(this.abs_path(event) as K); } hasListeners>(event: K) { return super.hasListeners(this.abs_path(event) as K); } emit>( event: K, ...args: NamespacedEventArgs ) { return super.emit(this.abs_path(event) as K, ...args); } /** * Return a list of all event keys with at least one listener. * This will only return events owned by this emitter, or by * descendents of this emitter. */ eventsWithChildren() { const keys = super.events(), path = this.__path || '', len = path.length; return keys.filter(x => { const y = x.charAt(len); return x.startsWith(path) && (y === '' || y === '.' || y === ':'); }); } /** * Return a list of all event keys with at least one listener. * This will not return any events owned by other emitters. */ events() { const keys = super.events(), path = this.__path || '', len = path.length; return keys.filter(x => { const y = x.charAt(len); return x.startsWith(path) && (y === '' || y === ':'); }); } } export default HierarchicalEventEmitter;