Add event handler class

This commit is contained in:
Andras Schmelczer 2025-12-07 13:30:45 +00:00
parent 1ed22c72d7
commit ad3191957a
14 changed files with 2428 additions and 2309 deletions

View file

@ -0,0 +1,147 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { EventListeners } from "./event-listeners";
describe("EventListeners", () => {
it("should add & remove listeners", () => {
const listeners = new EventListeners<() => void>();
const listener = () => { };
listeners.add(listener);
assert.strictEqual(listeners.count, 1);
const removed = listeners.remove(listener);
assert.strictEqual(removed, true);
assert.strictEqual(listeners.count, 0);
});
it("should remove listeners using unsubscribe function", () => {
const listeners = new EventListeners<() => void>();
const listener = () => { };
const unsubscribe = listeners.add(listener);
unsubscribe();
assert.strictEqual(listeners.count, 0);
});
it("should return false when removing non-existent listener", () => {
const listeners = new EventListeners<() => void>();
const listener = () => { };
const removed = listeners.remove(listener);
assert.strictEqual(removed, false);
});
it("should handle multiple listeners", () => {
const listeners = new EventListeners<() => void>();
const listener1 = () => { };
const listener2 = () => { };
const listener3 = () => { };
listeners.add(listener1);
listeners.add(listener2);
listeners.add(listener3);
assert.strictEqual(listeners.count, 3);
listeners.remove(listener2);
assert.strictEqual(listeners.count, 2);
});
it("should trigger all listeners synchronously", () => {
const listeners = new EventListeners<(value: string) => void>();
const calls: string[] = [];
listeners.add((value) => calls.push(`listener1-${value}`));
listeners.add((value) => calls.push(`listener2-${value}`));
listeners.trigger("test");
assert.deepStrictEqual(calls, ["listener1-test", "listener2-test"]);
});
it("should trigger listeners with multiple arguments", () => {
const listeners = new EventListeners<
(a: number, b: string, c: boolean) => void
>();
const calls: [number, string, boolean][] = [];
listeners.add((a, b, c) => calls.push([a, b, c]));
listeners.trigger(42, "hello", true);
assert.deepStrictEqual(calls, [[42, "hello", true]]);
});
it("should not trigger removed listeners", () => {
const listeners = new EventListeners<() => void>();
let count1 = 0;
let count2 = 0;
const listener1 = () => {
count1++;
};
const listener2 = () => {
count2++;
};
listeners.add(listener1);
const unsubscribe = listeners.add(listener2);
unsubscribe();
listeners.trigger();
assert.strictEqual(count1, 1);
assert.strictEqual(count2, 0);
});
it("should trigger all listeners and await promises", async () => {
const listeners = new EventListeners<
(value: string) => Promise<void> | void
>();
const results: string[] = [];
listeners.add(async (value) => {
await new Promise((resolve) => setTimeout(resolve, 10));
results.push(`async1-${value}`);
});
listeners.add((value) => {
results.push(`sync-${value}`);
});
listeners.add(async (value) => {
await new Promise((resolve) => setTimeout(resolve, 5));
results.push(`async2-${value}`);
});
await listeners.triggerAsync("test");
assert.ok(results.includes("async1-test"));
assert.ok(results.includes("sync-test"));
assert.ok(results.includes("async2-test"));
assert.strictEqual(results.length, 3);
});
it("should not trigger cleared listeners", () => {
const listeners = new EventListeners<() => void>();
let called = false;
const listener = () => {
called = true;
};
listeners.add(listener);
listeners.clear();
assert.strictEqual(listeners.count, 0);
listeners.trigger();
assert.strictEqual(called, false);
});
});

View file

@ -0,0 +1,71 @@
import { removeFromArray } from "../remove-from-array";
import { awaitAll } from "../await-all";
/**
* A utility class for managing event listeners with type-safe add/remove operations.
*/
export class EventListeners<TListener extends (...args: any[]) => any> {
private readonly listeners: TListener[] = [];
/**
* Adds a new listener to the collection.
*
* @param listener The listener callback to add
* @returns An unsubscribe function that removes this listener when called
*/
public add(listener: TListener): () => void {
this.listeners.push(listener);
return () => this.remove(listener);
}
/**
* Removes a listener from the collection.
*
* @param listener The listener callback to remove
* @returns true if the listener was found and removed, false otherwise
*/
public remove(listener: TListener): boolean {
return removeFromArray(this.listeners, listener);
}
/**
* Triggers all listeners synchronously with the provided arguments.
* Any returned promises are ignored. Use triggerAsync() to await them.
*
* @param args The arguments to pass to each listener
*/
public trigger(...args: Parameters<TListener>): void {
this.listeners.forEach((listener) => {
listener(...args);
});
}
/**
* Triggers all listeners and awaits any promises they return.
* Synchronous listeners are called immediately, and any async listeners
* are awaited in parallel.
*
* @param args The arguments to pass to each listener
*/
public async triggerAsync(...args: Parameters<TListener>): Promise<void> {
await awaitAll(
this.listeners
.map((listener) => {
return listener(...args);
})
.filter((result): result is Promise<unknown> => {
return result instanceof Promise;
})
);
}
public clear(): void {
this.listeners.length = 0;
}
public get count(): number {
return this.listeners.length;
}
}

View file

@ -3,7 +3,7 @@ import type { LogLine } from "../../tracing/logger";
import { LogLevel } from "../../tracing/logger";
export function logToConsole(client: SyncClient): void {
client.logger.addOnMessageListener((logLine: LogLine) => {
client.logger.onLogEmitted.add((logLine: LogLine) => {
const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`;
switch (logLine.level) {