relay-list-cache.service.ts raw
1 import { RelayList } from '@/domain/relay/RelayList'
2 import { Event, kinds } from 'nostr-tools'
3 import client from './client.service'
4 import indexedDb from './indexed-db.service'
5
6 /**
7 * Cache entry for a user's relay list
8 */
9 export interface CachedRelayList {
10 pubkey: string
11 read: string[]
12 write: string[]
13 fetchedAt: number
14 event?: Event
15 }
16
17 /**
18 * LRU Cache implementation for in-memory caching
19 */
20 class LRUCache<K, V> {
21 private cache = new Map<K, V>()
22
23 constructor(private maxSize: number) {}
24
25 get(key: K): V | undefined {
26 const value = this.cache.get(key)
27 if (value !== undefined) {
28 // Move to end (most recently used)
29 this.cache.delete(key)
30 this.cache.set(key, value)
31 }
32 return value
33 }
34
35 set(key: K, value: V): void {
36 if (this.cache.has(key)) {
37 this.cache.delete(key)
38 } else if (this.cache.size >= this.maxSize) {
39 // Remove oldest (first) entry
40 const firstKey = this.cache.keys().next().value
41 if (firstKey !== undefined) {
42 this.cache.delete(firstKey)
43 }
44 }
45 this.cache.set(key, value)
46 }
47
48 has(key: K): boolean {
49 return this.cache.has(key)
50 }
51
52 delete(key: K): boolean {
53 return this.cache.delete(key)
54 }
55
56 clear(): void {
57 this.cache.clear()
58 }
59 }
60
61 /**
62 * RelayListCacheService
63 *
64 * Caches NIP-65 relay lists for other users to enable proper relay selection
65 * without falling back to hardcoded relay lists.
66 *
67 * Features:
68 * - In-memory LRU cache for fast access
69 * - IndexedDB persistence for cache survival across sessions
70 * - Batch fetching for efficiency
71 * - Stale-while-revalidate pattern
72 */
73 class RelayListCacheService {
74 private memoryCache: LRUCache<string, CachedRelayList>
75 private pendingFetches = new Map<string, Promise<CachedRelayList | null>>()
76
77 // Cache entries older than this are considered stale
78 private staleAfterMs = 24 * 60 * 60 * 1000 // 24 hours
79
80 constructor() {
81 this.memoryCache = new LRUCache(500) // Keep up to 500 relay lists in memory
82 }
83
84 /**
85 * Get a cached relay list for a pubkey
86 * Returns null if not in cache
87 */
88 async getRelayList(pubkey: string): Promise<CachedRelayList | null> {
89 // Check memory cache first
90 const memCached = this.memoryCache.get(pubkey)
91 if (memCached) {
92 return memCached
93 }
94
95 // Check IndexedDB
96 const event = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)
97 if (event) {
98 const cached = this.eventToCachedRelayList(event)
99 this.memoryCache.set(pubkey, cached)
100 return cached
101 }
102
103 return null
104 }
105
106 /**
107 * Check if a cached relay list is stale
108 */
109 isStale(cached: CachedRelayList): boolean {
110 return Date.now() - cached.fetchedAt > this.staleAfterMs
111 }
112
113 /**
114 * Fetch a relay list from the network
115 * Uses relay hints if provided
116 */
117 async fetchRelayList(
118 pubkey: string,
119 hints?: string[]
120 ): Promise<CachedRelayList | null> {
121 // Dedupe concurrent fetches for the same pubkey
122 const pending = this.pendingFetches.get(pubkey)
123 if (pending) {
124 return pending
125 }
126
127 const fetchPromise = this.doFetchRelayList(pubkey, hints)
128 this.pendingFetches.set(pubkey, fetchPromise)
129
130 try {
131 return await fetchPromise
132 } finally {
133 this.pendingFetches.delete(pubkey)
134 }
135 }
136
137 private async doFetchRelayList(
138 pubkey: string,
139 _hints?: string[]
140 ): Promise<CachedRelayList | null> {
141 try {
142 // TODO: Use hints when fetching from network (requires adding hints support to fetchRelayListEvent)
143 const event = await client.fetchRelayListEvent(pubkey)
144 if (!event) {
145 // Cache the miss to avoid repeated fetches
146 await indexedDb.putNullReplaceableEvent(pubkey, kinds.RelayList)
147 return null
148 }
149
150 // Store in both caches
151 await indexedDb.putReplaceableEvent(event)
152 const cached = this.eventToCachedRelayList(event)
153 this.memoryCache.set(pubkey, cached)
154
155 return cached
156 } catch (error) {
157 console.warn(`Failed to fetch relay list for ${pubkey}:`, error)
158 return null
159 }
160 }
161
162 /**
163 * Fetch relay lists for multiple pubkeys efficiently
164 */
165 async fetchRelayLists(
166 pubkeys: string[],
167 hints?: string[]
168 ): Promise<Map<string, CachedRelayList>> {
169 const results = new Map<string, CachedRelayList>()
170 const toFetch: string[] = []
171
172 // Check caches first
173 for (const pubkey of pubkeys) {
174 const cached = await this.getRelayList(pubkey)
175 if (cached && !this.isStale(cached)) {
176 results.set(pubkey, cached)
177 } else {
178 toFetch.push(pubkey)
179 }
180 }
181
182 // Batch fetch the rest
183 if (toFetch.length > 0) {
184 const fetchPromises = toFetch.map((pubkey) =>
185 this.fetchRelayList(pubkey, hints).then((result) => ({
186 pubkey,
187 result
188 }))
189 )
190
191 const fetched = await Promise.all(fetchPromises)
192 for (const { pubkey, result } of fetched) {
193 if (result) {
194 results.set(pubkey, result)
195 }
196 }
197 }
198
199 return results
200 }
201
202 /**
203 * Get combined write relays for multiple recipients
204 * Used when publishing events that mention/reply to others
205 */
206 async getWriteRelaysForRecipients(pubkeys: string[]): Promise<string[]> {
207 const relayLists = await this.fetchRelayLists(pubkeys)
208 const writeRelays = new Set<string>()
209
210 for (const cached of relayLists.values()) {
211 for (const relay of cached.write) {
212 writeRelays.add(relay)
213 }
214 }
215
216 return Array.from(writeRelays)
217 }
218
219 /**
220 * Store a relay list that was fetched elsewhere (opportunistic caching)
221 */
222 async setRelayList(event: Event): Promise<void> {
223 if (event.kind !== kinds.RelayList) {
224 return
225 }
226
227 await indexedDb.putReplaceableEvent(event)
228 const cached = this.eventToCachedRelayList(event)
229 this.memoryCache.set(event.pubkey, cached)
230 }
231
232 /**
233 * Convert a kind 10002 event to a CachedRelayList
234 */
235 private eventToCachedRelayList(event: Event): CachedRelayList {
236 const relayList = RelayList.fromEvent(event)
237 return {
238 pubkey: event.pubkey,
239 read: relayList.getReadUrls(),
240 write: relayList.getWriteUrls(),
241 fetchedAt: Date.now(),
242 event
243 }
244 }
245
246 /**
247 * Clear all cached relay lists
248 */
249 clearCache(): void {
250 this.memoryCache.clear()
251 }
252 }
253
254 const relayListCacheService = new RelayListCacheService()
255 export default relayListCacheService
256