raiden-ts
Version:
Raiden Light Client Typescript/Javascript SDK
320 lines • 15.4 kB
JavaScript
import constant from 'lodash/constant';
import { defer, EMPTY, from, merge, of, partition, pipe, race, ReplaySubject, Subject, throwError, timer, } from 'rxjs';
import { catchError, concatMap, distinctUntilChanged, filter, finalize, last, map, mergeMap, mergeMapTo, pluck, repeatWhen, retryWhen, scan, share, switchMap, takeUntil, takeWhile, tap, } from 'rxjs/operators';
import { isResponseOf } from './actions';
import { shouldRetryError } from './error';
import { isntNil } from './types';
/**
* Maps each source value (an object) to its specified nested property,
* and emits only if the value changed since last emission
*
* It's a combination of `pluck` and `distinctUntilChanged` operators.
*
* @param properties - The nested properties to pluck from each source value (an object).
* @returns A new Observable of property values from the source values.
*/
export const pluckDistinct = (...properties) => pipe(pluck(...properties), distinctUntilChanged());
/**
* Creates an operator to output changed values unique by key ([key, value] tuples)
* It's equivalent to (from(Object.entries) + distinct), but uses key to prevent memory leak
*
* @param compareFn - Function to compare equality between two values, default to === (reference)
* @returns Operator to map from a record to changed values (all on first)
*/
export function distinctRecordValues(compareFn = (prev, new_) => prev === new_) {
return pipe(distinctUntilChanged(), mergeMap((map) => Object.entries(map)),
/* this scan stores a reference to each [key,value] in 'acc', and emit as 'changed' iff it
* changes from last time seen. It relies on value references changing only if needed */
scan(({ acc }, [key, value]) =>
// if ref didn't change, emit previous accumulator, without 'changed' value
compareFn(acc[key], value)
? { acc }
: // else, update ref in 'acc' and emit value in 'changed' prop
{ acc: { ...acc, [key]: value }, changed: [key, value] }, { acc: {} }), pluck('changed'), filter(isntNil));
}
/**
* Operator to repeat-subscribe an input observable until a notifier emits
*
* @param notifier - Notifier observable to stop repeating
* @param delayMs - Delay between retries or an Iterator of delays; in milliseconds
* @returns Monotype operator function
*/
export function repeatUntil(notifier, delayMs = 30e3) {
// Resubscribe/retry every 30s or yielded ms after messageSend succeeds
// Notice first (or any) messageSend.request can wait for a long time before succeeding, as it
// waits for address's user in transport to be online and joined room before actually
// sending the message. That's why repeatWhen emits/resubscribe only some time after
// sendOnceAndWaitSent$ completes, instead of a plain 'interval'
return pipe(repeatWhen((completed$) => completed$.pipe(map(() => {
if (typeof delayMs === 'number')
return delayMs;
const next = delayMs.next();
return !next.done ? next.value : -1;
}), takeWhile((value) => value >= 0), // stop repeatWhen when done
switchMap((value) => timer(value)))), takeUntil(notifier));
}
// guard an Iterable between an iterable and iterator union
function isIterable(interval) {
return Symbol.iterator in interval;
}
/**
* Operator to retry/re-subscribe input$ until a stopPredicate returns truthy or delayMs iterator
* completes, waiting delayMs milliseconds between retries.
* Input observable must be re-subscribable/retriable.
*
* @param interval - Interval, iterable or iterator of intervals to wait between retries;
* if it's an iterable, it resets (iterator recreated) if input$ emits
* @param options - shouldRetryError options, conditions are ANDed
* @returns Operator function to retry if stopPredicate not truthy waiting between retries
*/
export function retryWhile(interval, options = {}) {
let iter;
if (options.log)
options = { ...options, log: options.log.bind(null, 'retryWhile') };
let shouldRetry;
return (input$) => defer(() => {
iter = undefined;
shouldRetry = shouldRetryError(options);
return from(input$).pipe(
// if input$ emits, reset iter (only useful if delayMs is an Iterable) and shouldRetry func
tap(() => {
iter = undefined;
shouldRetry = shouldRetryError(options);
}), retryWhen((error$) => error$.pipe(mergeMap((error) => {
let delayMs;
if (typeof interval === 'number')
delayMs = interval;
else {
if (!iter) {
if (isIterable(interval))
iter = interval[Symbol.iterator]();
else
iter = interval;
}
const next = iter.next();
delayMs = !next.done ? next.value : -1;
}
if (delayMs <= 0 || !shouldRetry(error))
throw error;
return timer(delayMs);
}))));
});
}
/**
* Receives an async function and returns an observable which will retry it every interval until it
* resolves, or throw if it can't succeed after 10 retries.
* It is needed e.g. on provider methods which perform RPC requests directly, as they can fail
* temporarily due to network errors, so they need to be retried for a while.
* JsonRpcProvider._doPoll also catches, suppresses & retry
*
* @param func - An async function (e.g. a Promise factory, like a defer callback)
* @param retryWhileArgs - Rest arguments as received by [[retryWhile]] operator
* @returns Observable version of async function, with retries
*/
export function retryAsync$(func, ...retryWhileArgs) {
return defer(func).pipe(retryWhile(...retryWhileArgs));
}
/**
* RxJS operator to keep subscribed to input$ if condition is truty (or falsy, if negated),
* unsubscribes from source if cond$ becomes falsy, and re-subscribes if it becomes truty again
* (input$ must be re-subscribable). While subscribed to source$, completes when source$ completes,
* otherwise, when cond$ completes (since source$ isn't subscribed then), so make sure cond$
* completes too when desired, or the output observable may hang until unsubscribed.
*
* @param cond$ - Condition observable
* @param negate - Whether to negate condition
* (i.e. keep subscribed while falsy, unsubscribe when truty)
* @returns monotype operator to unsubscribe and re-subscribe to source/input based on confition
*/
export function takeIf(cond$, negate = false) {
return (source$) => {
const completed$ = new Subject();
return cond$.pipe(map((cond) => (negate ? !cond : !!cond)), distinctUntilChanged(), takeUntil(completed$), switchMap((cond) => {
if (!cond)
return EMPTY;
return source$.pipe(tap({ complete: () => completed$.next(true) }));
}));
};
}
/**
* Complete an input when another observable completes
*
* @param complete$ - Observable which will complete input when completed
* @param delayMs - Delay unsubscribing source by some time after complete$ completes
* @returns Operator returning observable mirroring input, but completes when complete$ completes
*/
export function completeWith(complete$, delayMs) {
return pipe(takeUntil(complete$.pipe(lastMap(constant(delayMs === undefined ? of(0) : timer(delayMs))))));
}
/**
* Like a mergeMap which only subscribes to the inner observable once the input completes;
* Intermediary values are ignored; project receives optionally the last value emitted by input,
* or null if no value was emitted
*
* @param project - callback to generate the inner observable, receives last emitted value or null
* @returns Operator returning observable mirroring inner observable
*/
export function lastMap(project) {
return pipe(last(undefined, null), mergeMap(project));
}
/**
* Like timeout rxjs operator, but applies only on first emition
*
* @param timeout - Timeout to wait for an item flow through input
* @returns Operator function
*/
export function timeoutFirst(timeout) {
return (input$) => race(timer(timeout).pipe(mergeMapTo(throwError(() => new Error('timeout waiting first')))), input$);
}
/**
* Like a concatMap, but input values emitted while a subscription is active are buffered and
* passed to project callback as an array when previous subscription completes.
* This means a value emitted by input while there's no active subscription will cause project to
* be called with a single-element array (value), while multiple values going through will get
* buffered and project called with all of them only once previous completes.
*
* @param project - Callback to generate the inner ObservableInput
* @param maxBatchSize - Limit emitted batches to this size; non-emitted values will stay in queue
* and be passed on next project call and subscription
* @returns Observable of values emitted by inner subscription
*/
export function concatBuffer(project, maxBatchSize) {
return (input$) => {
const buffer = [];
return input$.pipe(tap((value) => buffer.push(value)), concatMap(() => defer(() => buffer.length
? project(buffer.splice(0, maxBatchSize ?? buffer.length))
: EMPTY)));
};
}
/**
* Flatten the merging of higher-order observables but preserving previous value
*
* It's like [[withLatestFrom]], but don't lose outter values and merges all inner emitted ones.
* Instead of the callback-hell of:
* obs1.pipe(
* mergeMap((v1) =>
* obs2(v1).pipe( // obs2 uses v1
* mergeMap((v2) =>
* obs3(v1, v2).pipe( // obs3 uses v1, v2
* map(({ v3_a, v3_b }) => { v1, v2, v3: v3_a + v3_b }), // map uses v1, v2, v3
* ),
* ),
* ),
* ),
* );
*
* You can now:
* obs1.pipe(
* withMergeFrom((v1) => obs2(v1, 123)),
* withMergeFrom(([v1, v2]) => obs3(v1, v2, true)),
* // you can use tuple-destructuring on values, and obj-destructuring on objects
* map(([[v1, v2], { v3_a, v3_b }]) => ({ v1, v2, v3: v3_a + v3_b })),
* );
*
* @param project - Project function passed to mergeMap
* @param mapFunc - Funtion to merge result with, like mergeMap or switchMap
* @returns Observable mirroring project's return, but prepending emitted values from this inner
* observable in a tuple with the value from the outter observable which generated the inner.
*/
export function withMergeFrom(project, mapFunc = mergeMap) {
return pipe(mapFunc((value, index) => from(project(value, index)).pipe(map((res) => [value, res]))));
}
/**
* Operator to catch, log and suppress observable errors
*
* @param opts - shouldRetryError parameters
* @param logParams - Additional log parameters, message and details to bind to opts.log
* @returns Operator to catch errors, log and suppress if it matches the opts conditions,
* Re-throws otherwise
*/
export function catchAndLog(opts, ...logParams) {
if (opts.log && logParams.length)
opts = { ...opts, log: opts.log.bind(null, ...logParams) };
const shouldSuppress = shouldRetryError(opts);
return pipe(catchError((err) => {
if (!shouldSuppress(err))
throw err;
return EMPTY;
}));
}
/**
* Custom operator providing a project function which is mirrored in the output, but provides a
* parameter function which allows submitting requests directly to the output as well, and returns
* with an observable which filters input$ for success|failures, errors on failures and completes
* on successes. In case 'confirmed' is true, this observable also emits intermediate unconfirmed
* successes and only completes upon the confirmed one is seen.
* Example:
* output$: Observable<anotherAction.success | messageSend.request> = action$.pipe(
* dispatchRequestAndGetResponse(messageSend, (dispatchRequest) =>
* // this observable will be mirrored to output, plus requests sent to dispatchRequest
* action$.pipe(
* filter(anotherAction.request.is),
* mergeMap((action) =>
* dispatchRequest(messageSend.request('test')).pipe(
* map((sentAction) => anotherAction.success({ sent: msgSendSucAction })),
* ),
* ),
* ),
* ),
* )
*
* @param aac - AsyncActionCreator type to wait for response; can be an array of action creators,
* in which case, dispatchRequest function will accept one request and return an observable
* for the corresponding response
* @param project - Function to be merged to output; called with a function which allows to
* dispatch requests directly to output and returns an observable which will emit the success
* coming in input and complete, or error if a failure goes through
* @param confirmed - Keep emitting success to dispatchRequest's returned observable while it isn't
* confirmed yet
* @param dedupKey - Function to calculate keys to deduplicate requests (returns the same
observable as result if a request with similar key is performed while one is still pending)
* @returns Custom operator which mirrors projected observable plus requests called in the
* project's function parameter
*/
export function dispatchRequestAndGetResponse(aac, project, confirmed = false, dedupKey = (value) => value) {
return (input$) => defer(() => {
const requestOutput$ = new Subject();
const pending = new Map();
const projectOutput$ = defer(() => project((request) => {
const key = dedupKey(request);
const pending$ = pending.get(key);
if (pending$)
return pending$;
const result$ = new ReplaySubject(1);
const sub = input$
.pipe(filter(isResponseOf(aac, request.meta)), map((response) => {
if (aac.failure.is(response))
throw response.payload;
return response;
}), takeWhile((response) => confirmed &&
'confirmed' in response.payload &&
response.payload.confirmed === undefined, true))
.subscribe(result$);
requestOutput$.next(request);
const res = result$.pipe(finalize(() => {
sub.unsubscribe();
pending.delete(key);
}));
pending.set(key, res);
return res;
})).pipe(finalize(() => requestOutput$.complete()));
return merge(requestOutput$, projectOutput$);
});
}
/**
* A custom operator to apply an inner operator only to a partitioned (filtered) view of the input,
* matching a given predicate, and merging the output with the values which doesn't match it
*
* @param predicate - Test input values if they should be projected
* @param operator - Receives observable of input values which matches predicate and return an
* observable input to be merged in the output together with values which don't
* @returns Observable of values which doesn't pass the predicate merged with the projected
* observables returned on the values which pass
*/
export function partitionMap(predicate, operator) {
return (source$) => {
const [true$, false$] = partition(source$.pipe(share()), predicate);
return merge(operator(true$), false$);
};
}
//# sourceMappingURL=rx.js.map