Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 67 additions & 17 deletions src/events/TypedEventEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,39 +28,68 @@ export class AbortError extends Error {
}

export type EventMap = Record<string, unknown>;
export type WildcardEventHandler<T extends EventMap> = (
event: keyof T,
payload: T[keyof T],
) => void;

export class TypedEventEmitter<T extends EventMap> {
private listeners: { [K in keyof T]?: Set<(payload: T[K]) => void> } = {};
private wildcardListeners = new Set<WildcardEventHandler<T>>();

/**
* Register a handler for `event`. Returns an unsubscribe function.
*/
on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): Unsubscribe {
on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): Unsubscribe;
on(event: "*", handler: WildcardEventHandler<T>): Unsubscribe;
on(event: keyof T | "*", handler: ((payload: never) => void) | WildcardEventHandler<T>): Unsubscribe {
if (event === "*") {
const wildcardHandler = handler as WildcardEventHandler<T>;
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<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void {
this.listeners[event]?.delete(handler);
off<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void;
off(event: "*", handler: WildcardEventHandler<T>): void;
off(event: keyof T | "*", handler: ((payload: never) => void) | WildcardEventHandler<T>): void {
if (event === "*") {
this.wildcardListeners.delete(handler as WildcardEventHandler<T>);
return;
}

const typedHandler = handler as (payload: T[typeof event]) => void;
this.listeners[event]?.delete(typedHandler);
}

/**
* Synchronously invoke every handler registered for `event` with `payload`.
*/
emit<K extends keyof T>(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);
}
}
}

Expand All @@ -69,8 +98,10 @@ export class TypedEventEmitter<T extends EventMap> {
* provided and fires before the event, the promise rejects with an
* {@link AbortError} instead.
*/
once<K extends keyof T>(event: K, signal?: AbortSignal): Promise<T[K]> {
return new Promise<T[K]>((resolve, reject) => {
once<K extends keyof T>(event: K, signal?: AbortSignal): Promise<T[K]>;
once(event: "*", signal?: AbortSignal): Promise<{ event: keyof T; payload: T[keyof T] }>;
once(event: keyof T | "*", signal?: AbortSignal): Promise<unknown> {
return new Promise<unknown>((resolve, reject) => {
if (signal?.aborted) {
reject(new AbortError());
return;
Expand All @@ -84,27 +115,46 @@ export class TypedEventEmitter<T extends EventMap> {
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<K extends keyof T>(event?: K): void {
removeAllListeners(): void;
removeAllListeners<K extends keyof T>(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<K extends keyof T>(event: K): number {
listenerCount<K extends keyof T>(event: K): number;
listenerCount(event: "*"): number;
listenerCount(event: keyof T | "*"): number {
if (event === "*") {
return this.wildcardListeners.size;
}
return this.listeners[event]?.size ?? 0;
}
}
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions test/TypedEventEmitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ describe("TypedEventEmitter", () => {
expect(emitter.listenerCount("ping")).toBe(0);
});

it("invokes wildcard listeners for every emitted event", () => {
const emitter = new TypedEventEmitter<TestEvents>();
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<TestEvents>();
Expand Down