relay-discovery.service.ts raw

   1  /**
   2   * Relay Discovery Service
   3   *
   4   * Discovers all known relays on the Nostr network by:
   5   * 1. Starting with bootstrap relays
   6   * 2. Querying for NIP-65 relay list events (kind 10002)
   7   * 3. Extracting relay URLs and doing a second round of queries
   8   * 4. Compiling a frequency-sorted list of all discovered relays
   9   */
  10  
  11  import { DEPLOYMENT } from '@/constants'
  12  import { kinds, Event as NEvent } from 'nostr-tools'
  13  import client from './client.service'
  14  
  15  /** Bootstrap relays to seed the discovery */
  16  const BOOTSTRAP_RELAYS = DEPLOYMENT.defaultRelay
  17    ? [DEPLOYMENT.defaultRelay]
  18    : [
  19        'wss://relay.damus.io/',
  20        'wss://relay.nostr.band/',
  21        'wss://nos.lol/',
  22        'wss://relay.snort.social/',
  23        'wss://purplepag.es/'
  24      ]
  25  
  26  /** Cache key for localStorage */
  27  const CACHE_KEY = 'relay-discovery-cache'
  28  const CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
  29  
  30  export type RelayFrequency = {
  31    url: string
  32    count: number
  33    percentage: number
  34  }
  35  
  36  export type DiscoveryProgress = {
  37    phase: 'idle' | 'phase1' | 'phase2' | 'complete'
  38    relaysQueried: number
  39    totalRelays: number
  40    eventsFound: number
  41    uniqueRelaysFound: number
  42  }
  43  
  44  export type DiscoveryResult = {
  45    relays: RelayFrequency[]
  46    totalEvents: number
  47    timestamp: number
  48  }
  49  
  50  type CachedResult = DiscoveryResult & {
  51    cachedAt: number
  52  }
  53  
  54  class RelayDiscoveryService {
  55    private abortController: AbortController | null = null
  56  
  57    /**
  58     * Normalize a relay URL for consistent comparison
  59     */
  60    private normalizeUrl(url: string): string {
  61      try {
  62        const parsed = new URL(url.trim())
  63        // Ensure wss:// protocol and trailing slash
  64        let normalized = parsed.href
  65        if (!normalized.endsWith('/')) {
  66          normalized += '/'
  67        }
  68        return normalized.toLowerCase()
  69      } catch {
  70        return ''
  71      }
  72    }
  73  
  74    /**
  75     * Extract relay URLs from a NIP-65 relay list event
  76     */
  77    private extractRelaysFromEvent(event: NEvent): string[] {
  78      const relays: string[] = []
  79      for (const tag of event.tags) {
  80        if (tag[0] === 'r' && tag[1]) {
  81          const normalized = this.normalizeUrl(tag[1])
  82          if (normalized && normalized.startsWith('wss://')) {
  83            relays.push(normalized)
  84          }
  85        }
  86      }
  87      return relays
  88    }
  89  
  90    /**
  91     * Query relays for NIP-65 relay list events
  92     */
  93    private async queryRelayLists(
  94      relayUrls: string[],
  95      onProgress?: (queried: number, total: number, events: number) => void
  96    ): Promise<{ events: NEvent[]; relayFrequency: Map<string, number> }> {
  97      const events: NEvent[] = []
  98      const relayFrequency = new Map<string, number>()
  99      const seenEventIds = new Set<string>()
 100  
 101      // Query all relays in parallel with timeout
 102      const promises = relayUrls.map(async (relayUrl, index) => {
 103        if (this.abortController?.signal.aborted) return
 104  
 105        try {
 106          const relayEvents = await client.fetchEvents(
 107            [relayUrl],
 108            {
 109              kinds: [kinds.RelayList],
 110              limit: 500
 111            }
 112          )
 113  
 114          for (const event of relayEvents) {
 115            if (seenEventIds.has(event.id)) continue
 116            seenEventIds.add(event.id)
 117            events.push(event)
 118  
 119            // Extract and count relays
 120            const extractedRelays = this.extractRelaysFromEvent(event)
 121            for (const relay of extractedRelays) {
 122              relayFrequency.set(relay, (relayFrequency.get(relay) || 0) + 1)
 123            }
 124          }
 125  
 126          onProgress?.(index + 1, relayUrls.length, events.length)
 127        } catch (err) {
 128          console.warn(`[RelayDiscovery] Failed to query ${relayUrl}:`, err)
 129        }
 130      })
 131  
 132      await Promise.allSettled(promises)
 133      return { events, relayFrequency }
 134    }
 135  
 136    /**
 137     * Run the full two-phase discovery process
 138     */
 139    async discover(
 140      onProgress?: (progress: DiscoveryProgress) => void
 141    ): Promise<DiscoveryResult> {
 142      // Cancel any existing discovery
 143      this.abort()
 144      this.abortController = new AbortController()
 145  
 146      const allRelayFrequency = new Map<string, number>()
 147      let totalEvents = 0
 148  
 149      // Phase 1: Query bootstrap relays
 150      onProgress?.({
 151        phase: 'phase1',
 152        relaysQueried: 0,
 153        totalRelays: BOOTSTRAP_RELAYS.length,
 154        eventsFound: 0,
 155        uniqueRelaysFound: 0
 156      })
 157  
 158      const phase1Result = await this.queryRelayLists(
 159        BOOTSTRAP_RELAYS,
 160        (queried, total, events) => {
 161          onProgress?.({
 162            phase: 'phase1',
 163            relaysQueried: queried,
 164            totalRelays: total,
 165            eventsFound: events,
 166            uniqueRelaysFound: allRelayFrequency.size
 167          })
 168        }
 169      )
 170  
 171      if (this.abortController.signal.aborted) {
 172        return this.getEmptyResult()
 173      }
 174  
 175      // Merge phase 1 results
 176      for (const [relay, count] of phase1Result.relayFrequency) {
 177        allRelayFrequency.set(relay, (allRelayFrequency.get(relay) || 0) + count)
 178      }
 179      totalEvents += phase1Result.events.length
 180  
 181      // Phase 2: Query discovered relays (top 50 by frequency)
 182      const discoveredRelays = Array.from(allRelayFrequency.entries())
 183        .sort((a, b) => b[1] - a[1])
 184        .slice(0, 50)
 185        .map(([url]) => url)
 186        .filter(url => !BOOTSTRAP_RELAYS.includes(url))
 187  
 188      if (discoveredRelays.length > 0 && !this.abortController.signal.aborted) {
 189        onProgress?.({
 190          phase: 'phase2',
 191          relaysQueried: 0,
 192          totalRelays: discoveredRelays.length,
 193          eventsFound: totalEvents,
 194          uniqueRelaysFound: allRelayFrequency.size
 195        })
 196  
 197        const phase2Result = await this.queryRelayLists(
 198          discoveredRelays,
 199          (queried, total, events) => {
 200            onProgress?.({
 201              phase: 'phase2',
 202              relaysQueried: queried,
 203              totalRelays: total,
 204              eventsFound: totalEvents + events,
 205              uniqueRelaysFound: allRelayFrequency.size
 206            })
 207          }
 208        )
 209  
 210        // Merge phase 2 results
 211        for (const [relay, count] of phase2Result.relayFrequency) {
 212          allRelayFrequency.set(relay, (allRelayFrequency.get(relay) || 0) + count)
 213        }
 214        totalEvents += phase2Result.events.length
 215      }
 216  
 217      // Build final sorted result
 218      const relays: RelayFrequency[] = Array.from(allRelayFrequency.entries())
 219        .map(([url, count]) => ({
 220          url,
 221          count,
 222          percentage: Math.round((count / totalEvents) * 100 * 10) / 10
 223        }))
 224        .sort((a, b) => b.count - a.count)
 225  
 226      const result: DiscoveryResult = {
 227        relays,
 228        totalEvents,
 229        timestamp: Date.now()
 230      }
 231  
 232      // Cache the result
 233      this.saveToCache(result)
 234  
 235      onProgress?.({
 236        phase: 'complete',
 237        relaysQueried: BOOTSTRAP_RELAYS.length + discoveredRelays.length,
 238        totalRelays: BOOTSTRAP_RELAYS.length + discoveredRelays.length,
 239        eventsFound: totalEvents,
 240        uniqueRelaysFound: relays.length
 241      })
 242  
 243      return result
 244    }
 245  
 246    /**
 247     * Abort an in-progress discovery
 248     */
 249    abort(): void {
 250      if (this.abortController) {
 251        this.abortController.abort()
 252        this.abortController = null
 253      }
 254    }
 255  
 256    /**
 257     * Get cached discovery result if still valid
 258     */
 259    getCachedResult(): DiscoveryResult | null {
 260      try {
 261        const cached = localStorage.getItem(CACHE_KEY)
 262        if (!cached) return null
 263  
 264        const parsed: CachedResult = JSON.parse(cached)
 265        if (Date.now() - parsed.cachedAt > CACHE_TTL) {
 266          localStorage.removeItem(CACHE_KEY)
 267          return null
 268        }
 269  
 270        return {
 271          relays: parsed.relays,
 272          totalEvents: parsed.totalEvents,
 273          timestamp: parsed.timestamp
 274        }
 275      } catch {
 276        return null
 277      }
 278    }
 279  
 280    /**
 281     * Save result to cache
 282     */
 283    private saveToCache(result: DiscoveryResult): void {
 284      try {
 285        const cached: CachedResult = {
 286          ...result,
 287          cachedAt: Date.now()
 288        }
 289        localStorage.setItem(CACHE_KEY, JSON.stringify(cached))
 290      } catch (err) {
 291        console.warn('[RelayDiscovery] Failed to cache result:', err)
 292      }
 293    }
 294  
 295    /**
 296     * Get the top N relay URLs from the cached discovery result.
 297     * Returns empty array if no cached result exists or cache has expired.
 298     */
 299    getTopRelays(n: number): string[] {
 300      const cached = this.getCachedResult()
 301      if (!cached || cached.relays.length === 0) {
 302        return []
 303      }
 304      return cached.relays.slice(0, n).map(r => r.url)
 305    }
 306  
 307    /**
 308     * Get discovered relays in batches for progressive querying.
 309     * Returns arrays of relay URLs, each batch of `batchSize`,
 310     * starting after `offset` relays, up to `maxTotal` total relays.
 311     * Excludes relays in the `exclude` set.
 312     */
 313    getRelayBatches(
 314      batchSize: number,
 315      offset: number,
 316      maxTotal: number,
 317      exclude: Set<string>
 318    ): string[][] {
 319      const cached = this.getCachedResult()
 320      if (!cached || cached.relays.length === 0) {
 321        return []
 322      }
 323  
 324      const available = cached.relays
 325        .map(r => r.url)
 326        .filter(url => !exclude.has(url))
 327        .slice(offset, offset + maxTotal)
 328  
 329      const batches: string[][] = []
 330      for (let i = 0; i < available.length; i += batchSize) {
 331        batches.push(available.slice(i, i + batchSize))
 332      }
 333      return batches
 334    }
 335  
 336    /**
 337     * Run discovery if no valid cached result exists.
 338     * Intended for background auto-discovery on app startup.
 339     */
 340    async discoverIfNeeded(): Promise<void> {
 341      const cached = this.getCachedResult()
 342      if (cached && cached.relays.length > 0) {
 343        return // Cache is fresh, no discovery needed
 344      }
 345  
 346      console.log('[RelayDiscovery] No cached result, starting background discovery...')
 347      try {
 348        await this.discover()
 349        console.log('[RelayDiscovery] Background discovery complete')
 350      } catch (err) {
 351        console.warn('[RelayDiscovery] Background discovery failed:', err)
 352      }
 353    }
 354  
 355    /**
 356     * Clear the cache
 357     */
 358    clearCache(): void {
 359      localStorage.removeItem(CACHE_KEY)
 360    }
 361  
 362    /**
 363     * Export relay list as plaintext (one URL per line)
 364     */
 365    exportAsPlaintext(relays: RelayFrequency[]): string {
 366      return relays.map(r => r.url).join('\n')
 367    }
 368  
 369    /**
 370     * Download relay list as a text file
 371     */
 372    downloadAsFile(relays: RelayFrequency[], filename = 'nostr-relays.txt'): void {
 373      const content = this.exportAsPlaintext(relays)
 374      const blob = new Blob([content], { type: 'text/plain' })
 375      const url = URL.createObjectURL(blob)
 376      const a = document.createElement('a')
 377      a.href = url
 378      a.download = filename
 379      document.body.appendChild(a)
 380      a.click()
 381      document.body.removeChild(a)
 382      URL.revokeObjectURL(url)
 383    }
 384  
 385    private getEmptyResult(): DiscoveryResult {
 386      return {
 387        relays: [],
 388        totalEvents: 0,
 389        timestamp: Date.now()
 390      }
 391    }
 392  }
 393  
 394  const relayDiscoveryService = new RelayDiscoveryService()
 395  export default relayDiscoveryService
 396