regevbr/busywait.js

View on GitHub
src/index.ts

Summary

Maintainability
A
3 hrs
Test Coverage
A
100%
type _CheckFn<T> = (iteration: number, delay: number, totalDelay: number) => T;
export type SyncCheckFn<T> = T extends Promise<any> ? never : _CheckFn<T>;
export type ASyncCheckFn<T> = _CheckFn<Promise<T>>;
export type CheckFn<T> = ASyncCheckFn<T> | SyncCheckFn<T>;

export type Jitter = 'none' | 'full';

export interface IBusyWaitOptions {
    sleepTime: number;
    failMsg?: string;
    maxChecks?: number;
    waitFirst?: boolean;
    multiplier?: number;
    jitter?: Jitter;
    maxDelay?: number;
}

export interface IBusyWaitResult<T> {
    backoff: {
        iterations: number;
        time: number;
    }
    result: T;
}

type Resolve<T> = (result: T) => void;
type Reject = (error: Error) => void;
type Delayer = (iteration: number) => number;

const isFunction = (value: any): value is () => any => {
    const str = Object.prototype.toString.call(value);
    return str === '[object Function]' || str === '[object AsyncFunction]';
}

const isNumber = (value: any): value is number => typeof value === 'number';

const unwrapPromise = <T>() => {
    let resolve: Resolve<T>;
    let reject: Reject;
    const promise = new Promise<T>((_resolve, _reject) => {
        resolve = _resolve;
        reject = _reject;
    });
    // @ts-expect-error
    return {promise, resolve, reject};
}

const wrapSyncMethod = <T>(checkFn: CheckFn<T>): ASyncCheckFn<T> =>
    async (iteration: number, delay: number, totalDelay: number) => checkFn(iteration, delay, totalDelay);

const getAndValidateOptions = <T>(checkFn: CheckFn<T>, _options: IBusyWaitOptions): IBusyWaitOptions => {
    const options = Object.assign({}, _options);
    if (isNumber(options.maxChecks) && (isNaN(options.maxChecks) || options.maxChecks < 1)) {
        throw new Error('maxChecks must be a valid integer greater than 1');
    }
    if (isNaN(options.sleepTime) || options.sleepTime < 1) {
        throw new Error('sleepTime must be a valid integer greater than 1');
    }
    if (isNumber(options.multiplier) &&
        (isNaN(options.multiplier) || options.multiplier < 1)) {
        throw new Error('multiplier must be a valid integer greater than 1');
    }
    if (isNumber(options.maxDelay) &&
        (isNaN(options.maxDelay) || options.maxDelay < 1)) {
        throw new Error('maxDelay must be a valid integer greater than 1');
    }
    if (!checkFn || !isFunction(checkFn)) {
        throw new Error('checkFn must be a function');
    }
    return options;
};

const getDelayer = (options: IBusyWaitOptions): Delayer => (iteration: number) => {
    let delay = options.sleepTime * Math.pow(options.multiplier || 1, iteration);
    delay = Math.min(delay, options.maxDelay || Infinity);
    if (options.jitter === 'full') {
        return Math.round(Math.random() * delay);
    }
    return delay;
}

interface IIterationState<T> {
    iteration: number;
    delayerIteration: number;
    delay: number;
    readonly startTime: number;
    resolve: Resolve<IBusyWaitResult<T>>;
    reject: Reject;
    promise: Promise<IBusyWaitResult<T>>;
    options: IBusyWaitOptions;
    delayer: Delayer;
    checkFn: ASyncCheckFn<T>;
}

const buildIterationState = <T>(checkFn: CheckFn<T>, _options: IBusyWaitOptions): IIterationState<T> => {
    const options = getAndValidateOptions(checkFn, _options);
    const delayer = getDelayer(options);
    const delayerIteration = options.waitFirst ? 0 : -1;
    return {
        iteration: 0,
        delayerIteration,
        startTime: Date.now(),
        delay: options.waitFirst ? delayer(delayerIteration) : 0,
        options,
        delayer,
        checkFn: wrapSyncMethod(checkFn),
        ...unwrapPromise<IBusyWaitResult<T>>(),
    }
}

const iterationCheck = <T>(state: IIterationState<T>) => {
    const iterationRun = async () => {
        state.iteration++;
        state.delayerIteration++;
        try {
            const totalDelay = Date.now() - state.startTime;
            const result = await state.checkFn(state.iteration, state.delay, totalDelay);
            return state.resolve({
                backoff: {
                    iterations: state.iteration,
                    time: totalDelay,
                },
                result,
            });
        } catch (e) {
            if (state.options.maxChecks && state.iteration === state.options.maxChecks) {
                return state.reject(new Error(state.options.failMsg || 'Exceeded number of iterations to wait'));
            }
            state.delay = state.delayer(state.delayerIteration);
            setTimeout(iterationRun, state.delay);
        }
    };
    setTimeout(iterationRun, state.delay);
    return state.promise;
}

export const busywait = async <T>(checkFn: CheckFn<T>, options: IBusyWaitOptions): Promise<IBusyWaitResult<T>> => {
    const iterationState = buildIterationState(checkFn, options)
    return iterationCheck(iterationState);
};