UNPKG

@hyperlane-xyz/utils

Version:

General utilities and types for the Hyperlane network

274 lines 9.27 kB
import { rootLogger } from './logging.js'; import { assert } from './validation.js'; /** * Lazily initialized async value with deduplication. * Concurrent callers share the same initialization promise. * After successful init, returns cached value immediately. * On error, clears state to allow retry on next call. */ export class LazyAsync { initializer; promise; value; hasValue = false; generation = 0; constructor(initializer) { this.initializer = initializer; } get() { if (this.hasValue) return Promise.resolve(this.value); this.promise ??= this.initialize(this.generation); return this.promise; } reset() { this.generation++; this.promise = undefined; this.value = undefined; this.hasValue = false; } isInitialized() { return this.hasValue; } peek() { return this.hasValue ? this.value : undefined; } async initialize(gen) { try { const result = await this.initializer(); // Only store if generation hasn't changed (no reset during init) if (gen === this.generation) { this.value = result; this.hasValue = true; } return result; } catch (error) { // Only clear promise if generation hasn't changed if (gen === this.generation) { this.promise = undefined; } throw error; } } } /** * Return a promise that resolves in ms milliseconds. * @param ms Time to wait */ export function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Wait up to a given amount of time, and throw an error if the promise does not resolve in time. * @param promise The promise to timeout on. * @param timeoutMs How long to wait for the promise in milliseconds. * @param message The error message if a timeout occurs. */ export function timeout(promise, timeoutMs, message = 'Timeout reached') { if (!timeoutMs || timeoutMs <= 0) return promise; return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(message)); }, timeoutMs); promise.then((val) => { clearTimeout(timer); resolve(val); }, (err) => { clearTimeout(timer); reject(err); }); }); } /** * Run a callback with a timeout. * @param timeoutMs How long to wait for the promise in milliseconds. * @param callback The callback to run. * @returns callback return value * @throws Error if the timeout is reached before the callback completes */ export async function runWithTimeout(timeoutMs, callback) { let timeoutId; const timeoutProm = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(`Timed out in ${timeoutMs}ms.`)); }, timeoutMs); }); try { const result = await Promise.race([callback(), timeoutProm]); return result; } finally { // @ts-ignore timeout gets set immediately by the promise constructor clearTimeout(timeoutId); } } /** * Executes a fetch request that fails after a timeout via an AbortController. * @param resource resource to fetch (e.g URL) * @param options fetch call options object * @param timeout timeout MS (default 10_000) * @returns fetch response */ export async function fetchWithTimeout(resource, options, timeout = 10_000) { const controller = new AbortController(); const id = setTimeout(controller.abort.bind(controller), timeout); const response = await fetch(resource, { ...options, signal: controller.signal, }); clearTimeout(id); return response; } /** * Retries an async function with exponential backoff. * Always executes at least once, even if `attempts` is 0 or negative. * Stops retrying if `error.isRecoverable` is set to false. * @param runner callback to run * @param attempts max number of attempts (defaults to 5, minimum 1) * @param baseRetryMs base delay between attempts in milliseconds (defaults to 50ms) * @returns runner return value */ export async function retryAsync(runner, attempts = 5, baseRetryMs = 50) { // Guard against invalid attempts - always try at least once attempts = attempts > 0 ? attempts : 1; let i = 0; for (;;) { try { const result = await runner(); return result; } catch (e) { const error = e; // Non-recoverable only if the flag is present _and_ set to false if (error.isRecoverable === false || ++i >= attempts) { throw error; } await sleep(baseRetryMs * 2 ** (i - 1)); } } } /** * Run a callback with a timeout, and retry if the callback throws an error. * @param runner callback to run * @param delayMs base delay between attempts * @param maxAttempts maximum number of attempts * @returns runner return value */ export async function pollAsync(runner, delayMs = 500, maxAttempts = undefined) { let attempts = 0; let saveError; while (!maxAttempts || attempts < maxAttempts) { try { const ret = await runner(); return ret; } catch (error) { rootLogger.debug(`Error in pollAsync`, { error }); saveError = error; attempts += 1; await sleep(delayMs); } } throw saveError; } /** * An enhanced Promise.race that returns * objects with the promise itself and index * instead of just the resolved value. */ export async function raceWithContext(promises) { const promisesWithContext = promises.map((p, i) => p.then((resolved) => ({ resolved, promise: p, index: i }))); return Promise.race(promisesWithContext); } /** * Map an async function over a list xs with a given concurrency level * Forked from https://github.com/celo-org/developer-tooling/blob/0c61e7e02c741fe10ecd1d733a33692d324cdc82/packages/sdk/base/src/async.ts#L128 * * @param concurrency number of `mapFn` concurrent executions * @param xs list of value * @param mapFn mapping function */ export async function concurrentMap(concurrency, xs, mapFn) { let res = []; assert(concurrency > 0, 'concurrency must be greater than 0'); for (let i = 0; i < xs.length; i += concurrency) { const remaining = xs.length - i; const sliceSize = Math.min(remaining, concurrency); const slice = xs.slice(i, i + sliceSize); res = res.concat(await Promise.all(slice.map((elem, index) => mapFn(elem, i + index)))); } return res; } /** * Maps an async function over items using Promise.allSettled semantics. * Unlike Promise.all, this continues processing all items even if some fail. * * @param items - Array of items to process * @param mapFn - Async function to apply to each item * @param keyFn - Optional function to derive a key for each item (defaults to using index) * @returns Object with `fulfilled` Map (successful results) and `rejected` Map (errors) * * @example * ```typescript * // Process chains and collect results/errors * const { fulfilled, rejected } = await mapAllSettled( * chains, * async (chain) => deployContract(chain), * (chain) => chain, // use chain name as key * ); * * // Handle errors if any * if (rejected.size > 0) { * const errors = [...rejected.entries()].map(([chain, err]) => `${chain}: ${err.message}`); * throw new Error(`Deployment failed: ${errors.join('; ')}`); * } * * // Use successful results * for (const [chain, result] of fulfilled) { * console.log(`Deployed to ${chain}: ${result}`); * } * ``` */ export async function mapAllSettled(items, mapFn, keyFn) { const results = await Promise.allSettled(items.map((item, index) => mapFn(item, index))); const fulfilled = new Map(); const rejected = new Map(); results.forEach((result, index) => { const key = keyFn ? keyFn(items[index], index) : index; if (result.status === 'fulfilled') { fulfilled.set(key, result.value); } else { const error = result.reason instanceof Error ? result.reason : new Error(String(result.reason)); rejected.set(key, error); } }); return { fulfilled, rejected }; } /** * Wraps an async function and catches any errors, logging them instead of throwing. * Useful for fire-and-forget operations where you want to log errors but not crash. * * @param fn - The async function to execute * @param context - A description of the context for error logging * @param logger - The logger instance to use for error logging */ export async function tryFn(fn, context, logger) { try { await fn(); } catch (error) { logger.error({ context, err: error }, `Error in ${context}`); } } export async function timedAsync(name, fn) { const start = Date.now(); const result = await fn(); rootLogger.trace(`Timing: ${name} took ${Date.now() - start}ms`); return result; } //# sourceMappingURL=async.js.map