@bigmi/core
Version:
TypeScript library for Bitcoin apps.
368 lines (335 loc) • 10.2 kB
text/typescript
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_()
}