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