@homer0/events-hub
Version:
A simple implementation of a pubsub service for handling events
396 lines (378 loc) • 13.4 kB
text/typescript
// 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();