UNPKG

evnty

Version:

Async-first, reactive event handling library for complex event flows in browser and Node.js

349 lines (309 loc) 10.7 kB
import { RingBuffer } from './ring-buffer.js'; import { Signal } from './signal.js'; import { Disposer } from './async.js'; import { min } from './utils.js'; import { Action, Fn, Emitter, MaybePromise, Promiseable } from './types.js'; /** * A handle representing a consumer's position in a Broadcast. * Returned by `Broadcast.join()` and used to consume values. * Implements Disposable for automatic cleanup via `using` keyword. * * @example * ```typescript * const broadcast = new Broadcast<number>(); * using handle = broadcast.join(); * broadcast.emit(42); * const value = broadcast.consume(handle); // 42 * ``` */ export class ConsumerHandle<T> implements Disposable { #broadcast: Broadcast<T>; constructor(broadcast: Broadcast<T>) { this.#broadcast = broadcast; } /** * The current position of this consumer in the buffer. */ get cursor(): number { return this.#broadcast.getCursor(this); } /** * Leaves the broadcast, releasing this consumer's position. */ [Symbol.dispose](): void { this.#broadcast.leave(this); } } /** * @internal */ export class BroadcastIterator<T> implements AsyncIterator<T, void, void> { #broadcast: Broadcast<T>; #signal: Signal<T>; #handle: ConsumerHandle<T>; constructor(broadcast: Broadcast<T>, signal: Signal<T>, handle: ConsumerHandle<T>) { this.#broadcast = broadcast; this.#signal = signal; this.#handle = handle; } async next(): Promise<IteratorResult<T, void>> { try { while (true) { const result = this.#broadcast.tryConsume(this.#handle); if (!result.done) { return { value: result.value, done: false }; } await this.#signal.receive(); } } catch { return { value: undefined, done: true }; } } async return(): Promise<IteratorResult<T, void>> { this.#broadcast.leave(this.#handle); return { value: undefined, done: true }; } } /** * A multi-consumer FIFO queue where each consumer maintains its own read position. * Values are buffered and each consumer can read them independently at their own pace. * The buffer automatically compacts when all consumers have read past a position. * * Key characteristics: * - Multiple consumers - each gets their own cursor position * - Buffered delivery - values are stored until all consumers read them * - Late joiners only see values emitted after joining * - Automatic cleanup via FinalizationRegistry when handles are garbage collected * * Differs from: * - Event: Broadcast buffers values, Event does not * - Sequence: Broadcast supports multiple consumers, Sequence is single-consumer * - Signal: Broadcast buffers values, Signal only notifies current waiters * * @template T - The type of values in the broadcast * * @example * ```typescript * const broadcast = new Broadcast<number>(); * * const handle1 = broadcast.join(); * const handle2 = broadcast.join(); * * broadcast.emit(1); * broadcast.emit(2); * * broadcast.consume(handle1); // 1 * broadcast.consume(handle2); // 1 * broadcast.consume(handle1); // 2 * ``` */ export class Broadcast<T> implements Emitter<T, boolean>, Promiseable<T>, Promise<T>, Disposable, AsyncIterable<T> { #buffer = new RingBuffer<T>(); #signal = new Signal<T>(); #disposer: Disposer; #sink?: Fn<[T], boolean>; #nextId = 0; #cursors = new Map<number, number>(); #handles = new WeakMap<ConsumerHandle<T>, number>(); #minCursor = 0; // Stryker disable all: FinalizationRegistry callback only testable via GC, excluded from mutation testing #registry = new FinalizationRegistry<number>((id) => { const cursor = this.#cursors.get(id)!; this.#cursors.delete(id); if (cursor === this.#minCursor) { this.#minCursor = min(this.#cursors.values(), this.#buffer.right); const shift = this.#minCursor - this.#buffer.left; if (shift > 0) this.#buffer.shiftN(shift); } }); // Stryker restore all readonly [Symbol.toStringTag] = 'Broadcast'; constructor() { this.#disposer = new Disposer(this); } /** * Returns a bound emit function for use as a callback. */ get sink(): Fn<[T], boolean> { return (this.#sink ??= this.emit.bind(this)); } /** * DOM EventListener interface compatibility. */ handleEvent(event: T): void { this.emit(event); } /** * The number of active consumers. */ get size(): number { return this.#cursors.size; } /** * Emits a value to all consumers. The value is buffered for consumption. * * @param value - The value to emit. * @returns `true` if the value was emitted. */ emit(value: T): boolean { if (this.#disposer.disposed) { return false; } this.#buffer.push(value); this.#signal.emit(value); return true; } /** * Waits for the next emitted value without joining as a consumer. * Does not buffer - only receives values emitted after calling. * * @returns A promise that resolves with the next emitted value. */ receive(): Promise<T> { return this.#signal.receive(); } then<OK = T, ERR = never>(onfulfilled?: Fn<[T], MaybePromise<OK>> | null, onrejected?: Fn<[unknown], MaybePromise<ERR>> | null): Promise<OK | ERR> { return this.receive().then(onfulfilled, onrejected); } catch<ERR = never>(onrejected?: Fn<[unknown], MaybePromise<ERR>> | null): Promise<T | ERR> { return this.receive().catch(onrejected); } finally(onfinally?: Action | null): Promise<T> { return this.receive().finally(onfinally); } /** * Joins the broadcast as a consumer. Returns a handle used to consume values. * The consumer starts at the current buffer position and will only see * values emitted after joining. * * @example * ```typescript * const handle = broadcast.join(); * // Use handle with consume(), readable(), leave() * ``` */ join(): ConsumerHandle<T> { // Stryker disable next-line UpdateOperator: IDs only need uniqueness, direction is irrelevant const id = this.#nextId++; const cursor = this.#buffer.right; const handle = new ConsumerHandle<T>(this); this.#handles.set(handle, id); this.#cursors.set(id, cursor); // Stryker disable all: minCursor is an optimization hint; tryConsume recalculates on use if (this.#cursors.size === 1 || cursor < this.#minCursor) { this.#minCursor = cursor; } // Stryker restore all this.#registry.register(handle, id, handle); return handle; } /** * Gets the current cursor position for a consumer handle. * * @param handle - The consumer handle. * @returns The cursor position. * @throws If the handle is invalid (already left or never joined). */ getCursor(handle: ConsumerHandle<T>): number { const id = this.#handles.get(handle); // Stryker disable next-line ConditionalExpression: second cursor check catches invalid handles if (id === undefined) throw new Error('Invalid handle'); const cursor = this.#cursors.get(id); if (cursor === undefined) throw new Error('Invalid handle'); return cursor; } /** * Removes a consumer from the broadcast. The handle becomes invalid after this call. * Idempotent - calling multiple times has no effect. * * @param handle - The consumer handle to remove. */ leave(handle: ConsumerHandle<T>): void { const id = this.#handles.get(handle); // Stryker disable next-line ConditionalExpression,EqualityOperator: subsequent ops are safe with undefined id if (id === undefined) return; const cursor = this.#cursors.get(id)!; this.#handles.delete(handle); this.#cursors.delete(id); this.#registry.unregister(handle); // Stryker disable all: compaction condition is optimization; buffer reads are correct regardless if (cursor === this.#minCursor) { this.#minCursor = min(this.#cursors.values(), this.#buffer.right); const shift = this.#minCursor - this.#buffer.left; if (shift > 0) this.#buffer.shiftN(shift); } // Stryker restore all } /** * Consumes and returns the next value for a consumer. * Advances the consumer's cursor position. * * @param handle - The consumer handle. * @throws If no value is available or the handle is invalid. * * @example * ```typescript * if (broadcast.readable(handle)) { * const value = broadcast.consume(handle); * } * ``` */ consume(handle: ConsumerHandle<T>): T { const result = this.tryConsume(handle); if (result.done) { throw new Error('No value available'); } return result.value; } /** * Attempts to consume the next value for a consumer. * Returns `{ done: true }` when no value is currently available. * * @param handle - The consumer handle. * @returns The next value, or `{ done: true }` when nothing is available. * @throws If the handle is invalid. */ tryConsume(handle: ConsumerHandle<T>): IteratorResult<T, void> { const id = this.#handles.get(handle); // Stryker disable next-line ConditionalExpression: second cursor check catches invalid handles if (id === undefined) throw new Error('Invalid handle'); const cursor = this.#cursors.get(id); if (cursor === undefined) throw new Error('Invalid handle'); if (cursor >= this.#buffer.right) { return { value: undefined, done: true }; } const value = this.#buffer.peekAt(cursor)!; this.#cursors.set(id, cursor + 1); // Stryker disable all: compaction condition is optimization; buffer reads are correct regardless if (cursor === this.#minCursor) { this.#minCursor = min(this.#cursors.values(), this.#buffer.right); const shift = this.#minCursor - this.#buffer.left; if (shift > 0) this.#buffer.shiftN(shift); } // Stryker restore all return { value, done: false }; } /** * Checks if there are values available for a consumer to read. * * @param handle - The consumer handle. * @returns `true` if there are unread values, `false` otherwise. */ readable(handle: ConsumerHandle<T>): boolean { return this.getCursor(handle) < this.#buffer.right; } [Symbol.asyncIterator](): AsyncIterator<T, void, void> { return new BroadcastIterator(this, this.#signal, this.join()); } dispose(): void { this[Symbol.dispose](); } [Symbol.dispose](): void { // Stryker disable next-line ConditionalExpression: double-dispose re-clears empty collections, no observable effect if (this.#disposer[Symbol.dispose]()) { this.#signal[Symbol.dispose](); this.#buffer.clear(); this.#cursors.clear(); } } }