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