From 091ae47eb0350ab7692699ca2914617cb03a31e1 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Wed, 15 Oct 2025 23:36:01 -0700 Subject: [PATCH] feat(lib): type-safe event emitter --- lib/Events.ts | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 lib/Events.ts diff --git a/lib/Events.ts b/lib/Events.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab97b49784723a658ce82ca5c2fef66772f25983 --- /dev/null +++ b/lib/Events.ts @@ -0,0 +1,85 @@ +/** + * type-safe, browser compatible, mini event emitter. + * this module is intended to be imported via the main class. + * + * ```ts + * import { Events } from '@clo/lib/Events'; + * const emitter = new Events<{ change: [string] }>(); + * ``` + * + * @module + */ + +/** + * minimal event emitter supporting typed events. Unlike `EventTarget` and + * `node:events`, this supports an `onAny`, which is used by `lib/progress.ts` + * + * beware that the type parameter cannot be an `interface`. + */ +export class Events { + #channels: { [C in keyof Map]?: Array<(...args: Map[C]) => void> } = {}; + #global: Array<(channel: keyof Map, args: unknown[]) => void> = []; + + /** + * Listen for events on the specified channel + * ```ts + * using dispose = events.on("change", () => { ... }); + * // disposable will remove the listener on scope exit. + * // alternatively, can use `const` and call `dispose()`. + * ``` + */ + on(channel: C, cb: Events.Handler): ts.Dispose { + const list = this.#channels[channel] ??= []; + list.push(cb); + return ts.defer(() => void (list.splice(list.indexOf(cb)), 1)); + } + /** + * listen for one event on the specified channel + * ```ts + * // can be awaited + * const [data] = await events.once("something"); + * // can be given a callback + * events.once("something", (data) => { ... }); + * // can be cancelled + * const cancelable = events.once("something"); + * cancelable.then(() => assert(false)); + * cancelable.cancel(); + * ``` + */ + once( + channel: C, + cb?: Events.Handler, + ): async.Cancelable { + let dispose: ts.Dispose | null = null; + return async.makeCancelable( + new Promise((resolve) => + dispose = this.on(channel, (...args) => { + UNWRAP(dispose)(); + resolve(args); + cb?.(...args); + }) + ), + () => dispose?.(), + ); + } + /** listen for any event */ + onAny(cb: (channel: keyof Map, args: unknown[]) => void): ts.Dispose { + this.#global.push(cb); + return ts.defer(() => + void (this.#global.splice(this.#global.indexOf(cb)), 1) + ); + } + /** emit an event on the specified channel */ + emit(channel: C, ...args: Map[C]): void { + this.#channels[channel]?.forEach((cb) => cb(...args)); + } +} + +export namespace Events { + export type Map = { [key: string]: unknown[] }; + export type Handler = (...args: Args) => void; +} + +import * as ts from "./ts.ts"; +import * as async from "./async.ts"; +import { UNWRAP } from "./assert.ts"; -- 2.54.0