event notifier

This commit is contained in:
Marcelo Paternostro 2025-04-13 19:50:24 -04:00
commit f5b4254add
No known key found for this signature in database
2 changed files with 294 additions and 0 deletions

View file

@ -0,0 +1,145 @@
import { createEventNotifier } from "./eventNotifier.js";
describe("EventNotifier", () => {
let notifier: ReturnType<typeof createEventNotifier<string>>;
beforeEach(() => {
notifier = createEventNotifier<string>();
});
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");
});
});

149
src/shared/eventNotifier.ts Normal file
View file

@ -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<T, E = Error> = {
/**
* 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<string>();
* 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<string>();
*
* // Complex object event notifier
* interface UserEvent {
* type: 'created' | 'updated' | 'deleted';
* userId: number;
* data?: Record<string, unknown>;
* }
* const userNotifier = createEventNotifier<UserEvent>();
* ```
*
* @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 = <T, E = Error>(): EventNotifier<T, E> => {
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;
},
};
};