Skip to content

Commit 4d23975

Browse files
committed
event notifier
1 parent c9b9a2e commit 4d23975

File tree

2 files changed

+294
-0
lines changed

2 files changed

+294
-0
lines changed

src/shared/eventNotifier.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { createEventNotifier } from "./eventNotifier.js";
2+
3+
describe("EventNotifier", () => {
4+
let notifier: ReturnType<typeof createEventNotifier<string>>;
5+
6+
beforeEach(() => {
7+
notifier = createEventNotifier<string>();
8+
});
9+
10+
test("should notify listeners in registration order", () => {
11+
const events: string[] = [];
12+
notifier.onEvent((event) => events.push(`first: ${event}`));
13+
notifier.onEvent((event) => events.push(`second: ${event}`));
14+
notifier.onEvent((event) => events.push(`third: ${event}`));
15+
16+
notifier.notify("test event");
17+
18+
expect(events).toEqual([
19+
"first: test event",
20+
"second: test event",
21+
"third: test event",
22+
]);
23+
});
24+
25+
test("should not notify unsubscribed listeners", () => {
26+
const events: string[] = [];
27+
const subscription = notifier.onEvent((event) => events.push(event));
28+
29+
notifier.notify("first event");
30+
subscription.close();
31+
notifier.notify("second event");
32+
33+
expect(events).toEqual(["first event"]);
34+
});
35+
36+
test("should handle function events", () => {
37+
const events: string[] = [];
38+
notifier.onEvent((event) => events.push(event));
39+
40+
notifier.notify(() => "dynamic event");
41+
42+
expect(events).toEqual(["dynamic event"]);
43+
});
44+
45+
test("should handle errors through error handler", () => {
46+
const errors: Error[] = [];
47+
notifier.onError((error) => errors.push(error));
48+
49+
notifier.onEvent(() => {
50+
throw new Error("test error");
51+
});
52+
53+
notifier.notify("test event");
54+
55+
expect(errors).toHaveLength(1);
56+
expect(errors[0]).toBeInstanceOf(Error);
57+
expect(errors[0].message).toBe("test error");
58+
});
59+
60+
test("should not notify after close", () => {
61+
const events: string[] = [];
62+
notifier.onEvent((event) => events.push(event));
63+
64+
notifier.notify("first event");
65+
notifier.close();
66+
notifier.notify("second event");
67+
68+
expect(events).toEqual(["first event"]);
69+
});
70+
71+
test("should handle multiple subscriptions and unsubscriptions", () => {
72+
const events: string[] = [];
73+
const subscription1 = notifier.onEvent((event) =>
74+
events.push(`1: ${event}`)
75+
);
76+
const subscription2 = notifier.onEvent((event) =>
77+
events.push(`2: ${event}`)
78+
);
79+
80+
notifier.notify("first event");
81+
subscription1.close();
82+
notifier.notify("second event");
83+
subscription2.close();
84+
notifier.notify("third event");
85+
86+
expect(events).toEqual([
87+
"1: first event",
88+
"2: first event",
89+
"2: second event",
90+
]);
91+
});
92+
93+
test("should handle error handler after close", () => {
94+
const errors: Error[] = [];
95+
notifier.onError((error) => errors.push(error));
96+
notifier.close();
97+
98+
notifier.onEvent(() => {
99+
throw new Error("test error");
100+
});
101+
102+
notifier.notify("test event");
103+
104+
expect(errors).toHaveLength(0);
105+
});
106+
107+
test("should clear error handler on close", () => {
108+
const errors: Error[] = [];
109+
notifier.onError((error) => errors.push(error));
110+
111+
// Close should clear the error handler
112+
notifier.close();
113+
114+
// Setting up a new listener after close
115+
notifier.onEvent(() => {
116+
throw new Error("test error");
117+
});
118+
119+
// This should not trigger the error handler since the notifier is closed
120+
notifier.notify("test event");
121+
122+
expect(errors).toHaveLength(0);
123+
});
124+
125+
test("should use the last set error handler", () => {
126+
const errors1: Error[] = [];
127+
const errors2: Error[] = [];
128+
129+
// First error handler
130+
notifier.onError((error) => errors1.push(error));
131+
132+
// Second error handler should replace the first one
133+
notifier.onError((error) => errors2.push(error));
134+
135+
notifier.onEvent(() => {
136+
throw new Error("test error");
137+
});
138+
139+
notifier.notify("test event");
140+
141+
expect(errors1).toHaveLength(0);
142+
expect(errors2).toHaveLength(1);
143+
expect(errors2[0].message).toBe("test error");
144+
});
145+
});

