authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-15 23:36:01-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-14 01:08:14-08:00
log091ae47eb0350ab7692699ca2914617cb03a31e1
treebeff8198b0ef6e4ce53069643a06c52aa08b78dc
parent8a84a3a419254b2273898266e8e32ebd36843020
signature Commit is signed but in an unrecognized format.

feat(lib): type-safe event emitter


1 files changed, 85 insertions(+), 0 deletions(-)

lib/Events.ts created+85
......@@ -0,0 +1,85 @@
1/**
2 * type-safe, browser compatible, mini event emitter.
3 * this module is intended to be imported via the main class.
4 *
5 * ```ts
6 * import { Events } from '@clo/lib/Events';
7 * const emitter = new Events<{ change: [string] }>();
8 * ```
9 *
10 * @module
11 */
12
13/**
14 * minimal event emitter supporting typed events. Unlike `EventTarget` and
15 * `node:events`, this supports an `onAny`, which is used by `lib/progress.ts`
16 *
17 * beware that the type parameter cannot be an `interface`.
18 */
19export class Events<Map extends Events.Map> {
20 #channels: { [C in keyof Map]?: Array<(...args: Map[C]) => void> } = {};
21 #global: Array<(channel: keyof Map, args: unknown[]) => void> = [];
22
23 /**
24 * Listen for events on the specified channel
25 * ```ts
26 * using dispose = events.on("change", () => { ... });
27 * // disposable will remove the listener on scope exit.
28 * // alternatively, can use `const` and call `dispose()`.
29 * ```
30 */
31 on<C extends keyof Map>(channel: C, cb: Events.Handler<Map[C]>): ts.Dispose {
32 const list = this.#channels[channel] ??= [];
33 list.push(cb);
34 return ts.defer(() => void (list.splice(list.indexOf(cb)), 1));
35 }
36 /**
37 * listen for one event on the specified channel
38 * ```ts
39 * // can be awaited
40 * const [data] = await events.once("something");
41 * // can be given a callback
42 * events.once("something", (data) => { ... });
43 * // can be cancelled
44 * const cancelable = events.once("something");
45 * cancelable.then(() => assert(false));
46 * cancelable.cancel();
47 * ```
48 */
49 once<C extends keyof Map>(
50 channel: C,
51 cb?: Events.Handler<Map[C]>,
52 ): async.Cancelable<Map[C]> {
53 let dispose: ts.Dispose | null = null;
54 return async.makeCancelable(
55 new Promise((resolve) =>
56 dispose = this.on(channel, (...args) => {
57 UNWRAP(dispose)();
58 resolve(args);
59 cb?.(...args);
60 })
61 ),
62 () => dispose?.(),
63 );
64 }
65 /** listen for any event */
66 onAny(cb: (channel: keyof Map, args: unknown[]) => void): ts.Dispose {
67 this.#global.push(cb);
68 return ts.defer(() =>
69 void (this.#global.splice(this.#global.indexOf(cb)), 1)
70 );
71 }
72 /** emit an event on the specified channel */
73 emit<C extends keyof Map>(channel: C, ...args: Map[C]): void {
74 this.#channels[channel]?.forEach((cb) => cb(...args));
75 }
76}
77
78export namespace Events {
79 export type Map = { [key: string]: unknown[] };
80 export type Handler<Args extends unknown[]> = (...args: Args) => void;
81}
82
83import * as ts from "./ts.ts";
84import * as async from "./async.ts";
85import { UNWRAP } from "./assert.ts";