relay-list-cache.service.ts raw

   1  import { RelayList } from '@/domain/relay/RelayList'
   2  import { Event, kinds } from 'nostr-tools'
   3  import client from './client.service'
   4  import indexedDb from './indexed-db.service'
   5  
   6  /**
   7   * Cache entry for a user's relay list
   8   */
   9  export interface CachedRelayList {
  10    pubkey: string
  11    read: string[]
  12    write: string[]
  13    fetchedAt: number
  14    event?: Event
  15  }
  16  
  17  /**
  18   * LRU Cache implementation for in-memory caching
  19   */
  20  class LRUCache<K, V> {
  21    private cache = new Map<K, V>()
  22  
  23    constructor(private maxSize: number) {}
  24  
  25    get(key: K): V | undefined {
  26      const value = this.cache.get(key)
  27      if (value !== undefined) {
  28        // Move to end (most recently used)
  29        this.cache.delete(key)
  30        this.cache.set(key, value)
  31      }
  32      return value
  33    }
  34  
  35    set(key: K, value: V): void {
  36      if (this.cache.has(key)) {
  37        this.cache.delete(key)
  38      } else if (this.cache.size >= this.maxSize) {
  39        // Remove oldest (first) entry
  40        const firstKey = this.cache.keys().next().value
  41        if (firstKey !== undefined) {
  42          this.cache.delete(firstKey)
  43        }
  44      }
  45      this.cache.set(key, value)
  46    }
  47  
  48    has(key: K): boolean {
  49      return this.cache.has(key)
  50    }
  51  
  52    delete(key: K): boolean {
  53      return this.cache.delete(key)
  54    }
  55  
  56    clear(): void {
  57      this.cache.clear()
  58    }
  59  }
  60  
  61  /**
  62   * RelayListCacheService
  63   *
  64   * Caches NIP-65 relay lists for other users to enable proper relay selection
  65   * without falling back to hardcoded relay lists.
  66   *
  67   * Features:
  68   * - In-memory LRU cache for fast access
  69   * - IndexedDB persistence for cache survival across sessions
  70   * - Batch fetching for efficiency
  71   * - Stale-while-revalidate pattern
  72   */
  73  class RelayListCacheService {
  74    private memoryCache: LRUCache<string, CachedRelayList>
  75    private pendingFetches = new Map<string, Promise<CachedRelayList | null>>()
  76  
  77    // Cache entries older than this are considered stale
  78    private staleAfterMs = 24 * 60 * 60 * 1000 // 24 hours
  79  
  80    constructor() {
  81      this.memoryCache = new LRUCache(500) // Keep up to 500 relay lists in memory
  82    }
  83  
  84    /**
  85     * Get a cached relay list for a pubkey
  86     * Returns null if not in cache
  87     */
  88    async getRelayList(pubkey: string): Promise<CachedRelayList | null> {
  89      // Check memory cache first
  90      const memCached = this.memoryCache.get(pubkey)
  91      if (memCached) {
  92        return memCached
  93      }
  94  
  95      // Check IndexedDB
  96      const event = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)
  97      if (event) {
  98        const cached = this.eventToCachedRelayList(event)
  99        this.memoryCache.set(pubkey, cached)
 100        return cached
 101      }
 102  
 103      return null
 104    }
 105  
 106    /**
 107     * Check if a cached relay list is stale
 108     */
 109    isStale(cached: CachedRelayList): boolean {
 110      return Date.now() - cached.fetchedAt > this.staleAfterMs
 111    }
 112  
 113    /**
 114     * Fetch a relay list from the network
 115     * Uses relay hints if provided
 116     */
 117    async fetchRelayList(
 118      pubkey: string,
 119      hints?: string[]
 120    ): Promise<CachedRelayList | null> {
 121      // Dedupe concurrent fetches for the same pubkey
 122      const pending = this.pendingFetches.get(pubkey)
 123      if (pending) {
 124        return pending
 125      }
 126  
 127      const fetchPromise = this.doFetchRelayList(pubkey, hints)
 128      this.pendingFetches.set(pubkey, fetchPromise)
 129  
 130      try {
 131        return await fetchPromise
 132      } finally {
 133        this.pendingFetches.delete(pubkey)
 134      }
 135    }
 136  
 137    private async doFetchRelayList(
 138      pubkey: string,
 139      _hints?: string[]
 140    ): Promise<CachedRelayList | null> {
 141      try {
 142        // TODO: Use hints when fetching from network (requires adding hints support to fetchRelayListEvent)
 143        const event = await client.fetchRelayListEvent(pubkey)
 144        if (!event) {
 145          // Cache the miss to avoid repeated fetches
 146          await indexedDb.putNullReplaceableEvent(pubkey, kinds.RelayList)
 147          return null
 148        }
 149  
 150        // Store in both caches
 151        await indexedDb.putReplaceableEvent(event)
 152        const cached = this.eventToCachedRelayList(event)
 153        this.memoryCache.set(pubkey, cached)
 154  
 155        return cached
 156      } catch (error) {
 157        console.warn(`Failed to fetch relay list for ${pubkey}:`, error)
 158        return null
 159      }
 160    }
 161  
 162    /**
 163     * Fetch relay lists for multiple pubkeys efficiently
 164     */
 165    async fetchRelayLists(
 166      pubkeys: string[],
 167      hints?: string[]
 168    ): Promise<Map<string, CachedRelayList>> {
 169      const results = new Map<string, CachedRelayList>()
 170      const toFetch: string[] = []
 171  
 172      // Check caches first
 173      for (const pubkey of pubkeys) {
 174        const cached = await this.getRelayList(pubkey)
 175        if (cached && !this.isStale(cached)) {
 176          results.set(pubkey, cached)
 177        } else {
 178          toFetch.push(pubkey)
 179        }
 180      }
 181  
 182      // Batch fetch the rest
 183      if (toFetch.length > 0) {
 184        const fetchPromises = toFetch.map((pubkey) =>
 185          this.fetchRelayList(pubkey, hints).then((result) => ({
 186            pubkey,
 187            result
 188          }))
 189        )
 190  
 191        const fetched = await Promise.all(fetchPromises)
 192        for (const { pubkey, result } of fetched) {
 193          if (result) {
 194            results.set(pubkey, result)
 195          }
 196        }
 197      }
 198  
 199      return results
 200    }
 201  
 202    /**
 203     * Get combined write relays for multiple recipients
 204     * Used when publishing events that mention/reply to others
 205     */
 206    async getWriteRelaysForRecipients(pubkeys: string[]): Promise<string[]> {
 207      const relayLists = await this.fetchRelayLists(pubkeys)
 208      const writeRelays = new Set<string>()
 209  
 210      for (const cached of relayLists.values()) {
 211        for (const relay of cached.write) {
 212          writeRelays.add(relay)
 213        }
 214      }
 215  
 216      return Array.from(writeRelays)
 217    }
 218  
 219    /**
 220     * Store a relay list that was fetched elsewhere (opportunistic caching)
 221     */
 222    async setRelayList(event: Event): Promise<void> {
 223      if (event.kind !== kinds.RelayList) {
 224        return
 225      }
 226  
 227      await indexedDb.putReplaceableEvent(event)
 228      const cached = this.eventToCachedRelayList(event)
 229      this.memoryCache.set(event.pubkey, cached)
 230    }
 231  
 232    /**
 233     * Convert a kind 10002 event to a CachedRelayList
 234     */
 235    private eventToCachedRelayList(event: Event): CachedRelayList {
 236      const relayList = RelayList.fromEvent(event)
 237      return {
 238        pubkey: event.pubkey,
 239        read: relayList.getReadUrls(),
 240        write: relayList.getWriteUrls(),
 241        fetchedAt: Date.now(),
 242        event
 243      }
 244    }
 245  
 246    /**
 247     * Clear all cached relay lists
 248     */
 249    clearCache(): void {
 250      this.memoryCache.clear()
 251    }
 252  }
 253  
 254  const relayListCacheService = new RelayListCacheService()
 255  export default relayListCacheService
 256