UNPKG

@homer0/events-hub

Version:

A simple implementation of a pubsub service for handling events

396 lines (378 loc) 13.4 kB
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- It's a generic function. type GenericFn = (...args: any[]) => any; type GenericParams = Parameters<GenericFn>; type OnceWrapperFn = GenericFn & { once: boolean; }; /** * When the service receives a "once subscription", it needs to track the listener so it * gets removed after being called once, and to avoid modifying the original function, it * creates a wrapper function that has the "once" property set to true. The wrapper and * the original function are stored in case `off` is called before the wrapper gets * triggered; it will receive the original function, not the wrapper, so the class needs a * way to map them together. */ type OnceWrapper = { /** * A simple wrapper for the original listener, with the difference that it has a `once` * property set to `true`. */ wrapper: OnceWrapperFn; /** * The original listener that will be called once. */ original: GenericFn; }; /** * A minimal implementation of an events handler service. */ export class EventsHub { /** * A dictionary of the events and their listeners. */ protected events: Record<string, GenericFn[]> = {}; /** * A dictionary of wrappers that were created for "one time subscriptions". This is * used by the {@link EventsHub.off}: if it doesn't find the subscriber as it is, it * will look for a wrapper and remove it. */ protected onceWrappers: Record<string, OnceWrapper[]> = {}; /** * Gets all the listeners for a specific event. * The list is returned by reference, so it can be modified once obtained. * * @param event The name of the event. */ protected getSubscribers(event: string): (GenericFn | OnceWrapperFn)[] { if (!this.events[event]) { this.events[event] = []; } return this.events[event]!; } on<ListenerFn extends GenericFn = GenericFn>( event: string, listener: ListenerFn, ): () => boolean; on<ListenerFn extends GenericFn = GenericFn>( event: string[], listener: ListenerFn, ): () => boolean[]; on<ListenerFn extends GenericFn = GenericFn>( event: string | string[], listener: ListenerFn, ): () => boolean | boolean[]; /** * Adds a new event listener. * * @param event An event name or a list of them. * @param listener The listener function. * @returns An unsubscribe function to remove the listene(s). * @template ListenerFn The type of the listener function. * @example * * const events = new EventsHub(); * type Listener = (arg0: string) => void; * const unsubscribe = events.on<Listener>('event', (arg0) => { * console.log(`Event received: ${arg0}`); * }); * */ on<ListenerFn extends GenericFn = GenericFn>( event: string | string[], listener: ListenerFn, ): () => boolean | boolean[] { const events = Array.isArray(event) ? event : [event]; events.forEach((name) => { const subscribers = this.getSubscribers(name); if (!subscribers.includes(listener)) { subscribers.push(listener); } }); return () => this.off<ListenerFn>(event, listener); } once<ListenerFn extends GenericFn = GenericFn>( event: string, listener: ListenerFn, ): () => boolean; once<ListenerFn extends GenericFn = GenericFn>( event: string[], listener: ListenerFn, ): () => boolean[]; once<ListenerFn extends GenericFn = GenericFn>( event: string | string[], listener: ListenerFn, ): () => boolean | boolean[]; /** * Adds an event listener that will only be executed once. * * @param event An event name or a list of them. * @param listener The listener function. * @returns An unsubscribe function to remove the listener(s). * @template ListenerFn The type of the listener function. * @example * * const events = new EventsHub(); * type Listener = (arg0: string) => void; * const unsubscribe = events.once<Listener>('event', (arg0) => { * console.log(`Event received: ${arg0}`); * }); * */ once<ListenerFn extends GenericFn = GenericFn>( event: string | string[], listener: ListenerFn, ): () => boolean | boolean[] { const events = Array.isArray(event) ? event : [event]; // Try to find an existing wrapper. let wrapper = events.reduce<OnceWrapperFn | null>((acc, name) => { // A previous iteration found a wrapper, so `continue`. if (acc) return acc; // A list of wrappers exists for the event, so, let's try an find one for this function. const onceWrapper = this.onceWrappers[name]; if (Array.isArray(onceWrapper)) { const existing = onceWrapper.find((item) => item.original === listener); if (existing) { return existing.wrapper; } return null; } // The list didn't even exists, let's at least create it. this.onceWrappers[name] = []; return null; }, null); if (!wrapper) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- It's a generic function. const newWrapper = (...args: any[]) => listener(...args); newWrapper.once = true; wrapper = newWrapper; events.forEach((name) => { this.onceWrappers[name]!.push({ wrapper: wrapper!, original: listener, }); }); } return this.on(event, wrapper); } off<ListenerFn extends GenericFn | OnceWrapperFn = GenericFn>( event: string[], listener: ListenerFn, ): boolean[]; off<ListenerFn extends GenericFn | OnceWrapperFn = GenericFn>( event: string, listener: ListenerFn, ): boolean; off<ListenerFn extends GenericFn | OnceWrapperFn = GenericFn>( event: string | string[], listener: ListenerFn, ): boolean | boolean[]; /** * Removes an event listener. * * @param event An event name or a list of them. * @param listener The listener function. * @returns If `event` was a `string`, it will return whether or not the listener * was found and removed; but if `event` * was an `Array`, it will return a list of boolean values. * @template ListenerFn The type of the listener function. * @example * * const events = new EventsHub(); * const listener = (arg0) => { * console.log(`Event received: ${arg0}`); * }; * events.on('event', listener); // subscribe. * events.off('event', listener); // manually unsubscribe. * */ off<ListenerFn extends GenericFn | OnceWrapperFn = GenericFn>( event: string | string[], listener: ListenerFn, ): boolean | boolean[] { const isArray = Array.isArray(event); const events = isArray ? event : [event]; const result = events.map((name) => { const subscribers = this.getSubscribers(name); const onceSubscribers = this.onceWrappers[name]; let found = false; let index = subscribers.indexOf(listener); if (index > -1) { found = true; /** * If the listener had the `once` flag, then it's a wrapper, so it needs to remove * it from the wrappers list too. */ if ('once' in listener && onceSubscribers) { const wrapperIndex = onceSubscribers.findIndex( (item) => item.wrapper === listener, ); onceSubscribers.splice(wrapperIndex, 1); } subscribers.splice(index, 1); } else if (onceSubscribers) { /** * If it couldn't found the subscriber, maybe it's because it's the original * listener of a wrapper. */ index = onceSubscribers.findIndex((item) => item.original === listener); if (index > -1) { found = true; const originalIndex = subscribers.indexOf(onceSubscribers[index]!.original); subscribers.splice(originalIndex, 1); onceSubscribers.splice(index, 1); } } return found; }); return isArray ? result : result[0]!; } /** * Emits an event and call all its listeners. * * @param event An event name or a list of them. * @param args A list of parameters to send to the listeners. * @template Args The type of the parameters to send to the listeners. * @example * * const events = new EventsHub(); * events.on('event', (arg0) => { * console.log(`Event received: ${arg0}`); * }); * events.emit('event', 'Hello'); // prints "Event received: Hello" * */ emit<Args extends GenericParams>(event: string | string[], ...args: Args): void { const toClean: { event: string; listener: GenericFn }[] = []; const events = Array.isArray(event) ? event : [event]; events.forEach((name) => { this.getSubscribers(name).forEach((subscriber) => { subscriber(...args); if ('once' in subscriber) { toClean.push({ event: name, listener: subscriber, }); } }); }); toClean.forEach((info) => this.off(info.event, info.listener)); } /** * Asynchronously reduces a target using an event. It's like emit, but the events * listener return a modified (or not) version of the `target`. * * @param event An event name or a list of them. * @param target The variable to reduce with the reducers/listeners. * @param args A list of parameters to send to the reducers/listeners. * @returns A version of the `target` processed by the listeners. * @template Target The type of the target. * @template Args The type of the parameters to send to the reducers/listeners. * @example * * const events = new EventsHub(); * events.on('event', async (target, arg0) => { * const data = await fetch(`https://api.example.com/${arg0}`); * target.push(data); * return target; * }); * const result = await events.reduce('event', [], 'Hello'); * // result would be a list of data fetched from the API. * */ async reduce<Target, Args extends GenericParams>( event: string | string[], target: Target, ...args: Args ): Promise<Target> { const events = Array.isArray(event) ? event : [event]; const toClean: { event: string; listener: GenericFn }[] = []; const result = await events.reduce<Promise<Target>>( (eventAcc, name) => eventAcc.then((eventCurrent) => { const subscribers = this.getSubscribers(name); return subscribers.reduce( (subAcc, subscriber) => subAcc.then((subCurrent) => { let useCurrent; if (Array.isArray(subCurrent)) { useCurrent = subCurrent.slice(); } else if (typeof subCurrent === 'object') { useCurrent = { ...subCurrent }; } else { useCurrent = subCurrent; } const nextStep = subscriber(...[useCurrent, ...args]); if ('once' in subscriber) { toClean.push({ event: name, listener: subscriber, }); } return nextStep; }), Promise.resolve(eventCurrent), ); }), Promise.resolve(target), ); toClean.forEach((info) => this.off(info.event, info.listener)); return result; } /** * Synchronously reduces a target using an event. It's like emit, but the events * listener return a modified (or not) version of the `target`. * * @param event An event name or a list of them. * @param target The variable to reduce with the reducers/listeners. * @param args A list of parameters to send to the reducers/listeners. * @returns A version of the `target` processed by the listeners. * @template Target The type of the target. * @template Args The type of the parameters to send to the reducers/listeners. * @example * * const events = new EventsHub(); * events.on('event', (target, arg0) => { * target.push(arg0); * return target; * }); * events.reduce('event', [], 'Hello'); // returns ['Hello'] * */ reduceSync<Target, ReducerArgs extends GenericParams>( event: string | string[], target: Target, ...args: ReducerArgs ): Target { const events = Array.isArray(event) ? event : [event]; const toClean: { event: string; listener: GenericFn }[] = []; const result = events.reduce((eventAcc, name) => { const subscribers = this.getSubscribers(name); return subscribers.reduce((subAcc, subscriber) => { let useCurrent; if (Array.isArray(subAcc)) { useCurrent = subAcc.slice(); } else if (typeof subAcc === 'object') { useCurrent = { ...subAcc }; } else { useCurrent = subAcc; } const nextStep = subscriber(...[useCurrent, ...args]); if ('once' in subscriber) { toClean.push({ event: name, listener: subscriber, }); } return nextStep; }, eventAcc); }, target); toClean.forEach((info) => this.off(info.event, info.listener)); return result; } } /** * Shorthand for `new EventsHub()`. * * @returns A new instance of {@link EventsHub}. */ export const eventsHub = () => new EventsHub();