event notifier
This commit is contained in:
parent
b5e6d7bc32
commit
f5b4254add
2 changed files with 294 additions and 0 deletions
145
src/shared/eventNotifier.test.ts
Normal file
145
src/shared/eventNotifier.test.ts
Normal 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
149
src/shared/eventNotifier.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
};
|
||||
Loading…
Reference in a new issue