async-function-serializer
Version:
A helper which produces serial async functions without losing return values
233 lines (196 loc) • 5.55 kB
text/typescript
type Queue<Input, Return> = {
resolve: (result: Return) => void;
input: Input;
}[];
export type SerializeOptions<Input, Return> = {
/**
* Maximum number of simultaneous executions
* @defaultValue 1
*/
concurrency?: number;
/**
* How long to delay before starting initial execution
* @defaultValue 0
*/
delay?: number;
/**
* Used to sort the queue at the beginning of each execution cycle
* @defaultValue undefined
*/
sortBy?: {
/**
* Key of input to sort. Will only populate if input is of type object, and only supports top-level keys.
*/
key: keyof Input;
/**
* Sort direction.
* @defaultValue 'asc'
*/
direction?: 'asc' | 'desc';
};
/**
* Batch input when adding them to the queue.
* @defaultValue undefined
*/
batch?: {
/**
* How to long wait before adding the batch to the queue
*/
debounceInterval: number;
/**
* Maximum duration before a batch will close
*/
maxDebounceInterval?: number;
/**
* Function to combine the new queue item into the current batch
* @param existingBatch - the current batch
* @param newInput - the new item being added into the batch
*/
batchTransformer: (
existingBatch: Input | undefined,
newInput: Input,
) => Input;
};
/**
* Function to transform the input at the beginning of each execution cycle
* @param input - the current value being transformed
* @param previousResult - the results from the previous execution, if any
*/
inputTransformer?: (
input: Input,
previousResult: Awaited<Return> | undefined,
) => Input | Promise<Input>;
};
type InputOptions = {
/**
* This will force-start a new batch. If another batch is in-progress, it will be immediately added to queue.
* */
startNewBatch?: boolean;
};
type Result<R> = {
data?: R;
message?: unknown;
};
export default function serialize<
Input,
Return,
EnrichedReturn extends Result<Return>,
>(
func: (input: Input) => Return,
options?: SerializeOptions<Input, Return>,
): (input: Input, inputOptions?: InputOptions) => Promise<EnrichedReturn> {
let queue: Queue<Input, EnrichedReturn> = [];
let previousResult: Awaited<Return> | undefined = undefined;
let batchTimer: ReturnType<typeof setTimeout> | undefined = undefined;
let batchTimerFunc: () => void = () => {};
let batchStartTimestamp: number | undefined = undefined;
let inProgressBatch: Input | undefined = undefined;
let previousPromise: ((result: EnrichedReturn) => void) | undefined =
undefined;
const maxExecutions = options?.concurrency || 1;
let currentExecutions = 0;
let delayPromise: Promise<undefined> | undefined = undefined;
async function start() {
if (currentExecutions >= maxExecutions) return;
const mightDelay = currentExecutions === 0;
currentExecutions += 1;
if (mightDelay && options?.delay) {
delayPromise = new Promise(resolve =>
setTimeout(resolve, options.delay),
);
}
await delayPromise;
run();
}
async function run() {
if (options?.sortBy) {
const key = options.sortBy.key;
const direction = options.sortBy.direction || 'asc';
queue = queue.sort((a, b) => {
const valueOne =
direction === 'asc' ? a.input[key] : b.input[key];
const valueTwo =
direction === 'asc' ? b.input[key] : a.input[key];
if (
typeof valueOne === 'number' &&
typeof valueTwo === 'number'
) {
return valueOne - valueTwo;
} else if (
typeof valueOne === 'string' &&
typeof valueTwo === 'string'
) {
return valueOne.localeCompare(valueTwo);
} else {
return 0;
}
});
}
const current = queue.shift();
if (current) {
if (options?.inputTransformer) {
current.input = await options.inputTransformer(
current.input,
previousResult,
);
}
try {
const result = await func(current.input);
current.resolve({ data: result as Return } as EnrichedReturn);
previousResult = result;
} catch (error) {
current.resolve({ message: error } as EnrichedReturn);
}
}
if (queue.length) await run();
else currentExecutions -= 1;
}
return async function (
input: Input,
inputOptions?: InputOptions,
): Promise<EnrichedReturn> {
return await new Promise<EnrichedReturn>(resolve => {
if (inputOptions?.startNewBatch) {
if (batchTimer) clearTimeout(batchTimer);
batchTimerFunc();
}
if (!options?.batch) {
queue.push({ resolve, input });
start();
} else {
const {
debounceInterval,
batchTransformer,
maxDebounceInterval,
} = options.batch;
if (!batchStartTimestamp)
batchStartTimestamp = new Date().valueOf();
const elapsedTime = maxDebounceInterval
? Math.max(new Date().valueOf() - batchStartTimestamp, 0)
: 0;
const availableTime = maxDebounceInterval
? Math.max(maxDebounceInterval - elapsedTime, 0)
: Infinity;
const thisDebounceInterval = Math.min(
debounceInterval,
availableTime,
);
if (batchTimer) clearTimeout(batchTimer);
inProgressBatch = batchTransformer(inProgressBatch, input);
if (previousPromise) {
previousPromise({ message: 'batched' } as EnrichedReturn);
}
previousPromise = resolve;
batchTimerFunc = () => {
queue.push({ resolve, input: inProgressBatch || input });
inProgressBatch = undefined;
previousPromise = undefined;
batchStartTimestamp = undefined;
batchTimerFunc = () => {};
start();
};
batchTimer = setTimeout(batchTimerFunc, thisDebounceInterval);
}
});
};
}