already
Version:
Promise extensions; map, filter, etc
718 lines (717 loc) • 22.6 kB
JavaScript
export default {
defer,
deferSet,
delay,
delayChain,
each,
filter,
flatMap,
funnel,
inspect,
map,
once,
props,
reduce,
rethrow,
retry,
some,
specific,
tap,
wrapFunction,
};
function toReadonlyArray(arr) {
/* istanbul ignore else */
if (typeof arr.map === "function")
return arr;
else
return Array.from(arr);
}
export function concurrent(size, fn) {
const queue = makeQueue(size);
if (size < 1)
throw new RangeError(`Size must be at least 1`);
if (!fn)
return (cb, ...args) => queue.enqueue(() => cb(...args));
else
return (...args) => queue.enqueue(() => fn(...args));
}
function makeQueue(size) {
const queue = {
size,
count: 0,
queue: [],
process: () => {
if (queue.queue.length) {
const first = queue.queue.shift();
const { cb, deferred } = first;
queue.runOne(cb).then(deferred.resolve, deferred.reject);
}
},
runOne: (cb) => {
++queue.count;
return (async () => cb())()
.finally(() => {
--queue.count;
queue.process();
});
},
enqueue: async (cb) => {
if (queue.count >= queue.size) {
const deferred = defer();
queue.queue.push({ cb, deferred });
return deferred.promise;
}
return queue.runOne(cb);
}
};
return queue;
}
export function delay(milliseconds, t) {
return new Promise(resolve => {
setTimeout(() => resolve(t), milliseconds);
});
}
export function delayChain(milliseconds) {
return tap(() => delay(milliseconds));
}
export function tap(fn) {
return async (t) => {
await fn(t);
return t;
};
}
export function props(obj) {
const ret = {};
const awaiters = [];
for (const prop of Object.keys(obj))
awaiters.push(Promise.resolve(obj[prop])
.then(val => { ret[prop] = val; }));
return Promise.all(awaiters).then(() => ret);
}
const defaultFilterMapOptions = {
concurrency: Infinity
};
export function filter(arr, opts, filterFn) {
if (Array.isArray(arr)) {
if (typeof opts === "function") {
filterFn = opts;
opts = defaultFilterMapOptions;
}
const intermediate = filter(opts, filterFn);
return intermediate(arr);
}
filterFn = typeof arr === "function" ? arr : opts;
opts =
typeof arr === "function"
? defaultFilterMapOptions
: arr;
const wrappedFilterFn = (val, index, arr) => Promise.resolve(filterFn(val, index, arr))
.then(ok => ({ ok, val }));
return (t) => {
return map(opts, wrappedFilterFn)(t)
.then(values => values
.filter(({ ok }) => ok)
.map(({ val }) => val));
};
}
export function map(arr, opts, mapFn) {
if (Array.isArray(arr)) {
if (typeof opts === "function") {
mapFn = opts;
opts = defaultFilterMapOptions;
}
return map(opts, mapFn)(arr);
}
mapFn = typeof arr === "function" ? arr : opts;
opts =
typeof arr === "function"
? defaultFilterMapOptions
: arr;
const promiseMapFn = (t, index, arr) => Promise.resolve(mapFn(t, index, arr));
const { concurrency = Infinity } = opts;
const { chunk } = opts;
if (typeof chunk !== 'undefined') {
if (typeof chunk !== 'number' && chunk !== 'idle')
throw new Error(`Invalid 'chunk' option to 'map': ${chunk}`);
const useIdle = chunk === 'idle';
const timeout = chunk === 'idle'
? 15 // 15ms, allow 1.666ms for browser work (to fill up 16.666 ms)
: chunk;
const looper = async (cb) => new Promise((resolve, reject) => {
const resolver = async (timeout) => {
try {
await cb(timeout);
resolve();
}
catch (err) {
reject(err);
}
};
(useIdle && typeof requestIdleCallback !== 'undefined')
? requestIdleCallback(idleDeadline => resolver(
// Round down to millisecond, subtract 1.
// That gives a little bit of time for the browser
// to do whatever it wants, and will to some
// degree prevent us to step over the time budget.
Math.floor(idleDeadline.timeRemaining()) - 1), { timeout: 0 })
: setTimeout(() => resolver(timeout), 0);
});
return (t) => {
return Promise.resolve(t)
.then(async (values) => {
const arr = toReadonlyArray(values);
const ret = [];
let i = 0;
const loop = async (timeout) => {
const start = Date.now();
for (; i < arr.length;) {
const u = await promiseMapFn(await arr[i], i, arr);
ret.push(u);
++i;
if (Date.now() - start >= timeout) {
await looper(loop);
break;
}
}
};
await loop(timeout);
return ret;
})
.then(values => Promise.all(values));
};
}
const concurrently = concurrent(concurrency);
return (t) => {
return Promise.resolve(t)
.then((values) => toReadonlyArray(values)
.map((val, index, arr) => (() => Promise.resolve(val))()
.then((val) => concurrently(promiseMapFn, val, index, arr))))
.then(values => Promise.all(values));
};
}
export function flatMap(arr, opts, mapFn) {
if (Array.isArray(arr)) {
return map(arr, opts, mapFn)
.then(arr => Promise.all(arr
.map(inner => Array.isArray(inner) ? inner : [inner])
.flat(1)));
}
return async (t) => {
return flatMap(t, arr, opts);
};
}
export function reduce(input, reducer, initialValue) {
if (typeof input === "function") {
initialValue = reducer;
const _reducer = input;
return async (input) => {
return reduceImpl(input, _reducer, initialValue);
};
}
return reduceImpl(input, reducer, initialValue);
}
async function reduceImpl(input, reducer, initialValue) {
const _input = Array.from(await input);
const _initialValue = await initialValue;
if (_input.length === 0)
return _initialValue;
const usingInitialValue = typeof _initialValue !== "undefined";
const length = _input.length;
let index = usingInitialValue ? 0 : 1;
let accumulator = usingInitialValue
? _initialValue
// This cast should be safe if the interface is respected
: await _input.shift();
while (_input.length > 0)
accumulator = await reducer(accumulator, await _input.shift(), index++, length);
return accumulator;
}
const defaultEachOptions = {
concurrency: 1
};
export function each(arr, opts, eachFn) {
if (Array.isArray(arr)) {
if (typeof opts === "function") {
eachFn = opts;
opts = defaultEachOptions;
}
const _opts = opts;
return eachImpl(_opts, eachFn)(arr);
}
if (typeof arr === "function") {
eachFn = arr;
opts = defaultEachOptions;
}
else {
eachFn = opts;
opts = arr;
}
const _opts = opts;
return eachImpl(_opts, eachFn);
}
function eachImpl(opts, eachFn) {
return async (arr) => {
const length = arr.length;
async function iterator(t, index) {
await eachFn(t, index, length);
return t;
}
return map(arr, opts, iterator);
};
}
export function some(list, fn) {
if (typeof list === "function") {
fn = list;
return (list) => someImpl(list, fn);
}
return someImpl(list, fn);
}
async function someImpl(list, fn) {
const _list = toReadonlyArray(await list);
for (const val of _list) {
const ret = await fn(await val);
if (ret)
return ret;
}
return false;
}
export function once(fn) {
if (fn) {
const _once = onceDynamic();
return ((t) => _once(fn, t));
}
else
return onceDynamic();
}
function onceDynamic() {
const state = new WeakMap();
const ensureState = (fn) => {
if (!state.has(fn))
state.set(fn, { hasRun: false });
};
return ((fn, t) => {
ensureState(fn);
const stateObject = state.get(fn);
if (stateObject.hasRun) {
if (stateObject.deferred)
return stateObject.deferred.promise;
return stateObject.returnValue;
}
stateObject.hasRun = true;
const ret = fn(t);
const pret = ret;
if (pret !== undefined && pret && typeof pret.then === "function") {
stateObject.deferred = defer(void 0);
return pret
.then(stateObject.deferred.resolve, rethrow(stateObject.deferred.reject))
.then(() => stateObject.deferred.promise);
}
stateObject.returnValue = ret;
return ret;
});
}
export function retry(times, fn, retryable = () => true) {
const retryAsync = (promise) => promise
.catch((err) => {
if (--times < 0 || !retryable(err))
throw err;
return retryAsync(fn());
});
const retrySync = (_err) => {
while (--times >= 0) {
try {
return fn();
}
catch (err) {
if (!retryable(err))
throw err;
_err = err;
}
}
throw _err;
};
try {
const ret = fn();
if (ret &&
typeof ret === "object" &&
typeof ret.then === "function") {
return retryAsync(ret);
}
return ret;
}
catch (err) {
if (!retryable(err))
throw err;
return retrySync(err);
}
}
export function defer() {
var _a;
const deferred = {};
deferred.promise = new Promise((resolve, reject) => {
deferred.resolve = resolve;
deferred.reject = reject;
});
/* istanbul ignore next */
if (typeof process !== 'undefined' &&
((_a = process === null || process === void 0 ? void 0 : process.env) === null || _a === void 0 ? void 0 : _a.JEST_WORKER_ID) !== undefined)
try {
// Jest has decided for many versions to break async catching,
// so this is needed for unit tests not to break unnecessarily.
deferred.promise.catch(() => { });
}
catch (_err) { }
return deferred;
}
export function reflect(promise) {
const inspection = inspect(promise);
function handleResolution(value) {
return {
isRejected: false,
isResolved: true,
value,
};
}
function handleRejection(error) {
return {
error,
isRejected: true,
isResolved: false,
};
}
return inspection.promise
.then(handleResolution, handleRejection);
}
export function inspect(promise) {
const inspectable = {
isPending: true,
isRejected: false,
isResolved: false,
promise: void 0,
};
inspectable.promise = promise.then(value => {
inspectable.isResolved = true;
inspectable.isPending = false;
return value;
})
.catch(err => {
inspectable.isRejected = true;
inspectable.isPending = false;
return Promise.reject(err);
});
return inspectable;
}
export function deferInspectable() {
const deferred = defer();
const ret = {
isPending: true,
isRejected: false,
isResolved: false,
promise: deferred.promise,
resolve(t) {
if (!ret.isPending)
return;
deferred.resolve(t);
ret.isPending = false;
ret.isRejected = false;
ret.isResolved = true;
},
reject(err) {
if (!ret.isPending)
return;
deferred.reject(err);
ret.isPending = false;
ret.isRejected = true;
ret.isResolved = false;
},
};
return ret;
}
// This logic is taken from Bluebird
function catchFilter(filters, err) {
return (Array.isArray(filters) ? filters : [filters])
.some((filter) => {
if (filter == null)
return false;
if (filter === Error ||
filter.prototype instanceof Error) {
if (err instanceof filter)
return true;
}
else if (typeof filter === "function") {
const filterFn = filter;
// It is "ok" for this to throw. It'll be thrown back to the catch
// handler, and the promise chain will contain this error.
return filterFn(err);
}
else if (typeof filter === "object") {
const obj = filter;
for (const key of Object.keys(obj))
if (obj[key] !== err[key])
return false;
return true;
}
return false;
});
}
export function specific(filters, handler) {
return (err) => {
if (!catchFilter(filters, err))
throw err;
return handler(err);
};
}
export function rethrow(fn) {
return async (err) => {
await fn(err);
throw err;
};
}
/**
* Timeout a promise with a certain number of milliseconds.
*
* Returns a promise (that will never be rejected) being resolved within _at
* least_ the timeout duration. The returned value contains the promise
* value or error, unless timeout was reached.
*
* @param promise The promise to await for at most <timeout> ms
* @param timeout Milliseconds to wait at most before timing out
* @returns A promise to a {@link TimeoutValue} object
*/
export async function timeout(promise, timeout) {
const sentry = Symbol();
return Promise.race([reflect(promise), delay(timeout, sentry)])
.then((value) => {
if (value === sentry) {
// Timed out
return { timedout: true, promise, reflection: undefined };
}
else {
// Did not time out, the value is the resolved reflection
return { timedout: false, promise, reflection: value };
}
});
}
export function wrapFunction(wrap) {
// tslint:disable-next-line
return function (t, cb) {
if (arguments.length === 1) {
if (wrap.length > 0)
throw new EvalError("Invalid invocation, function requires 2 arguments");
cb = t;
t = void 0;
}
const anyCleanup = wrap(t);
const callCleanup = (cleanup) => {
if (typeof cleanup === "function")
return cleanup();
else if (cleanup != null)
// Allow 'before' to just return null/undefined, but non-empty
// value would've been silently ignored.
throw new EvalError("Invalid return value in 'before' handler");
};
if (anyCleanup &&
typeof anyCleanup.then === "function") {
let doCleanup;
return anyCleanup
.then(async (cleanup) => {
doCleanup = () => callCleanup(cleanup);
return cb();
})
.finally(() => {
if (doCleanup)
return doCleanup();
});
}
else {
const cleanup = anyCleanup;
let cbRet;
try {
cbRet = cb();
}
catch (err) {
const cleanupRet = callCleanup(cleanup);
if (cleanupRet &&
typeof cleanupRet.then === "function") {
return cleanupRet
.then(() => { throw err; });
}
else {
throw err;
}
}
if (cbRet &&
typeof cbRet.then
=== "function") {
return cbRet
.finally(() => callCleanup(cleanup));
}
else {
const cleanupRet = callCleanup(cleanup);
if (cleanupRet &&
typeof cleanupRet.then === "function") {
return cleanupRet
.then(() => cbRet);
}
else {
return cbRet;
}
}
}
};
}
export function funnel(opts = {}) {
const { onEmpty, concurrency = 1 } = (opts || {});
let FunnelState;
(function (FunnelState) {
FunnelState[FunnelState["DEFAULT"] = 0] = "DEFAULT";
FunnelState[FunnelState["SHOULD_RETRY"] = 1] = "SHOULD_RETRY";
FunnelState[FunnelState["WAITING"] = 2] = "WAITING";
FunnelState[FunnelState["COMPLETED"] = 3] = "COMPLETED";
})(FunnelState || (FunnelState = {}));
/**
* All ongoing tasks (functions) regardless of state they are in.
* If they return/throw or shortcut, they get cleared from this map.
* The order is preserved for fifo fairness.
*/
const tasks = new Map();
const countWaiting = () => {
return [...tasks.values()]
.filter(({ state }) => state === FunnelState.WAITING)
.length;
};
const countWorking = () => {
return [...tasks.values()]
.filter(({ state }) => state === FunnelState.SHOULD_RETRY)
.length;
};
const freeSlots = () => {
return Math.max(0, concurrency - countWorking());
};
const triggerWaiting = () => {
const amountToResume = freeSlots();
[...tasks.values()]
.filter(({ state }) => state === FunnelState.WAITING)
.slice(0, amountToResume)
.forEach(task => {
task.resume();
});
};
return (fn) => {
const sentry = {};
const store = {
state: FunnelState.DEFAULT,
counted: false,
resume: undefined,
};
tasks.set(sentry, store);
const shouldRetry = () => {
if (store.state === FunnelState.COMPLETED)
// shortcut before should/retry shortcuts through
return false;
const free = freeSlots();
const shouldContinue = free > 0;
if (store.state !== FunnelState.DEFAULT)
throw new Error("Invalid use of 'shouldRetry'");
store.state = FunnelState.SHOULD_RETRY;
store.counted = true;
return !shouldContinue;
};
const retry = () => {
if (store.state !== FunnelState.SHOULD_RETRY)
throw new Error("Invalid use of 'retry', " +
"must only be called after 'shouldRetry'");
store.state = FunnelState.WAITING;
const deferred = defer();
const resume = () => {
store.state = FunnelState.DEFAULT;
store.resume = undefined;
deferred.resolve(runner());
};
store.resume = resume;
return deferred.promise;
};
const shortcut = () => {
if (store.state === FunnelState.COMPLETED)
return;
store.state = FunnelState.COMPLETED;
tasks.delete(sentry);
if (countWaiting() === 0)
onEmpty === null || onEmpty === void 0 ? void 0 : onEmpty();
else
triggerWaiting();
};
const runner = () => {
return (async () => fn(shouldRetry, retry, shortcut))()
.finally(shortcut);
};
return runner();
};
}
export class OrderedAsynchrony {
constructor() {
this.deferrals = [];
}
wait(waitForIndex, resolveIndex, rejectIndex) {
this.ensureDeferral([
...([].concat(waitForIndex)),
...(resolveIndex == null ? [] :
[].concat(resolveIndex)),
...(rejectIndex == null ? [] :
[].concat(rejectIndex)),
]);
return this.decorate(Promise.all([].concat(waitForIndex)
.map(index => this.deferrals[index].promise))
.then(() => Promise.all([
resolveIndex == null
? void 0
: this.resolve(resolveIndex),
rejectIndex == null
? void 0
: this.reject(rejectIndex),
])
.then(() => { })));
}
resolve(index) {
this.ensureDeferral(index);
return this.decorate(delay(0).then(() => {
[].concat(index)
.forEach(index => {
this.deferrals[index].resolve();
});
}));
}
reject(index, error = new Error("OrderedAsynchrony rejection")) {
this.ensureDeferral(index);
return this.decorate(delay(0).then(() => {
[].concat(index)
.forEach(index => {
this.deferrals[index].reject(error);
});
}));
}
ensureDeferral(index) {
const indices = []
.concat(index)
.sort((a, b) => b - a);
const highest = indices[0];
for (let i = this.deferrals.length; i <= highest; ++i)
this.deferrals.push(defer(void 0));
return this;
}
decorate(promise) {
// tslint:disable-next-line:variable-name
const This = {
decorate: this.decorate.bind(this),
deferrals: this.deferrals,
ensureDeferral: this.ensureDeferral.bind(this),
reject: this.reject.bind(this),
resolve: this.resolve.bind(this),
wait: this.wait.bind(this),
};
return Object.assign(promise, This);
}
}
export function deferSet() {
return new OrderedAsynchrony();
}