UNPKG

@saberhq/sail

Version:

Account caching and batched loading for React-based Solana applications.

364 lines (325 loc) 10.3 kB
import type { AccountInfoFetcher, Provider } from "@saberhq/solana-contrib"; import { exists } from "@saberhq/solana-contrib"; import { useSolana } from "@saberhq/use-solana"; import type { AccountInfo, ClientSubscriptionId } from "@solana/web3.js"; import { PublicKey } from "@solana/web3.js"; import DataLoader from "dataloader"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { AccountFetchResult, SailError } from ".."; import { SailRefetchSubscriptionsError } from ".."; import type { AccountDatum } from "../types"; import { SailBatchFetcher, SailBatchProvider } from "./batchProvider"; import type { CacheBatchUpdateEvent } from "./emitter"; import { AccountsEmitter } from "./emitter"; import { getMultipleAccounts } from "./fetchers"; import { fetchKeysUsingLoader } from "./fetchKeysUsingLoader"; /** * Gets the cache key associated with the given pubkey. * @param pubkey * @returns */ export const getCacheKeyOfPublicKey = (pubkey: PublicKey): string => pubkey.toString(); export type AccountLoader = DataLoader< PublicKey, AccountInfo<Buffer> | null, string >; interface AccountsProviderState { accountsCache: Map<string, AccountInfo<Buffer> | null>; emitter: AccountsEmitter; subscribedAccounts: Map<string, number>; } const newState = (): AccountsProviderState => ({ accountsCache: new Map<string, AccountInfo<Buffer> | null>(), emitter: new AccountsEmitter(), subscribedAccounts: new Map(), }); export interface UseAccountsArgs { /** * Duration in ms in which to batch all accounts data requests. Defaults to 500ms. */ batchDurationMs?: number; /** * Milliseconds between each refresh. Defaults to 60_000. */ refreshIntervalMs?: number; /** * Called whenever an error occurs. */ onError: (err: SailError) => void; /** * If true, allows one to subscribe to account updates via websockets rather than via polling. */ useWebsocketAccountUpdates?: boolean; /** * If true, disables periodic account refetches for subscriptions. */ disableAutoRefresh?: boolean; } /** * Function signature for fetching keys. */ export type FetchKeysFn = ( keys: readonly PublicKey[] ) => Promise<readonly AccountFetchResult[]>; /** * Fetches keys, passing through null/undefined values. * @param fetchKeys * @param keys * @returns */ export const fetchKeysMaybe = async ( fetchKeys: FetchKeysFn, keys: readonly (PublicKey | null | undefined)[] ): Promise<readonly (AccountFetchResult | null | undefined)[]> => { const keysWithIndex = keys.map((k, i) => [k, i] as const); const nonEmptyKeysWithIndex = keysWithIndex.filter( (key): key is readonly [PublicKey, number] => exists(key[0]) ); const nonEmptyKeys = nonEmptyKeysWithIndex.map((n) => n[0]); const accountsData = await fetchKeys(nonEmptyKeys); const result: (AccountFetchResult | null | undefined)[] = keys.slice() as ( | AccountFetchResult | null | undefined )[]; nonEmptyKeysWithIndex.forEach(([_, originalIndex], i) => { result[originalIndex] = accountsData[i]; }); return result; }; export interface UseAccounts extends Required<UseAccountsArgs> { /** * The loader. Usually should not be used directly. */ loader: AccountLoader; /** * Refetches an account. */ refetch: (key: PublicKey) => Promise<AccountInfo<Buffer> | null>; /** * Refetches multiple accounts. */ refetchMany: ( keys: readonly PublicKey[] ) => Promise<(AccountInfo<Buffer> | Error | null)[]>; /** * Refetches all accounts that are being subscribed to. */ refetchAllSubscriptions: () => Promise<void>; /** * Registers a callback to be called whenever a batch of items is cached. */ onBatchCache: (cb: (args: CacheBatchUpdateEvent) => void) => void; /** * Fetches the data associated with the given keys, via the AccountLoader. */ fetchKeys: FetchKeysFn; /** * Causes a key to be refetched periodically. */ subscribe: (key: PublicKey) => () => Promise<void>; /** * Gets the cached data of an account. */ getCached: (key: PublicKey) => AccountInfo<Buffer> | null | undefined; /** * Gets an AccountDatum from the cache. * * If the AccountInfo has never been fetched, this returns undefined. * If the AccountInfo has been fetched but wasn't found, this returns null. */ getDatum: (key: PublicKey | null | undefined) => AccountDatum; /** * Fetches accounts. */ batchFetcher: AccountInfoFetcher; /** * Provider using the batch fetcher. */ batchProviderMut: Provider | null; } export const useAccountsInternal = (args: UseAccountsArgs): UseAccounts => { const { batchDurationMs = 500, refreshIntervalMs = 60_000, onError, useWebsocketAccountUpdates = false, disableAutoRefresh: disableRefresh = false, } = args; const { network, connection, providerMut } = useSolana(); // Cache of accounts const [{ accountsCache, emitter, subscribedAccounts }, setState] = useState<AccountsProviderState>(newState()); useEffect(() => { setState((prevState) => { // clear accounts cache and subscriptions whenever the network changes prevState.accountsCache.clear(); prevState.subscribedAccounts.clear(); prevState.emitter.raiseCacheCleared(); return newState(); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [network]); const accountLoader = useMemo( () => new DataLoader<PublicKey, AccountInfo<Buffer> | null, string>( async (keys: readonly PublicKey[]) => { const result = await getMultipleAccounts( connection, keys, onError, "confirmed" ); const batch = new Set<string>(); result.array.forEach((info, i) => { const addr = keys[i]; if (addr && !(info instanceof Error)) { const cacheKey = getCacheKeyOfPublicKey(addr); accountsCache.set(cacheKey, info); batch.add(cacheKey); } }); emitter.raiseBatchCacheUpdated(batch); return result.array; }, { // aggregate all requests over 500ms batchScheduleFn: (callback) => setTimeout(callback, batchDurationMs), cacheKeyFn: getCacheKeyOfPublicKey, } ), [accountsCache, batchDurationMs, connection, emitter, onError] ); const { batchFetcher, batchProviderMut } = useMemo(() => { return { batchFetcher: new SailBatchFetcher(accountLoader), batchProviderMut: providerMut ? new SailBatchProvider(providerMut, accountLoader) : null, }; }, [providerMut, accountLoader]); const fetchKeys = useCallback( async (keys: readonly PublicKey[]) => { return await fetchKeysUsingLoader(accountLoader, keys); }, [accountLoader] ); const onBatchCache = emitter.onBatchCache; const refetch = useCallback( async (key: PublicKey) => { const result = await accountLoader.clear(key).load(key); return result; }, [accountLoader] ); const refetchMany = useCallback( async (keys: readonly PublicKey[]) => { keys.forEach((key) => { accountLoader.clear(key); }); return await accountLoader.loadMany(keys); }, [accountLoader] ); const getCached = useCallback( (key: PublicKey): AccountInfo<Buffer> | null | undefined => { // null: account not found on blockchain // undefined: cache miss (not yet fetched) return accountsCache.get(getCacheKeyOfPublicKey(key)); }, [accountsCache] ); const subscribe = useCallback( (key: PublicKey): (() => Promise<void>) => { const keyStr = getCacheKeyOfPublicKey(key); const amount = subscribedAccounts.get(keyStr); if (amount === undefined || amount === 0) { subscribedAccounts.set(keyStr, 1); } else { subscribedAccounts.set(keyStr, amount + 1); } let listener: ClientSubscriptionId | null = null; if (useWebsocketAccountUpdates) { listener = connection.onAccountChange(key, (data) => { const cacheKey = getCacheKeyOfPublicKey(key); accountsCache.set(cacheKey, data); accountLoader.clear(key).prime(key, data); emitter.raiseBatchCacheUpdated(new Set([cacheKey])); }); } return async () => { const currentAmount = subscribedAccounts.get(keyStr); if ((currentAmount ?? 0) > 1) { subscribedAccounts.set(keyStr, (currentAmount ?? 0) - 1); } else { subscribedAccounts.delete(keyStr); } if (listener) { await connection.removeAccountChangeListener(listener); } }; }, [ accountLoader, accountsCache, connection, emitter, subscribedAccounts, useWebsocketAccountUpdates, ] ); const refetchAllSubscriptions = useCallback(async () => { const keysToFetch = [...subscribedAccounts.keys()].map((keyStr) => { return new PublicKey(keyStr); }); await refetchMany(keysToFetch); }, [refetchMany, subscribedAccounts]); useEffect(() => { // don't auto refetch if we're disabling the refresh if (disableRefresh) { return; } const interval = setInterval(() => { void refetchAllSubscriptions().catch((e) => { onError(new SailRefetchSubscriptionsError(e)); }); }, refreshIntervalMs); return () => clearInterval(interval); }, [disableRefresh, onError, refetchAllSubscriptions, refreshIntervalMs]); const getDatum = useCallback( (k: PublicKey | null | undefined) => { if (!k) { return k; } const accountInfo = getCached(k); if (accountInfo) { return { accountId: k, accountInfo, }; } return accountInfo; }, [getCached] ); return { loader: accountLoader, getCached, getDatum, refetch, refetchMany, refetchAllSubscriptions, onBatchCache, fetchKeys, subscribe, batchDurationMs, refreshIntervalMs, useWebsocketAccountUpdates, disableAutoRefresh: disableRefresh, onError, batchFetcher, batchProviderMut, }; };