From f5b4254add2c308b5cc540cc7bbc18eb92cdc368 Mon Sep 17 00:00:00 2001 From: Marcelo Paternostro Date: Sun, 13 Apr 2025 19:50:24 -0400 Subject: [PATCH] event notifier --- src/shared/eventNotifier.test.ts | 145 ++++++++++++++++++++++++++++++ src/shared/eventNotifier.ts | 149 +++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 src/shared/eventNotifier.test.ts create mode 100644 src/shared/eventNotifier.ts diff --git a/src/shared/eventNotifier.test.ts b/src/shared/eventNotifier.test.ts new file mode 100644 index 00000000..2923f7a0 --- /dev/null +++ b/src/shared/eventNotifier.test.ts @@ -0,0 +1,145 @@ +import { createEventNotifier } from "./eventNotifier.js"; + +describe("EventNotifier", () => { + let notifier: ReturnType>; + + beforeEach(() => { + notifier = createEventNotifier(); + }); + + test("should notify listeners in registration order", () => { + const events: string[] = []; + notifier.onEvent((event) => events.push(`first: ${event}`)); + notifier.onEvent((event) => events.push(`second: ${event}`)); + notifier.onEvent((event) => events.push(`third: ${event}`)); + + notifier.notify("test event"); + + expect(events).toEqual([ + "first: test event", + "second: test event", + "third: test event", + ]); + }); + + test("should not notify unsubscribed listeners", () => { + const events: string[] = []; + const subscription = notifier.onEvent((event) => events.push(event)); + + notifier.notify("first event"); + subscription.close(); + notifier.notify("second event"); + + expect(events).toEqual(["first event"]); + }); + + test("should handle function events", () => { + const events: string[] = []; + notifier.onEvent((event) => events.push(event)); + + notifier.notify(() => "dynamic event"); + + expect(events).toEqual(["dynamic event"]); + }); + + test("should handle errors through error handler", () => { + const errors: Error[] = []; + notifier.onError((error) => errors.push(error)); + + notifier.onEvent(() => { + throw new Error("test error"); + }); + + notifier.notify("test event"); + + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(Error); + expect(errors[0].message).toBe("test error"); + }); + + test("should not notify after close", () => { + const events: string[] = []; + notifier.onEvent((event) => events.push(event)); + + notifier.notify("first event"); + notifier.close(); + notifier.notify("second event"); + + expect(events).toEqual(["first event"]); + }); + + test("should handle multiple subscriptions and unsubscriptions", () => { + const events: string[] = []; + const subscription1 = notifier.onEvent((event) => + events.push(`1: ${event}`) + ); + const subscription2 = notifier.onEvent((event) => + events.push(`2: ${event}`) + ); + + notifier.notify("first event"); + subscription1.close(); + notifier.notify("second event"); + subscription2.close(); + notifier.notify("third event"); + + expect(events).toEqual([ + "1: first event", + "2: first event", + "2: second event", + ]); + }); + + test("should handle error handler after close", () => { + const errors: Error[] = []; + notifier.onError((error) => errors.push(error)); + notifier.close(); + + notifier.onEvent(() => { + throw new Error("test error"); + }); + + notifier.notify("test event"); + + expect(errors).toHaveLength(0); + }); + + test("should clear error handler on close", () => { + const errors: Error[] = []; + notifier.onError((error) => errors.push(error)); + + // Close should clear the error handler + notifier.close(); + + // Setting up a new listener after close + notifier.onEvent(() => { + throw new Error("test error"); + }); + + // This should not trigger the error handler since the notifier is closed + notifier.notify("test event"); + + expect(errors).toHaveLength(0); + }); + + test("should use the last set error handler", () => { + const errors1: Error[] = []; + const errors2: Error[] = []; + + // First error handler + notifier.onError((error) => errors1.push(error)); + + // Second error handler should replace the first one + notifier.onError((error) => errors2.push(error)); + + notifier.onEvent(() => { + throw new Error("test error"); + }); + + notifier.notify("test event"); + + expect(errors1).toHaveLength(0); + expect(errors2).toHaveLength(1); + expect(errors2[0].message).toBe("test error"); + }); +}); diff --git a/src/shared/eventNotifier.ts b/src/shared/eventNotifier.ts new file mode 100644 index 00000000..349a9446 --- /dev/null +++ b/src/shared/eventNotifier.ts @@ -0,0 +1,149 @@ +/** + * Provides a simple, type-safe event notification implementation. This module allows components to implement the + * observer pattern with minimal boilerplate and proper type checking. + */ + +/** + * A type-safe event notifier that manages event listeners and notifications. + * + * @template T The type of events this notifier will handle + * @template E The type of error that can be handled (defaults to Error) + * + * EventNotifier provides: + * + * - Type-safe event subscriptions via `onEvent` + * - Synchronized event notifications via `notify` + * - Automatic cleanup of resources via `close` + * - Status tracking via `active` property + * - Error handling via optional error callback + */ +export type EventNotifier = { + /** + * Registers a listener function to be called when events are notified. Listeners are notified in the order they + * were registered. + * + * @example + * + * ```ts + * const notifier = createEventNotifier(); + * const subscription = notifier.onEvent((message) => { + * console.log(`Received message: ${message}`); + * }); + * + * // Later, to stop listening: + * subscription.close(); + * ``` + * + * @param listener A function that will be called with the notified event + * @returns A SyncCloseable that, when closed, will unregister the listener + */ + onEvent: (listener: (event: T) => unknown) => { close: () => void }; + + /** + * Notifies all registered listeners with the provided event. + * + * This method: + * + * - Calls all registered listeners with the event in their registration order + * - Ignores errors thrown by listeners (they won't affect other listeners) + * - Ignores returned promises (results are not awaited) + * - Does nothing if there are no listeners + * - If the event is a function, it will be called if there are listeners and its return value will be + * used as the event. + * + * @example + * + * ```ts + * const notifier = createEventNotifier<{ type: string; data: unknown }>(); + * notifier.onEvent((event) => { + * console.log(`Received ${event.type} with data:`, event.data); + * }); + * + * notifier.notify({ type: 'update', data: { id: 123, status: 'complete' } }); + * ``` + * + * @param event The event to send to all listeners or a function that returns such event. + */ + notify: (event: T | (() => T)) => void; + + /** + * Sets an error handler for the notifier. This handler will be called when a listener throws an error. + * + * @param handler A function that will be called with any errors thrown by listeners + */ + onError: (handler: (error: E) => void) => void; + + /** + * Closes the notifier and removes all listeners. + * + * @warning Failing to call close() on subscriptions or the notifier itself may lead to memory leaks. + */ + close: () => void; +}; + +/** + * Creates a type-safe event notifier. + * + * @example + * + * ```ts + * // Simple string event notifier + * const stringNotifier = createEventNotifier(); + * + * // Complex object event notifier + * interface UserEvent { + * type: 'created' | 'updated' | 'deleted'; + * userId: number; + * data?: Record; + * } + * const userNotifier = createEventNotifier(); + * ``` + * + * @template T The type of events this notifier will handle + * @template E The type of error that can be handled (defaults to Error) + * @returns A new EventNotifier instance + */ +export const createEventNotifier = (): EventNotifier => { + const listeners = new Set<(event: T) => unknown>(); + let errorHandler: ((error: E) => void) | undefined; + + return { + close: () => { + listeners.clear(); + errorHandler = undefined; + }, + + onEvent: (listener) => { + listeners.add(listener); + return { + close: () => { + listeners.delete(listener); + }, + }; + }, + + notify: (event: T | (() => T)) => { + if (!listeners.size) { + return; + } + + if (typeof event === "function") { + event = (event as () => T)(); + } + + for (const listener of listeners) { + try { + void listener(event); + } catch (error) { + if (errorHandler) { + errorHandler(error as E); + } + } + } + }, + + onError: (handler) => { + errorHandler = handler; + }, + }; +};