src/shared/eventNotifier.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* Provides a simple, type-safe event notification implementation. This module allows components to implement the
3+
* observer pattern with minimal boilerplate and proper type checking.
4+
*/
5+
6+
/**
7+
* A type-safe event notifier that manages event listeners and notifications.
8+
*
9+
* @template T The type of events this notifier will handle
10+
* @template E The type of error that can be handled (defaults to Error)
11+
*
12+
* EventNotifier provides:
13+
*
14+
* - Type-safe event subscriptions via `onEvent`
15+
* - Synchronized event notifications via `notify`
16+
* - Automatic cleanup of resources via `close`
17+
* - Status tracking via `active` property
18+
* - Error handling via optional error callback
19+
*/
20+
export type EventNotifier<T, E = Error> = {
21+
/**
22+
* Registers a listener function to be called when events are notified. Listeners are notified in the order they
23+
* were registered.
24+
*
25+
* @example
26+
*
27+
* ```ts
28+
* const notifier = createEventNotifier<string>();
29+
* const subscription = notifier.onEvent((message) => {
30+
* console.log(`Received message: ${message}`);
31+
* });
32+
*
33+
* // Later, to stop listening:
34+
* subscription.close();
35+
* ```
36+
*
37+
* @param listener A function that will be called with the notified event
38+
* @returns A SyncCloseable that, when closed, will unregister the listener
39+
*/
40+
onEvent: (listener: (event: T) => unknown) => { close: () => void };
41+
42+
/**
43+
* Notifies all registered listeners with the provided event.
44+
*
45+
* This method:
46+
*
47+
* - Calls all registered listeners with the event in their registration order
48+
* - Ignores errors thrown by listeners (they won't affect other listeners)
49+
* - Ignores returned promises (results are not awaited)
50+
* - Does nothing if there are no listeners
51+
* - If the event is a function, it will be called if there are listeners and its return value will be
52+
* used as the event.
53+
*
54+
* @example
55+
*
56+
* ```ts
57+
* const notifier = createEventNotifier<{ type: string; data: unknown }>();
58+
* notifier.onEvent((event) => {
59+
* console.log(`Received ${event.type} with data:`, event.data);
60+
* });
61+
*
62+
* notifier.notify({ type: 'update', data: { id: 123, status: 'complete' } });
63+
* ```
64+
*
65+
* @param event The event to send to all listeners or a function that returns such event.
66+
*/
67+
notify: (event: T | (() => T)) => void;
68+
69+
/**
70+
* Sets an error handler for the notifier. This handler will be called when a listener throws an error.
71+
*
72+
* @param handler A function that will be called with any errors thrown by listeners
73+
*/
74+
onError: (handler: (error: E) => void) => void;
75+
76+
/**
77+
* Closes the notifier and removes all listeners.
78+
*
79+
* @warning Failing to call close() on subscriptions or the notifier itself may lead to memory leaks.
80+
*/
81+
close: () => void;
82+
};
83+
84+
/**
85+
* Creates a type-safe event notifier.
86+
*
87+
* @example
88+
*
89+
* ```ts
90+
* // Simple string event notifier
91+
* const stringNotifier = createEventNotifier<string>();
92+
*
93+
* // Complex object event notifier
94+
* interface UserEvent {
95+
* type: 'created' | 'updated' | 'deleted';
96+
* userId: number;
97+
* data?: Record<string, unknown>;
98+
* }
99+
* const userNotifier = createEventNotifier<UserEvent>();
100+
* ```
101+
*
102+
* @template T The type of events this notifier will handle
103+
* @template E The type of error that can be handled (defaults to Error)
104+
* @returns A new EventNotifier instance
105+
*/
106+
export const createEventNotifier = <T, E = Error>(): EventNotifier<T, E> => {
107+
const listeners = new Set<(event: T) => unknown>();
108+
let errorHandler: ((error: E) => void) | undefined;
109+
110+
return {
111+
close: () => {
112+
listeners.clear();
113+
errorHandler = undefined;
114+
},
115+
116+
onEvent: (listener) => {
117+
listeners.add(listener);
118+
return {
119+
close: () => {
120+
listeners.delete(listener);
121+
},
122+
};
123+
},
124+
125+
notify: (event: T | (() => T)) => {
126+
if (!listeners.size) {
127+
return;
128+
}
129+
130+
if (typeof event === "function") {
131+
event = (event as () => T)();
132+
}
133+
134+
for (const listener of listeners) {
135+
try {
136+
void listener(event);
137+
} catch (error) {
138+
if (errorHandler) {
139+
errorHandler(error as E);
140+
}
141+
}
142+
}
143+
},
144+
145+
onError: (handler) => {
146+
errorHandler = handler;
147+
},
148+
};
149+
};

0 commit comments

Comments
 (0)