diff --git a/lib/components/neynar-client-provider.react.js b/lib/components/neynar-client-provider.react.js --- a/lib/components/neynar-client-provider.react.js +++ b/lib/components/neynar-client-provider.react.js @@ -2,10 +2,12 @@ import * as React from 'react'; +import { FCCache } from '../utils/fc-cache.js'; import { NeynarClient } from '../utils/neynar-client.js'; type NeynarClientContextType = { +client: NeynarClient, + +fcCache: FCCache, }; const NeynarClientContext: React.Context = @@ -31,6 +33,7 @@ } return { client: neynarClient, + fcCache: new FCCache(neynarClient), }; }, [neynarClient]); diff --git a/lib/types/farcaster-types.js b/lib/types/farcaster-types.js --- a/lib/types/farcaster-types.js +++ b/lib/types/farcaster-types.js @@ -14,13 +14,19 @@ +fid: string, }; -export type NeynarUserWithViewerContext = { +export type NeynarUser = { +fid: number, + +username: string, + ... +}; + +export type NeynarUserWithViewerContext = $ReadOnly<{ + ...NeynarUser, +viewerContext: { +following: boolean, }, ... -}; +}>; export type NeynarChannel = { +id: string, diff --git a/lib/utils/fc-cache.js b/lib/utils/fc-cache.js new file mode 100644 --- /dev/null +++ b/lib/utils/fc-cache.js @@ -0,0 +1,152 @@ +// @flow + +import { NeynarClient } from './neynar-client.js'; +import sleep from './sleep.js'; + +const cacheTimeout = 24 * 60 * 60 * 1000; // one day +const failedQueryCacheTimeout = 5 * 60 * 1000; // five minutes +const queryTimeout = 10 * 1000; // ten seconds + +async function throwOnTimeout(identifier: string) { + await sleep(queryTimeout); + throw new Error(`Farcaster fetch for ${identifier} timed out`); +} + +type FarcasterUsernameQueryCacheEntry = { + +fid: string, + +expirationTime: number, + +farcasterUsername: ?string | Promise, +}; + +class FCCache { + client: NeynarClient; + + // Maps from FIDs to a cache entry for its Farcaster username + farcasterUsernameQueryCache: Map = + new Map(); + + constructor(client: NeynarClient) { + this.client = client; + } + + getFarcasterUsernamesForFIDs( + fids: $ReadOnlyArray, + ): Promise> { + const cacheMatches = fids.map(fid => + this.getCachedFarcasterUsernameEntryForFID(fid), + ); + const cacheResultsPromise = Promise.all( + cacheMatches.map(match => + Promise.resolve(match ? match.farcasterUsername : match), + ), + ); + if (cacheMatches.every(Boolean)) { + return cacheResultsPromise; + } + + const needFetch = []; + for (let i = 0; i < fids.length; i++) { + const fid = fids[i]; + const cacheMatch = cacheMatches[i]; + if (!cacheMatch) { + needFetch.push(fid); + } + } + + const fetchFarcasterUsernamesPromise = (async () => { + let farcasterUsernames: $ReadOnlyArray; + try { + farcasterUsernames = await Promise.race([ + this.client.getFarcasterUsernames(needFetch), + throwOnTimeout(`usernames for ${JSON.stringify(needFetch)}`), + ]); + } catch (e) { + console.log(e); + farcasterUsernames = new Array(needFetch.length).fill(null); + } + + const resultMap = new Map(); + for (let i = 0; i < needFetch.length; i++) { + const fid = needFetch[i]; + const farcasterUsername = farcasterUsernames[i]; + resultMap.set(fid, farcasterUsername); + } + return resultMap; + })(); + + for (let i = 0; i < needFetch.length; i++) { + const fid = needFetch[i]; + const fetchFarcasterUsernamePromise = (async () => { + const resultMap = await fetchFarcasterUsernamesPromise; + return resultMap.get(fid) ?? null; + })(); + this.farcasterUsernameQueryCache.set(fid, { + fid, + expirationTime: Date.now() + queryTimeout * 2, + farcasterUsername: fetchFarcasterUsernamePromise, + }); + } + + return (async () => { + const [resultMap, cacheResults] = await Promise.all([ + fetchFarcasterUsernamesPromise, + cacheResultsPromise, + ]); + for (let i = 0; i < needFetch.length; i++) { + const fid = needFetch[i]; + const farcasterUsername = resultMap.get(fid); + const timeout = + farcasterUsername === null ? failedQueryCacheTimeout : cacheTimeout; + this.farcasterUsernameQueryCache.set(fid, { + fid, + expirationTime: Date.now() + timeout, + farcasterUsername, + }); + } + + const results = []; + for (let i = 0; i < fids.length; i++) { + const cachedResult = cacheResults[i]; + if (cachedResult) { + results.push(cachedResult); + } else { + results.push(resultMap.get(fids[i])); + } + } + return results; + })(); + } + + getCachedFarcasterUsernameEntryForFID( + fid: string, + ): ?FarcasterUsernameQueryCacheEntry { + const cacheResult = this.farcasterUsernameQueryCache.get(fid); + if (!cacheResult) { + return undefined; + } + + const { expirationTime } = cacheResult; + if (expirationTime <= Date.now()) { + this.farcasterUsernameQueryCache.delete(fid); + return undefined; + } + + return cacheResult; + } + + getCachedFarcasterUsernameForFID(fid: string): ?string { + const cacheResult = this.getCachedFarcasterUsernameEntryForFID(fid); + if (!cacheResult) { + return undefined; + } + + const { farcasterUsername } = cacheResult; + if (typeof farcasterUsername !== 'string') { + return undefined; + } + + return farcasterUsername; + } +} + +export { FCCache }; diff --git a/lib/utils/neynar-client.js b/lib/utils/neynar-client.js --- a/lib/utils/neynar-client.js +++ b/lib/utils/neynar-client.js @@ -5,6 +5,7 @@ import { getMessageForException } from './errors.js'; import type { NeynarChannel, + NeynarUser, NeynarUserWithViewerContext, } from '../types/farcaster-types.js'; @@ -24,6 +25,10 @@ }, }; +type FetchUsersResponse = { + +users: $ReadOnlyArray, +}; + const neynarBaseURL = 'https://api.neynar.com/'; const neynarURLs = { '1': `${neynarBaseURL}v1/farcaster/`, @@ -141,6 +146,47 @@ return farcasterChannels; } + + async getFarcasterUsernames( + fids: $ReadOnlyArray, + ): Promise> { + const fidsLeft = [...fids]; + const results: Array = []; + do { + // Neynar API allows querying 100 at a time + const batch = fidsLeft.splice(0, 100); + const url = getNeynarURL('2', 'user/bulk', { fids: batch.join(',') }); + try { + const response = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + api_key: this.apiKey, + }, + }); + + const json: FetchUsersResponse = await response.json(); + const { users } = json; + + const neynarUserMap = new Map(); + for (const neynarUser of users) { + neynarUserMap.set(neynarUser.fid, neynarUser); + } + + for (const fid of batch) { + const neynarUser = neynarUserMap.get(parseInt(fid)); + results.push(neynarUser ? neynarUser.username : null); + } + } catch (error) { + console.log( + 'Failed to fetch Farcaster usernames:', + getMessageForException(error) ?? 'unknown', + ); + throw error; + } + } while (fidsLeft.length > 0); + return results; + } } export { NeynarClient };