nrc-cache-relay.service.ts raw

   1  /**
   2   * NRC Cache Relay Service
   3   *
   4   * Manages NRC connections that act as "cache relays":
   5   * - Query first with 400ms timeout before falling back to regular relays
   6   * - Push loaded events to cache relays in background
   7   *
   8   * Cache relays are private relays accessible via NRC that can store
   9   * a user's viewed events for faster subsequent access.
  10   */
  11  
  12  import { Event, Filter } from 'nostr-tools'
  13  import { NRCClient, SyncProgress } from './nrc-client.service'
  14  
  15  /**
  16   * Configuration for an NRC cache relay
  17   */
  18  export interface NRCCacheRelayConfig {
  19    id: string
  20    uri: string // nostr+relayconnect:// URI
  21    label: string
  22    enabled: boolean
  23    queryFirst: boolean // Query before regular relays with 400ms timeout
  24    pushEvents: boolean // Push loaded events in background
  25    lastConnected?: number
  26    lastError?: string
  27  }
  28  
  29  /**
  30   * Cache relay query result
  31   */
  32  export interface CacheRelayQueryResult {
  33    events: Event[]
  34    fromCache: boolean
  35    relayId?: string
  36  }
  37  
  38  // Storage key for cache relay configs
  39  const STORAGE_KEY = 'nrc-cache-relays'
  40  
  41  // Default query timeout for cache relays (400ms)
  42  const DEFAULT_CACHE_QUERY_TIMEOUT = 400
  43  
  44  // Maximum events per push batch
  45  const MAX_PUSH_BATCH_SIZE = 50
  46  
  47  // Debounce time for push batching (ms)
  48  const PUSH_DEBOUNCE_MS = 100
  49  
  50  class NRCCacheRelayService extends EventTarget {
  51    private configs: NRCCacheRelayConfig[] = []
  52    private pushQueue: Event[] = []
  53    private pushInProgress = false
  54    private pushTimeout: ReturnType<typeof setTimeout> | null = null
  55    private seenEventIds: Set<string> = new Set()
  56  
  57    constructor() {
  58      super()
  59      this.loadConfigs()
  60    }
  61  
  62    /**
  63     * Load configurations from storage
  64     */
  65    private loadConfigs(): void {
  66      try {
  67        const stored = window.localStorage.getItem(STORAGE_KEY)
  68        if (stored) {
  69          this.configs = JSON.parse(stored)
  70        }
  71      } catch (err) {
  72        console.error('[NRC Cache] Failed to load configs:', err)
  73        this.configs = []
  74      }
  75    }
  76  
  77    /**
  78     * Save configurations to storage
  79     */
  80    private saveConfigs(): void {
  81      try {
  82        window.localStorage.setItem(STORAGE_KEY, JSON.stringify(this.configs))
  83      } catch (err) {
  84        console.error('[NRC Cache] Failed to save configs:', err)
  85      }
  86    }
  87  
  88    /**
  89     * Get all cache relay configurations
  90     */
  91    getAll(): NRCCacheRelayConfig[] {
  92      return [...this.configs]
  93    }
  94  
  95    /**
  96     * Get enabled cache relays that should be queried first
  97     */
  98    getQueryFirstRelays(): NRCCacheRelayConfig[] {
  99      return this.configs.filter((c) => c.enabled && c.queryFirst)
 100    }
 101  
 102    /**
 103     * Get enabled cache relays that should receive pushed events
 104     */
 105    getPushRelays(): NRCCacheRelayConfig[] {
 106      return this.configs.filter((c) => c.enabled && c.pushEvents)
 107    }
 108  
 109    /**
 110     * Add a new cache relay configuration
 111     */
 112    add(config: Omit<NRCCacheRelayConfig, 'id'>): NRCCacheRelayConfig {
 113      const newConfig: NRCCacheRelayConfig = {
 114        ...config,
 115        id: crypto.randomUUID()
 116      }
 117      this.configs.push(newConfig)
 118      this.saveConfigs()
 119      this.dispatchEvent(new CustomEvent('configsChanged'))
 120      return newConfig
 121    }
 122  
 123    /**
 124     * Update a cache relay configuration
 125     */
 126    update(id: string, updates: Partial<NRCCacheRelayConfig>): void {
 127      const index = this.configs.findIndex((c) => c.id === id)
 128      if (index >= 0) {
 129        this.configs[index] = { ...this.configs[index], ...updates }
 130        this.saveConfigs()
 131        this.dispatchEvent(new CustomEvent('configsChanged'))
 132      }
 133    }
 134  
 135    /**
 136     * Remove a cache relay configuration
 137     */
 138    remove(id: string): void {
 139      this.configs = this.configs.filter((c) => c.id !== id)
 140      this.saveConfigs()
 141      this.dispatchEvent(new CustomEvent('configsChanged'))
 142    }
 143  
 144    /**
 145     * Query cache relays with timeout
 146     *
 147     * Returns events from the first cache relay that responds within the timeout.
 148     * If no cache relay responds in time, returns an empty array.
 149     *
 150     * @param filters - Nostr filters to query
 151     * @param timeoutMs - Maximum time to wait for response (default: 400ms)
 152     * @returns Events from cache relay, or empty array if none respond in time
 153     */
 154    async queryWithTimeout(
 155      filters: Filter[],
 156      timeoutMs: number = DEFAULT_CACHE_QUERY_TIMEOUT
 157    ): Promise<CacheRelayQueryResult> {
 158      const queryRelays = this.getQueryFirstRelays()
 159  
 160      if (queryRelays.length === 0) {
 161        return { events: [], fromCache: false }
 162      }
 163  
 164      // Race all cache relays against a timeout
 165      const queryPromises = queryRelays.map(async (config) => {
 166        try {
 167          const client = new NRCClient(config.uri)
 168          const events = await client.sync(filters, undefined, timeoutMs + 5000) // Add buffer to timeout
 169  
 170          // Update last connected
 171          this.update(config.id, {
 172            lastConnected: Date.now(),
 173            lastError: undefined
 174          })
 175  
 176          return { events, relayId: config.id }
 177        } catch (err) {
 178          const errorMsg = err instanceof Error ? err.message : String(err)
 179          console.warn(`[NRC Cache] Query failed for ${config.label}:`, errorMsg)
 180  
 181          // Update error state
 182          this.update(config.id, {
 183            lastError: errorMsg
 184          })
 185  
 186          throw err
 187        }
 188      })
 189  
 190      // Create timeout promise
 191      const timeoutPromise = new Promise<never>((_, reject) => {
 192        setTimeout(() => reject(new Error('Cache query timeout')), timeoutMs)
 193      })
 194  
 195      try {
 196        // Race: first successful response wins, or timeout
 197        const result = await Promise.race([Promise.any(queryPromises), timeoutPromise])
 198  
 199        if (result && 'events' in result) {
 200          console.log(
 201            `[NRC Cache] Got ${result.events.length} events from cache relay in <${timeoutMs}ms`
 202          )
 203          return {
 204            events: result.events,
 205            fromCache: true,
 206            relayId: result.relayId
 207          }
 208        }
 209      } catch (err) {
 210        // All queries failed or timed out
 211        console.log('[NRC Cache] No cache relay responded in time')
 212      }
 213  
 214      return { events: [], fromCache: false }
 215    }
 216  
 217    /**
 218     * Queue an event for background push to cache relays
 219     *
 220     * Events are batched and pushed in the background to avoid
 221     * blocking the main thread.
 222     */
 223    queueEventForPush(event: Event): void {
 224      // Skip if already seen
 225      if (this.seenEventIds.has(event.id)) {
 226        return
 227      }
 228      this.seenEventIds.add(event.id)
 229  
 230      // Add to queue
 231      this.pushQueue.push(event)
 232  
 233      // Schedule batch push if not already scheduled
 234      if (!this.pushTimeout && !this.pushInProgress) {
 235        this.pushTimeout = setTimeout(() => {
 236          this.pushTimeout = null
 237          this.processPushQueue()
 238        }, PUSH_DEBOUNCE_MS)
 239      }
 240    }
 241  
 242    /**
 243     * Queue multiple events for background push
 244     */
 245    queueEventsForPush(events: Event[]): void {
 246      for (const event of events) {
 247        this.queueEventForPush(event)
 248      }
 249    }
 250  
 251    /**
 252     * Process the push queue in batches
 253     */
 254    private async processPushQueue(): Promise<void> {
 255      if (this.pushQueue.length === 0 || this.pushInProgress) {
 256        return
 257      }
 258  
 259      const pushRelays = this.getPushRelays()
 260      if (pushRelays.length === 0) {
 261        // No push relays configured, clear the queue
 262        this.pushQueue = []
 263        return
 264      }
 265  
 266      this.pushInProgress = true
 267  
 268      // Take a batch from the queue
 269      const batch = this.pushQueue.splice(0, MAX_PUSH_BATCH_SIZE)
 270      console.log(`[NRC Cache] Pushing ${batch.length} events to ${pushRelays.length} cache relays`)
 271  
 272      // Push to all configured relays in parallel
 273      const pushPromises = pushRelays.map(async (config) => {
 274        try {
 275          const client = new NRCClient(config.uri)
 276          const sentCount = await client.sendEvents(batch, (progress: SyncProgress) => {
 277            // Optional: track progress
 278            if (progress.phase === 'error') {
 279              console.warn(`[NRC Cache] Push error to ${config.label}: ${progress.message}`)
 280            }
 281          })
 282  
 283          console.log(`[NRC Cache] Pushed ${sentCount}/${batch.length} events to ${config.label}`)
 284  
 285          // Update last connected
 286          this.update(config.id, {
 287            lastConnected: Date.now(),
 288            lastError: undefined
 289          })
 290  
 291          return sentCount
 292        } catch (err) {
 293          const errorMsg = err instanceof Error ? err.message : String(err)
 294          console.warn(`[NRC Cache] Push failed to ${config.label}:`, errorMsg)
 295  
 296          // Update error state
 297          this.update(config.id, {
 298            lastError: errorMsg
 299          })
 300  
 301          return 0
 302        }
 303      })
 304  
 305      await Promise.allSettled(pushPromises)
 306  
 307      this.pushInProgress = false
 308  
 309      // If there are more events in the queue, schedule another batch
 310      if (this.pushQueue.length > 0) {
 311        this.pushTimeout = setTimeout(() => {
 312          this.pushTimeout = null
 313          this.processPushQueue()
 314        }, PUSH_DEBOUNCE_MS)
 315      }
 316    }
 317  
 318    /**
 319     * Test connection to a cache relay
 320     */
 321    async testConnection(
 322      uri: string,
 323      onProgress?: (progress: SyncProgress) => void
 324    ): Promise<boolean> {
 325      try {
 326        const client = new NRCClient(uri)
 327        // Request just one profile event to test the full round-trip
 328        const events = await client.sync([{ kinds: [0], limit: 1 }], onProgress, 15000)
 329        console.log(`[NRC Cache] Test connection successful, received ${events.length} events`)
 330        return true
 331      } catch (err) {
 332        console.error('[NRC Cache] Test connection failed:', err)
 333        throw err
 334      }
 335    }
 336  
 337    /**
 338     * Clear the seen event IDs cache
 339     * Call this periodically to prevent memory growth
 340     */
 341    clearSeenCache(): void {
 342      // Keep a reasonable size limit
 343      if (this.seenEventIds.size > 10000) {
 344        this.seenEventIds.clear()
 345      }
 346    }
 347  
 348    /**
 349     * Get push queue status
 350     */
 351    getPushQueueStatus(): { queueSize: number; inProgress: boolean } {
 352      return {
 353        queueSize: this.pushQueue.length,
 354        inProgress: this.pushInProgress
 355      }
 356    }
 357  }
 358  
 359  // Singleton instance
 360  const instance = new NRCCacheRelayService()
 361  export default instance
 362  
 363  export { NRCCacheRelayService }
 364