UNPKG

@pexip/signal

Version:

an observer pattern while avoiding boilerplate code. https://en.wikipedia.org/wiki/Signals_and_slots

240 lines (239 loc) 8.72 kB
/** * An implementation of {@link https://en.wikipedia.org/wiki/Signals_and_slots | Signals and slots}. * * @packageDocumentation */ import { logger, flags } from './logger'; import { createBuffer } from './buffer'; const NAMELESS_OBSERVER = 'Anonymous'; const NAMELESS_SIGNAL = 'AnonymousSignal'; const DEFAULT_BUFFER_SIZE = 2; function getName(observer) { return observer.name || NAMELESS_OBSERVER; } export function createSignal(options = { variant: 'generic' }) { const { allowEmittingWithoutObserver = false, variant = 'generic', name = NAMELESS_SIGNAL, } = options ?? {}; // Slots const observers = new Set(); const contexts = new WeakMap(); const once = new WeakSet(); // Ensure that we don't run the `batchTask` more than necessary let locked = false; let batching = false; let buffers = undefined; switch (options?.variant) { case 'batched': case 'replay': { if (options.bufferSize !== undefined && options.bufferSize < 1) { throw new Error('Batched/Replay Signal bufferSize must be greater than 0'); } if (options.bufferSize === 1) { const meta = { bufferSize: options.bufferSize, name, variant, }; switch (options.variant) { case 'batched': if (options.emitImmediatelyWhenFull) { logger.warn(meta, 'Batched signal with buffer size of 1 is basically a Generic signal'); } break; case 'replay': logger.warn(meta, 'Replay signal with bufferSize of 1 is identical to Behavior signal'); break; } } buffers = createBuffer(options.bufferSize ?? DEFAULT_BUFFER_SIZE); break; } case 'behavior': { buffers = createBuffer(1); break; } } if (options?.variant === 'batched' && options.schedule === undefined) { throw new Error('Batched signal requires a scheduler to be provided in the options'); } function emitOne(observer, subject) { try { const context = contexts.get(observer); logger.trace({ signal: name, variant, subject, observer, context }, `Emitting a subject to observer ${getName(observer)}`); // biome-ignore lint/suspicious/noExplicitAny: Needs any[] to accept optional args observer.call(context, subject); if (once.has(observer)) { remove(observer); } } catch (e) { logger.error({ signal: name, variant, error: e, observer, subject }, `emit with error for observer ${getName(observer)}`); if (e instanceof RangeError) { throw new RangeError(`RangeError: Possible recursive call when calling ${getName(observer)} for ${name}`); } throw e; } } const add = (observer, options) => { if (observers.has(observer)) { const msg = `Observer ${getName(observer)} has already been added!`; logger.error({ signal: name, variant, observer }, msg); throw new Error(`DuplicatedObserver: ${msg}`); } logger.trace({ signal: name, variant, observer }, `Adding ${getName(observer)} to ${name}`); observers.add(observer); if (options?.context) { contexts.set(observer, options.context); } switch (variant) { case 'replay': case 'behavior': { for (const subject of buffers ?? []) { emitOne(observer, subject); } break; } default: break; } if (options?.signal) { const detach = () => { remove(observer); options.signal?.removeEventListener('abort', detach); }; options.signal.addEventListener('abort', detach); return detach; } return () => remove(observer); }; const addOnce = (observer, options) => { if (once.has(observer)) { const msg = `${getName(observer)} has already been added once to ${name}!`; logger.error({ signal: name, variant, observer }, msg); throw new Error(`NoOnceAgain: ${msg}`); } once.add(observer); return add(observer, options); }; const remove = (observer) => { if (!observers.delete(observer)) { logger.error({ signal: name, variant, observer }, `Unable to remove observer ${getName(observer)}`); throw new Error(`UnableToRemove: ${getName(observer)}`); } once.delete(observer); contexts.delete(observer); logger.trace({ signal: name, variant, observer }, `Removed ${getName(observer)} from ${name}`); }; const size = () => observers.size; const batchTask = () => { if (!batching || !buffers || buffers.size < 1) { return; } const result = []; for (const value of buffers) { if (value !== undefined) { result.push(value); } } logger.trace({ signal: name, variant, observers: observers.size, locked, bufferSize: buffers?.size, values: result, batching, options, }, `Execute batched tasks from ${name}`); buffers.clear(); for (const observer of observers) { emitOne(observer, result); } locked = false; batching = false; }; function emit(subject) { // Buffer the subject if (buffers) { buffers.add(subject); } else if (!observers.size && !allowEmittingWithoutObserver) { const { stack } = flags.debug ? new Error() : { stack: undefined }; logger.warn({ signal: name, variant, subject, stack }, `Emitting ${name} without any observer! This may be a mistake.`); } switch (options.variant) { case 'batched': { if (options.emitImmediatelyWhenFull && buffers?.size === options.bufferSize) { logger.trace({ signal: name, variant, observers: observers.size, locked, bufferSize: buffers?.size, subject, batching, options, }, `Buffer is full, execute batched tasks from ${name}`); batching = true; batchTask(); } else if (!locked && buffers?.size === 1) { logger.trace({ signal: name, variant, observers: observers.size, locked, bufferSize: buffers?.size, subject, batching, options, }, `Schedule batched tasks from ${name}`); locked = true; batching = true; options.schedule(batchTask); } else { logger.trace({ signal: name, variant, observers: observers.size, locked, bufferSize: buffers?.size, subject, batching, options, }, `Batch tasks from ${name}`); } break; } default: { for (const observer of observers) { emitOne(observer, subject); } break; } } } return { name, get size() { return size(); }, clearBuffers() { buffers?.clear(); logger.trace({ signal: name, variant, observers: observers.size, locked, bufferSize: buffers?.size, batching, options, }, `Clear Buffers from ${name}`); }, add, addOnce, remove, emit, }; }