diff --git a/src/events/TypedEventEmitter.ts b/src/events/TypedEventEmitter.ts index 7fd8279..1bdd5f3 100644 --- a/src/events/TypedEventEmitter.ts +++ b/src/events/TypedEventEmitter.ts @@ -28,29 +28,51 @@ export class AbortError extends Error { } export type EventMap = Record; +export type WildcardEventHandler = ( + event: keyof T, + payload: T[keyof T], +) => void; export class TypedEventEmitter { private listeners: { [K in keyof T]?: Set<(payload: T[K]) => void> } = {}; + private wildcardListeners = new Set>(); /** * Register a handler for `event`. Returns an unsubscribe function. */ - on(event: K, handler: (payload: T[K]) => void): Unsubscribe { + on(event: K, handler: (payload: T[K]) => void): Unsubscribe; + on(event: "*", handler: WildcardEventHandler): Unsubscribe; + on(event: keyof T | "*", handler: ((payload: never) => void) | WildcardEventHandler): Unsubscribe { + if (event === "*") { + const wildcardHandler = handler as WildcardEventHandler; + this.wildcardListeners.add(wildcardHandler); + return () => this.wildcardListeners.delete(wildcardHandler); + } + + const typedHandler = handler as (payload: T[typeof event]) => void; let set = this.listeners[event]; if (!set) { set = new Set(); this.listeners[event] = set; } - set.add(handler); - return () => set!.delete(handler); + set.add(typedHandler); + return () => set!.delete(typedHandler); } /** * Remove a previously registered handler for `event`. No-op if the handler * was never registered (or already removed). */ - off(event: K, handler: (payload: T[K]) => void): void { - this.listeners[event]?.delete(handler); + off(event: K, handler: (payload: T[K]) => void): void; + off(event: "*", handler: WildcardEventHandler): void; + off(event: keyof T | "*", handler: ((payload: never) => void) | WildcardEventHandler): void { + if (event === "*") { + this.wildcardListeners.delete(handler as WildcardEventHandler); + return; + } + + const typedHandler = handler as (payload: T[typeof event]) => void; + this.listeners[event]?.delete(typedHandler); } /** @@ -58,9 +80,16 @@ export class TypedEventEmitter { */ emit(event: K, payload: T[K]): void { const set = this.listeners[event]; - if (!set || set.size === 0) return; - for (const handler of Array.from(set)) { - handler(payload); + if (set && set.size > 0) { + for (const handler of Array.from(set)) { + handler(payload); + } + } + + if (this.wildcardListeners.size > 0) { + for (const handler of Array.from(this.wildcardListeners)) { + handler(event, payload); + } } } @@ -69,8 +98,10 @@ export class TypedEventEmitter { * provided and fires before the event, the promise rejects with an * {@link AbortError} instead. */ - once(event: K, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { + once(event: K, signal?: AbortSignal): Promise; + once(event: "*", signal?: AbortSignal): Promise<{ event: keyof T; payload: T[keyof T] }>; + once(event: keyof T | "*", signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new AbortError()); return; @@ -84,27 +115,46 @@ export class TypedEventEmitter { reject(new AbortError()); }; - unsubscribe = this.on(event, (payload) => { - signal?.removeEventListener("abort", onAbort); - unsubscribe(); - resolve(payload); - }); + if (event === "*") { + unsubscribe = this.on("*", (eventName, payload) => { + signal?.removeEventListener("abort", onAbort); + unsubscribe(); + resolve({ event: eventName, payload }); + }); + } else { + unsubscribe = this.on(event, (payload) => { + signal?.removeEventListener("abort", onAbort); + unsubscribe(); + resolve(payload); + }); + } signal?.addEventListener("abort", onAbort); }); } /** Remove all handlers for `event`, or every handler for every event if omitted. */ - removeAllListeners(event?: K): void { + removeAllListeners(): void; + removeAllListeners(event: K): void; + removeAllListeners(event: "*"): void; + removeAllListeners(event?: keyof T | "*"): void { if (event === undefined) { this.listeners = {}; + this.wildcardListeners.clear(); + } else if (event === "*") { + this.wildcardListeners.clear(); } else { delete this.listeners[event]; } } /** Number of handlers currently registered for `event`. */ - listenerCount(event: K): number { + listenerCount(event: K): number; + listenerCount(event: "*"): number; + listenerCount(event: keyof T | "*"): number { + if (event === "*") { + return this.wildcardListeners.size; + } return this.listeners[event]?.size ?? 0; } } diff --git a/src/index.ts b/src/index.ts index b030cf6..e74513e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1404,7 +1404,11 @@ export type { // #588 — Soroban Transaction Footprint Optimizer // --------------------------------------------------------------------------- -export { optimizeFootprint } from "./soroban/footprint.js"; +export { + optimizeFootprint, + simulateFootprint, + clearFootprintSimulationCache, +} from "./soroban/footprint.js"; export type { OptimizeFootprintOptions, FootprintLogger, diff --git a/test/TypedEventEmitter.test.ts b/test/TypedEventEmitter.test.ts index 2434087..b2d83be 100644 --- a/test/TypedEventEmitter.test.ts +++ b/test/TypedEventEmitter.test.ts @@ -83,6 +83,18 @@ describe("TypedEventEmitter", () => { expect(emitter.listenerCount("ping")).toBe(0); }); + it("invokes wildcard listeners for every emitted event", () => { + const emitter = new TypedEventEmitter(); + const handler = vi.fn(); + emitter.on("*", handler); + + emitter.emit("greeting", { name: "Ada" }); + emitter.emit("ping", undefined); + + expect(handler).toHaveBeenNthCalledWith(1, "greeting", { name: "Ada" }); + expect(handler).toHaveBeenNthCalledWith(2, "ping", undefined); + }); + describe("once()", () => { it("resolves with the payload of the next emission", async () => { const emitter = new TypedEventEmitter();