@hyperlane-xyz/utils
Version:
General utilities and types for the Hyperlane network
274 lines • 9.27 kB
JavaScript
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