@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
JavaScript
/**
* 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,
};
}