Event Source/Handler helpers with optional frequency ratio limit

A few simple generic classes to create event sources and handlers. The handlers can optionally be set to receive events with a maximum frequency limit.

export type EventHandlerCallback<TArgs extends unknown[]> = (...args: TArgs) => void;

const MARK_REMOVED = Symbol("markRemoved");

export class EventHandler<TArgs extends unknown[]> {
    private lastFired: number = 0;
    private removed: boolean = false;

    constructor(
        private readonly source: EventSource<TArgs>,
        private readonly callback: EventHandlerCallback<TArgs>,
        private readonly minIntervalMS: number,
    ) {}

    get isRemoved(): boolean {
        return this.removed;
    }

    fire(args: TArgs): void {
        // A handler removed earlier in the current dispatch round must not run.
        if (this.removed) {
            return;
        }

        const now = performance.now();;
        if (this.minIntervalMS > 0 && now - this.lastFired < this.minIntervalMS) {
            return;
        }
        this.lastFired = now;
        this.callback(...args);
    }

    remove(): void {
        this.source.removeHandler(this);
    }

    /** @internal — called by EventSource only. */
    [MARK_REMOVED](): void {
        this.removed = true;
    }
}

export class EventSource<TArgs extends unknown[] = []> {
    private readonly handlers: EventHandler<TArgs>[] = [];

    addHandler(callback: EventHandlerCallback<TArgs>, minIntervalMS: number = 0): EventHandler<TArgs> {
        const handler = new EventHandler(this, callback, minIntervalMS);
        this.handlers.push(handler);
        return handler;
    }

    removeHandler(handler: EventHandler<TArgs>): void {
        const idx = this.handlers.indexOf(handler);
        if (idx === -1) {
            return;
        }
        this.handlers.splice(idx, 1);
        handler[MARK_REMOVED]();
    }

    fire(...args: TArgs): void {
        // Iterate over a snapshot: handlers may add or remove handlers while running.
        const snapshot = this.handlers.slice();
        for (const handler of snapshot) {
            try {
                handler.fire(args);
            } catch (error) {
                // One failing handler must not abort the remaining ones. Rethrow
                // asynchronously so the error still surfaces to the global handler
                // instead of being silently swallowed.
                queueMicrotask(() => {
                    throw error;
                });
            }
        }
    }
}

Comments

No comments yet - be the first.

Leave a comment