contexify
Version:
A TypeScript library providing a powerful dependency injection container with context-based IoC capabilities, inspired by LoopBack's Context system.
476 lines (399 loc) • 11.2 kB
text/typescript
/**
* This module contains utilities to convert events to promises and async iterators.
* It's a simplified version of the p-event package (https://github.com/sindresorhus/p-event)
* adapted for our specific needs.
*/
/**
* Error thrown when a promise times out
*/
export class TimeoutError extends Error {
constructor(message: string) {
super(message);
this.name = 'TimeoutError';
}
}
/**
* Normalize an event emitter to have consistent addListener and removeListener methods
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const normalizeEmitter = (emitter: any) => {
const addListener =
emitter.addEventListener || emitter.on || emitter.addListener;
const removeListener =
emitter.removeEventListener || emitter.off || emitter.removeListener;
if (!addListener || !removeListener) {
throw new TypeError('Emitter is not compatible');
}
return {
addListener: addListener.bind(emitter),
removeListener: removeListener.bind(emitter),
};
};
/**
* Options for pEventMultiple
*/
export interface MultipleOptions<T = unknown> {
/**
* Events that will reject the promise.
* @default ['error']
*/
rejectionEvents?: string[];
/**
* Whether to return all arguments from the event callback.
* @default false
*/
multiArgs?: boolean;
/**
* Whether to resolve the promise immediately.
* @default false
*/
resolveImmediately?: boolean;
/**
* The number of times the event needs to be emitted before the promise resolves.
*/
count: number;
/**
* The time in milliseconds before timing out.
* @default Infinity
*/
timeout?: number;
/**
* A filter function for accepting an event.
*/
filter?: (value: T) => boolean;
/**
* An AbortSignal to abort waiting for the event.
*/
signal?: AbortSignal;
}
/**
* Options for pEvent
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface Options<T = unknown>
extends Partial<Omit<MultipleOptions<T>, 'count'>> {}
/**
* A promise with a cancel method
*/
export interface CancelablePromise<T> extends Promise<T> {
cancel: () => void;
}
/**
* Wait for multiple event emissions
*/
export function pEventMultiple<T = unknown>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emitter: any,
event: string | symbol | (string | symbol)[],
options: MultipleOptions<T>
): CancelablePromise<T[]> {
let cancel: () => void;
const returnValue = new Promise<T[]>((resolve, reject) => {
options = {
rejectionEvents: ['error'],
multiArgs: false,
resolveImmediately: false,
...options,
};
if (
!(
options.count >= 0 &&
(options.count === Number.POSITIVE_INFINITY ||
Number.isInteger(options.count))
)
) {
throw new TypeError('The `count` option should be at least 0 or more');
}
options.signal?.throwIfAborted();
// Allow multiple events
const events = Array.isArray(event) ? event : [event];
const items: T[] = [];
const { addListener, removeListener } = normalizeEmitter(emitter);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onItem = (...arguments_: any[]) => {
const value = options.multiArgs
? (arguments_ as unknown as T)
: (arguments_[0] as T);
if (options.filter && !options.filter(value)) {
return;
}
items.push(value);
if (options.count === items.length) {
cancel();
resolve(items);
}
};
const rejectHandler = (error: Error) => {
cancel();
reject(error);
};
cancel = () => {
for (const event of events) {
removeListener(event, onItem);
}
for (const rejectionEvent of options.rejectionEvents || []) {
removeListener(rejectionEvent, rejectHandler);
}
};
for (const event of events) {
addListener(event, onItem);
}
for (const rejectionEvent of options.rejectionEvents || []) {
addListener(rejectionEvent, rejectHandler);
}
if (options.signal) {
options.signal.addEventListener(
'abort',
() => {
rejectHandler(options.signal!.reason as Error);
},
{ once: true }
);
}
if (options.resolveImmediately) {
resolve(items);
}
}) as CancelablePromise<T[]>;
returnValue.cancel = cancel!;
if (typeof options.timeout === 'number') {
const timeoutPromise = new Promise<T[]>((_, reject) => {
const timer = setTimeout(() => {
returnValue.cancel();
reject(
new TimeoutError(
`Promise timed out after ${options.timeout} milliseconds`
)
);
}, options.timeout);
// Clear the timeout when the promise is fulfilled
returnValue.then(
() => clearTimeout(timer),
() => clearTimeout(timer)
);
});
const combinedPromise = Promise.race([
returnValue,
timeoutPromise,
]) as CancelablePromise<T[]>;
combinedPromise.cancel = cancel!;
return combinedPromise;
}
return returnValue;
}
/**
* Wait for a single event emission
*/
export function pEvent<T = unknown>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emitter: any,
event: string | symbol | (string | symbol)[],
options?: Options<T> | ((value: T) => boolean)
): CancelablePromise<T> {
if (typeof options === 'function') {
options = { filter: options };
}
const multipleOptions: MultipleOptions<T> = {
...(options as Options<T>),
count: 1,
resolveImmediately: false,
};
const arrayPromise = pEventMultiple<T>(emitter, event, multipleOptions);
const promise = arrayPromise.then(
(array) => array[0]
) as CancelablePromise<T>;
promise.cancel = arrayPromise.cancel;
return promise;
}
/**
* Options for pEventIterator
*/
export interface IteratorOptions<T = unknown>
extends Omit<Options<T>, 'timeout'> {
/**
* Events that will end the iterator.
* @default []
*/
resolutionEvents?: string[];
/**
* The maximum number of events for the iterator before it ends.
* @default Infinity
*/
limit?: number;
}
/**
* Create an async iterator for events
*/
export function pEventIterator<T = unknown>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emitter: any,
event: string | symbol | (string | symbol)[],
options?: IteratorOptions<T> | ((value: T) => boolean)
): AsyncIterableIterator<T> {
if (typeof options === 'function') {
options = { filter: options };
}
// Allow multiple events
const events = Array.isArray(event) ? event : [event];
options = {
rejectionEvents: ['error'],
resolutionEvents: [],
limit: Number.POSITIVE_INFINITY,
multiArgs: false,
...options,
};
const { limit } = options;
const isValidLimit =
limit! >= 0 &&
(limit === Number.POSITIVE_INFINITY || Number.isInteger(limit));
if (!isValidLimit) {
throw new TypeError(
'The `limit` option should be a non-negative integer or Infinity'
);
}
options.signal?.throwIfAborted();
if (limit === 0) {
// Return an empty async iterator to avoid any further cost
return {
[Symbol.asyncIterator]() {
return this;
},
async next() {
return {
done: true,
value: undefined,
};
},
};
}
const { addListener, removeListener } = normalizeEmitter(emitter);
let isDone = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let error: any;
let hasPendingError = false;
const nextQueue: Array<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolve: (value: any) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
reject: (error: any) => void;
}> = [];
const valueQueue: T[] = [];
let eventCount = 0;
let isLimitReached = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const valueHandler = (...arguments_: any[]) => {
eventCount++;
isLimitReached = eventCount === limit;
const value = options!.multiArgs
? (arguments_ as unknown as T)
: (arguments_[0] as T);
if (nextQueue.length > 0) {
const { resolve } = nextQueue.shift()!;
resolve({ done: false, value });
if (isLimitReached) {
cancel();
}
return;
}
valueQueue.push(value);
if (isLimitReached) {
cancel();
}
};
const cancel = () => {
isDone = true;
for (const event of events) {
removeListener(event, valueHandler);
}
for (const rejectionEvent of options!.rejectionEvents!) {
removeListener(rejectionEvent, rejectHandler);
}
for (const resolutionEvent of options!.resolutionEvents!) {
removeListener(resolutionEvent, resolveHandler);
}
while (nextQueue.length > 0) {
const { resolve } = nextQueue.shift()!;
resolve({ done: true, value: undefined });
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rejectHandler = (...arguments_: any[]) => {
error = options!.multiArgs ? arguments_ : arguments_[0];
if (nextQueue.length > 0) {
const { reject } = nextQueue.shift()!;
reject(error);
} else {
hasPendingError = true;
}
cancel();
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolveHandler = (...arguments_: any[]) => {
const value = options!.multiArgs
? (arguments_ as unknown as T)
: (arguments_[0] as T);
if (options!.filter && !options!.filter(value)) {
cancel();
return;
}
if (nextQueue.length > 0) {
const { resolve } = nextQueue.shift()!;
resolve({ done: true, value });
} else {
valueQueue.push(value);
}
cancel();
};
for (const event of events) {
addListener(event, valueHandler);
}
for (const rejectionEvent of options!.rejectionEvents!) {
addListener(rejectionEvent, rejectHandler);
}
for (const resolutionEvent of options!.resolutionEvents!) {
addListener(resolutionEvent, resolveHandler);
}
if (options!.signal) {
options!.signal.addEventListener(
'abort',
() => {
rejectHandler(options!.signal!.reason);
},
{ once: true }
);
}
return {
[Symbol.asyncIterator]() {
return this;
},
async next() {
if (valueQueue.length > 0) {
const value = valueQueue.shift()!;
return {
done: isDone && valueQueue.length === 0 && !isLimitReached,
value,
};
}
if (hasPendingError) {
hasPendingError = false;
throw error;
}
if (isDone) {
return {
done: true,
value: undefined,
};
}
return new Promise((resolve, reject) => {
nextQueue.push({ resolve, reject });
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async return(value?: any) {
cancel();
return {
done: isDone,
value,
};
},
};
}