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