UNPKG

@bigmi/core

Version:

TypeScript library for Bitcoin apps.

368 lines (335 loc) 10.2 kB
import { RpcErrorCode } from '../errors/rpc.js' import { AllTransportsFailedError, TransportMethodNotSupportedError, } from '../errors/transport.js' import type { ErrorType } from '../errors/utils.js' import { InsufficientUTXOBalanceError } from '../errors/utxo.js' import { createTransport } from '../factories/createTransport.js' import type { Chain } from '../types/chain.js' import type { CreateTransportErrorType, Transport, TransportConfig, } from '../types/transport.js' import { wait } from '../utils/wait.js' // TODO: Narrow `method` & `params` types. export type OnResponseFn = ( args: { method: string params: unknown[] transport: ReturnType<Transport> } & ( | { error?: undefined response: unknown status: 'success' } | { error: Error response?: undefined status: 'error' } ) ) => void type RankOptions = { /** * The polling interval (in ms) at which the ranker should ping the RPC URL. * @default client.pollingInterval */ interval?: number | undefined /** * Ping method to determine latency. */ ping?: (parameters: { transport: ReturnType<Transport> }) => Promise<unknown> | undefined /** * The number of previous samples to perform ranking on. * @default 10 */ sampleCount?: number | undefined /** * Timeout when sampling transports. * @default 1_000 */ timeout?: number | undefined /** * Weights to apply to the scores. Weight values are proportional. */ weights?: | { /** * The weight to apply to the latency score. * @default 0.3 */ latency?: number | undefined /** * The weight to apply to the stability score. * @default 0.7 */ stability?: number | undefined } | undefined } export type FallbackTransportConfig = { /** The key of the Fallback transport. */ key?: TransportConfig['key'] | undefined /** The name of the Fallback transport. */ name?: TransportConfig['name'] | undefined /** Toggle to enable ranking, or rank options. */ rank?: boolean | RankOptions | undefined /** The max number of times to retry. */ retryCount?: TransportConfig['retryCount'] | undefined /** The base delay (in ms) between retries. */ retryDelay?: TransportConfig['retryDelay'] | undefined /** Callback on whether an error should throw or try the next transport in the fallback. */ shouldThrow?: (error: Error) => boolean | undefined } export type FallbackTransport< transports extends readonly Transport[] = readonly Transport[], > = Transport< 'fallback', { onResponse: (fn: OnResponseFn) => void transports: { [key in keyof transports]: ReturnType<transports[key]> } } > export type FallbackTransportErrorType = CreateTransportErrorType | ErrorType export function fallback<const transports extends readonly Transport[]>( transports_: transports, config: FallbackTransportConfig = {} ): FallbackTransport<transports> { const { key = 'fallback', name = 'Fallback', rank = false, shouldThrow: shouldThrow_ = shouldThrow, retryCount, retryDelay, } = config return (({ chain, pollingInterval = 4_000, timeout, ...rest }) => { let transports = transports_ let onResponse: OnResponseFn = () => {} const transport = createTransport( { key, name, async request({ method, params }) { // Filter transports to only those that support the requested method const supportedTransports = transports.reduce< ReturnType<Transport>[] >((supportedTransports, transport) => { const instance = transport({ ...rest, chain, retryCount: 0, timeout, }) const { include, exclude } = instance.config.methods || {} if (include) { if (include.includes(method)) { supportedTransports.push(instance) } return supportedTransports } if (exclude) { if (!exclude.includes(method)) { supportedTransports.push(instance) } return supportedTransports } supportedTransports.push(instance) return supportedTransports }, []) if (!supportedTransports.length) { throw new TransportMethodNotSupportedError({ method }) } const collectedErrors: Array<{ transport: string error: Error attempt: number }> = [] const fetch = async (i = 0): Promise<any> => { const transport = supportedTransports[i] try { const response = await transport.request({ method, params, }) onResponse({ method, params: params as unknown[], response, transport, status: 'success', }) return response } catch (err) { onResponse({ error: err as Error, method, params: params as unknown[], transport, status: 'error', }) if (shouldThrow_(err as Error)) { throw err } collectedErrors.push({ transport: transport.config.name, error: err as Error, attempt: i + 1, }) // If we've reached the end of the fallbacks, throw all collected errors. if (i === supportedTransports.length - 1) { throw new AllTransportsFailedError({ method, params, errors: collectedErrors, totalAttempts: i + 1, }) } // Otherwise, try the next fallback. return fetch(i + 1) } } return fetch() }, retryCount, retryDelay, type: 'fallback', }, { onResponse: (fn: OnResponseFn) => { onResponse = fn }, transports: transports.map((fn) => fn({ chain, retryCount: 0 })), } ) if (rank) { const rankOptions = (typeof rank === 'object' ? rank : {}) as RankOptions rankTransports({ chain, interval: rankOptions.interval ?? pollingInterval, onTransports: (transports_) => { transports = transports_ as transports }, ping: rankOptions.ping, sampleCount: rankOptions.sampleCount, timeout: rankOptions.timeout, transports, weights: rankOptions.weights, }) } return transport }) as FallbackTransport<transports> } export function shouldThrow(error: Error) { if (error instanceof InsufficientUTXOBalanceError) { return true } if ('code' in error && typeof error.code === 'number') { if ( error.code === RpcErrorCode.INTERNAL_ERROR || error.code === RpcErrorCode.USER_REJECTION || error.code === 5000 // CAIP UserRejectedRequestError ) { return true } } return false } /** @internal */ export function rankTransports({ chain, interval = 4_000, onTransports, ping, sampleCount = 10, timeout = 1_000, transports, weights = {}, }: { chain?: Chain | undefined interval: RankOptions['interval'] onTransports: (transports: readonly Transport[]) => void ping?: RankOptions['ping'] | undefined sampleCount?: RankOptions['sampleCount'] | undefined timeout?: RankOptions['timeout'] | undefined transports: readonly Transport[] weights?: RankOptions['weights'] | undefined }) { const { stability: stabilityWeight = 0.7, latency: latencyWeight = 0.3 } = weights type SampleData = { latency: number; success: number } type Sample = SampleData[] const samples: Sample[] = [] const rankTransports_ = async () => { // 1. Take a sample from each Transport. const sample: Sample = await Promise.all( transports.map(async (transport) => { const transport_ = transport({ chain, retryCount: 0, timeout }) const start = Date.now() let end: number let success: number try { await (ping ? ping({ transport: transport_ }) : transport_.request({ method: 'net_listening', params: undefined, })) success = 1 } catch { success = 0 } finally { end = Date.now() } const latency = end - start return { latency, success } }) ) // 2. Store the sample. If we have more than `sampleCount` samples, remove // the oldest sample. samples.push(sample) if (samples.length > sampleCount) { samples.shift() } // 3. Calculate the max latency from samples. const maxLatency = Math.max( ...samples.map((sample) => Math.max(...sample.map(({ latency }) => latency)) ) ) // 4. Calculate the score for each Transport. const scores = transports .map((_, i) => { const latencies = samples.map((sample) => sample[i].latency) const meanLatency = latencies.reduce((acc, latency) => acc + latency, 0) / latencies.length const latencyScore = 1 - meanLatency / maxLatency const successes = samples.map((sample) => sample[i].success) const stabilityScore = successes.reduce((acc, success) => acc + success, 0) / successes.length if (stabilityScore === 0) { return [0, i] } return [ latencyWeight * latencyScore + stabilityWeight * stabilityScore, i, ] }) .sort((a, b) => b[0] - a[0]) // 5. Sort the Transports by score. onTransports(scores.map(([, i]) => transports[i])) // 6. Wait, and then rank again. await wait(interval) rankTransports_() } rankTransports_() }