@byloth/core
Version:
An unopinionated collection of useful functions and classes that I use widely in all my projects. 🔧
282 lines (261 loc) • 9.21 kB
text/typescript
import { ReferenceException } from "../exceptions/index.js";
import type { Callback, CallbackMap, WithWildcard } from "./types.js";
/**
* A class implementing the
* {@link https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern|Publish-subscribe} pattern.
*
* It can be used to create a simple event system where objects can subscribe
* to events and receive notifications when the events are published.
* It's a simple and efficient way to decouple the objects and make them communicate with each other.
*
* Using generics, it's also possible to define the type of the events and the callbacks that can be subscribed to them.
*
* ---
*
* @example
* ```ts
* interface EventsMap
* {
* "player:spawn": (evt: SpawnEvent) => void;
* "player:move": ({ x, y }: Point) => void;
* "player:death": () => void;
* }
*
* const publisher = new Publisher<EventsMap>();
*
* let unsubscribe: () => void;
* publisher.subscribe("player:death", unsubscribe);
* publisher.subscribe("player:spawn", (evt) =>
* {
* unsubscribe = publisher.subscribe("player:move", ({ x, y }) => { [...] });
* });
* ```
*
* ---
*
* @template T
* A map containing the names of the emittable events and the
* related callback signatures that can be subscribed to them.
* Default is `Record<string, (...args: unknown[]) => unknown>`.
*
* @template E An utility type that extends the `T` map with a wildcard event.
*/
export default class Publisher<T extends CallbackMap<T> = CallbackMap, W extends WithWildcard<T> = WithWildcard<T>>
{
/**
* A map containing all the subscribers for each event.
*
* The keys are the names of the events they are subscribed to.
* The values are the arrays of the subscribers themselves.
*/
protected readonly _subscribers: Map<string, Callback<unknown[], unknown>[]>;
/**
* Initializes a new instance of the {@link Publisher} class.
*
* ---
*
* @example
* ```ts
* const publisher = new Publisher();
* ```
*/
public constructor()
{
this._subscribers = new Map();
}
/**
* Unsubscribes all the subscribers from all the events.
*
* ---
*
* @example
* ```ts
* publisher.subscribe("player:spawn", (evt) => { [...] });
* publisher.subscribe("player:move", (coords) => { [...] });
* publisher.subscribe("player:move", () => { [...] });
* publisher.subscribe("player:move", ({ x, y }) => { [...] });
* publisher.subscribe("player:death", () => { [...] });
*
* // All these subscribers are working fine...
*
* publisher.clear();
*
* // ... but now they're all gone!
* ```
*/
public clear(): void
{
// @ts-expect-error It's an internal event, not part of the public API.
this.publish("__internals__:clear");
this._subscribers.clear();
}
/**
* Creates a new scoped instance of the {@link Publisher} class,
* which can be used to publish and subscribe events within a specific context.
*
* It can receive all events published to the parent publisher while also allowing
* the scoped publisher to handle its own events independently.
* In fact, events published to the scoped publisher won't be propagated back to the parent publisher.
*
* ---
*
* @example
* ```ts
* const publisher = new Publisher();
* const context = publisher.createScope();
*
* publisher.subscribe("player:death", () => { console.log(`Player has died.`); });
* context.subscribe("player:spawn", () => { console.log(`Player has spawned.`); });
*
* publisher.publish("player:spawn"); // Player has spawned.
* context.publish("player:death"); // * no output *
* ```
*
* ---
*
* @template U
* A map containing the names of the emittable events and the
* related callback signatures that can be subscribed to them.
* Default is `T`.
*
* @template X An utility type that extends the `U` map with a wildcard event.
*
* @return
* A new instance of the {@link Publisher} class that can be
* used to publish and subscribe events within a specific context.
*/
public createScope<U extends T = T, X extends WithWildcard<U> = WithWildcard<U>>(): Publisher<U, X>
{
const scope = new Publisher<U, X>();
const propagator = (event: (keyof T) & string, ...args: Parameters<T[keyof T]>): void =>
{
scope.publish(event, ...args);
};
// @ts-expect-error It's an internal event, not part of the public API.
this.subscribe("__internals__:clear", () => scope.clear());
this.subscribe("*", propagator as W["*"]);
return scope;
}
/**
* Publishes an event to all the subscribers.
*
* ---
*
* @example
* ```ts
* publisher.subscribe("player:move", (coords) => { [...] });
* publisher.subscribe("player:move", ({ x, y }) => { [...] });
* publisher.subscribe("player:move", (evt) => { [...] });
*
* publisher.publish("player:move", { x: 10, y: 20 });
* ```
*
* ---
*
* @template K The key of the map containing the callback signature to publish.
*
* @param event The name of the event to publish.
* @param args The arguments to pass to the subscribers.
*
* @returns An array containing the return values of all the subscribers.
*/
public publish<K extends keyof T>(event: K & string, ...args: Parameters<T[K]>): ReturnType<T[K]>[]
{
let results: ReturnType<T[K]>[];
let subscribers = this._subscribers.get(event);
if (subscribers)
{
results = subscribers.slice()
.map((subscriber) => subscriber(...args)) as ReturnType<T[K]>[];
}
else { results = []; }
if (!(event.startsWith("__")))
{
subscribers = this._subscribers.get("*");
if (subscribers)
{
subscribers.slice()
.forEach((subscriber) => subscriber(event, ...args));
}
}
return results;
}
/**
* Subscribes to an event and adds a subscriber to be executed when the event is published.
*
* ---
*
* @example
* ```ts
* let unsubscribe: () => void;
* publisher.subscribe("player:death", unsubscribe);
* publisher.subscribe("player:spawn", (evt) =>
* {
* unsubscribe = publisher.subscribe("player:move", ({ x, y }) => { [...] });
* });
* ```
*
* ---
*
* @template K The key of the map containing the callback signature to subscribe.
*
* @param event The name of the event to subscribe to.
* @param subscriber The subscriber to execute when the event is published.
*
* @returns A function that can be used to unsubscribe the subscriber from the event.
*/
public subscribe<K extends keyof W>(event: K & string, subscriber: W[K]): () => void
{
const subscribers = this._subscribers.get(event) ?? [];
subscribers.push(subscriber);
this._subscribers.set(event, subscribers);
return () =>
{
const index = subscribers.indexOf(subscriber);
if (index < 0)
{
throw new ReferenceException("Unable to unsubscribe the required subscriber. " +
"The subscription was already unsubscribed.");
}
subscribers.splice(index, 1);
};
}
/**
* Unsubscribes from an event and removes a subscriber from being executed when the event is published.
*
* ---
*
* @example
* ```ts
* const onPlayerMove = ({ x, y }: Point) => { [...] };
*
* publisher.subscribe("player:spawn", (evt) => publisher.subscribe("player:move", onPlayerMove));
* publisher.subscribe("player:death", () => publisher.unsubscribe("player:move", onPlayerMove));
* ```
*
* ---
*
* @template K The key of the map containing the callback signature to unsubscribe.
*
* @param event The name of the event to unsubscribe from.
* @param subscriber The subscriber to remove from the event.
*/
public unsubscribe<K extends keyof W>(event: K & string, subscriber: W[K]): void
{
const subscribers = this._subscribers.get(event);
if (!(subscribers))
{
throw new ReferenceException("Unable to unsubscribe the required subscriber. " +
"The subscription was already unsubscribed or was never subscribed.");
}
const index = subscribers.indexOf(subscriber);
if (index < 0)
{
throw new ReferenceException("Unable to unsubscribe the required subscriber. " +
"The subscription was already unsubscribed or was never subscribed.");
}
subscribers.splice(index, 1);
if (subscribers.length === 0) { this._subscribers.delete(event); }
}
public readonly [Symbol.toStringTag]: string = "Publisher";
}