nrc-cache-relay.service.ts raw
1 /**
2 * NRC Cache Relay Service
3 *
4 * Manages NRC connections that act as "cache relays":
5 * - Query first with 400ms timeout before falling back to regular relays
6 * - Push loaded events to cache relays in background
7 *
8 * Cache relays are private relays accessible via NRC that can store
9 * a user's viewed events for faster subsequent access.
10 */
11
12 import { Event, Filter } from 'nostr-tools'
13 import { NRCClient, SyncProgress } from './nrc-client.service'
14
15 /**
16 * Configuration for an NRC cache relay
17 */
18 export interface NRCCacheRelayConfig {
19 id: string
20 uri: string // nostr+relayconnect:// URI
21 label: string
22 enabled: boolean
23 queryFirst: boolean // Query before regular relays with 400ms timeout
24 pushEvents: boolean // Push loaded events in background
25 lastConnected?: number
26 lastError?: string
27 }
28
29 /**
30 * Cache relay query result
31 */
32 export interface CacheRelayQueryResult {
33 events: Event[]
34 fromCache: boolean
35 relayId?: string
36 }
37
38 // Storage key for cache relay configs
39 const STORAGE_KEY = 'nrc-cache-relays'
40
41 // Default query timeout for cache relays (400ms)
42 const DEFAULT_CACHE_QUERY_TIMEOUT = 400
43
44 // Maximum events per push batch
45 const MAX_PUSH_BATCH_SIZE = 50
46
47 // Debounce time for push batching (ms)
48 const PUSH_DEBOUNCE_MS = 100
49
50 class NRCCacheRelayService extends EventTarget {
51 private configs: NRCCacheRelayConfig[] = []
52 private pushQueue: Event[] = []
53 private pushInProgress = false
54 private pushTimeout: ReturnType<typeof setTimeout> | null = null
55 private seenEventIds: Set<string> = new Set()
56
57 constructor() {
58 super()
59 this.loadConfigs()
60 }
61
62 /**
63 * Load configurations from storage
64 */
65 private loadConfigs(): void {
66 try {
67 const stored = window.localStorage.getItem(STORAGE_KEY)
68 if (stored) {
69 this.configs = JSON.parse(stored)
70 }
71 } catch (err) {
72 console.error('[NRC Cache] Failed to load configs:', err)
73 this.configs = []
74 }
75 }
76
77 /**
78 * Save configurations to storage
79 */
80 private saveConfigs(): void {
81 try {
82 window.localStorage.setItem(STORAGE_KEY, JSON.stringify(this.configs))
83 } catch (err) {
84 console.error('[NRC Cache] Failed to save configs:', err)
85 }
86 }
87
88 /**
89 * Get all cache relay configurations
90 */
91 getAll(): NRCCacheRelayConfig[] {
92 return [...this.configs]
93 }
94
95 /**
96 * Get enabled cache relays that should be queried first
97 */
98 getQueryFirstRelays(): NRCCacheRelayConfig[] {
99 return this.configs.filter((c) => c.enabled && c.queryFirst)
100 }
101
102 /**
103 * Get enabled cache relays that should receive pushed events
104 */
105 getPushRelays(): NRCCacheRelayConfig[] {
106 return this.configs.filter((c) => c.enabled && c.pushEvents)
107 }
108
109 /**
110 * Add a new cache relay configuration
111 */
112 add(config: Omit<NRCCacheRelayConfig, 'id'>): NRCCacheRelayConfig {
113 const newConfig: NRCCacheRelayConfig = {
114 ...config,
115 id: crypto.randomUUID()
116 }
117 this.configs.push(newConfig)
118 this.saveConfigs()
119 this.dispatchEvent(new CustomEvent('configsChanged'))
120 return newConfig
121 }
122
123 /**
124 * Update a cache relay configuration
125 */
126 update(id: string, updates: Partial<NRCCacheRelayConfig>): void {
127 const index = this.configs.findIndex((c) => c.id === id)
128 if (index >= 0) {
129 this.configs[index] = { ...this.configs[index], ...updates }
130 this.saveConfigs()
131 this.dispatchEvent(new CustomEvent('configsChanged'))
132 }
133 }
134
135 /**
136 * Remove a cache relay configuration
137 */
138 remove(id: string): void {
139 this.configs = this.configs.filter((c) => c.id !== id)
140 this.saveConfigs()
141 this.dispatchEvent(new CustomEvent('configsChanged'))
142 }
143
144 /**
145 * Query cache relays with timeout
146 *
147 * Returns events from the first cache relay that responds within the timeout.
148 * If no cache relay responds in time, returns an empty array.
149 *
150 * @param filters - Nostr filters to query
151 * @param timeoutMs - Maximum time to wait for response (default: 400ms)
152 * @returns Events from cache relay, or empty array if none respond in time
153 */
154 async queryWithTimeout(
155 filters: Filter[],
156 timeoutMs: number = DEFAULT_CACHE_QUERY_TIMEOUT
157 ): Promise<CacheRelayQueryResult> {
158 const queryRelays = this.getQueryFirstRelays()
159
160 if (queryRelays.length === 0) {
161 return { events: [], fromCache: false }
162 }
163
164 // Race all cache relays against a timeout
165 const queryPromises = queryRelays.map(async (config) => {
166 try {
167 const client = new NRCClient(config.uri)
168 const events = await client.sync(filters, undefined, timeoutMs + 5000) // Add buffer to timeout
169
170 // Update last connected
171 this.update(config.id, {
172 lastConnected: Date.now(),
173 lastError: undefined
174 })
175
176 return { events, relayId: config.id }
177 } catch (err) {
178 const errorMsg = err instanceof Error ? err.message : String(err)
179 console.warn(`[NRC Cache] Query failed for ${config.label}:`, errorMsg)
180
181 // Update error state
182 this.update(config.id, {
183 lastError: errorMsg
184 })
185
186 throw err
187 }
188 })
189
190 // Create timeout promise
191 const timeoutPromise = new Promise<never>((_, reject) => {
192 setTimeout(() => reject(new Error('Cache query timeout')), timeoutMs)
193 })
194
195 try {
196 // Race: first successful response wins, or timeout
197 const result = await Promise.race([Promise.any(queryPromises), timeoutPromise])
198
199 if (result && 'events' in result) {
200 console.log(
201 `[NRC Cache] Got ${result.events.length} events from cache relay in <${timeoutMs}ms`
202 )
203 return {
204 events: result.events,
205 fromCache: true,
206 relayId: result.relayId
207 }
208 }
209 } catch (err) {
210 // All queries failed or timed out
211 console.log('[NRC Cache] No cache relay responded in time')
212 }
213
214 return { events: [], fromCache: false }
215 }
216
217 /**
218 * Queue an event for background push to cache relays
219 *
220 * Events are batched and pushed in the background to avoid
221 * blocking the main thread.
222 */
223 queueEventForPush(event: Event): void {
224 // Skip if already seen
225 if (this.seenEventIds.has(event.id)) {
226 return
227 }
228 this.seenEventIds.add(event.id)
229
230 // Add to queue
231 this.pushQueue.push(event)
232
233 // Schedule batch push if not already scheduled
234 if (!this.pushTimeout && !this.pushInProgress) {
235 this.pushTimeout = setTimeout(() => {
236 this.pushTimeout = null
237 this.processPushQueue()
238 }, PUSH_DEBOUNCE_MS)
239 }
240 }
241
242 /**
243 * Queue multiple events for background push
244 */
245 queueEventsForPush(events: Event[]): void {
246 for (const event of events) {
247 this.queueEventForPush(event)
248 }
249 }
250
251 /**
252 * Process the push queue in batches
253 */
254 private async processPushQueue(): Promise<void> {
255 if (this.pushQueue.length === 0 || this.pushInProgress) {
256 return
257 }
258
259 const pushRelays = this.getPushRelays()
260 if (pushRelays.length === 0) {
261 // No push relays configured, clear the queue
262 this.pushQueue = []
263 return
264 }
265
266 this.pushInProgress = true
267
268 // Take a batch from the queue
269 const batch = this.pushQueue.splice(0, MAX_PUSH_BATCH_SIZE)
270 console.log(`[NRC Cache] Pushing ${batch.length} events to ${pushRelays.length} cache relays`)
271
272 // Push to all configured relays in parallel
273 const pushPromises = pushRelays.map(async (config) => {
274 try {
275 const client = new NRCClient(config.uri)
276 const sentCount = await client.sendEvents(batch, (progress: SyncProgress) => {
277 // Optional: track progress
278 if (progress.phase === 'error') {
279 console.warn(`[NRC Cache] Push error to ${config.label}: ${progress.message}`)
280 }
281 })
282
283 console.log(`[NRC Cache] Pushed ${sentCount}/${batch.length} events to ${config.label}`)
284
285 // Update last connected
286 this.update(config.id, {
287 lastConnected: Date.now(),
288 lastError: undefined
289 })
290
291 return sentCount
292 } catch (err) {
293 const errorMsg = err instanceof Error ? err.message : String(err)
294 console.warn(`[NRC Cache] Push failed to ${config.label}:`, errorMsg)
295
296 // Update error state
297 this.update(config.id, {
298 lastError: errorMsg
299 })
300
301 return 0
302 }
303 })
304
305 await Promise.allSettled(pushPromises)
306
307 this.pushInProgress = false
308
309 // If there are more events in the queue, schedule another batch
310 if (this.pushQueue.length > 0) {
311 this.pushTimeout = setTimeout(() => {
312 this.pushTimeout = null
313 this.processPushQueue()
314 }, PUSH_DEBOUNCE_MS)
315 }
316 }
317
318 /**
319 * Test connection to a cache relay
320 */
321 async testConnection(
322 uri: string,
323 onProgress?: (progress: SyncProgress) => void
324 ): Promise<boolean> {
325 try {
326 const client = new NRCClient(uri)
327 // Request just one profile event to test the full round-trip
328 const events = await client.sync([{ kinds: [0], limit: 1 }], onProgress, 15000)
329 console.log(`[NRC Cache] Test connection successful, received ${events.length} events`)
330 return true
331 } catch (err) {
332 console.error('[NRC Cache] Test connection failed:', err)
333 throw err
334 }
335 }
336
337 /**
338 * Clear the seen event IDs cache
339 * Call this periodically to prevent memory growth
340 */
341 clearSeenCache(): void {
342 // Keep a reasonable size limit
343 if (this.seenEventIds.size > 10000) {
344 this.seenEventIds.clear()
345 }
346 }
347
348 /**
349 * Get push queue status
350 */
351 getPushQueueStatus(): { queueSize: number; inProgress: boolean } {
352 return {
353 queueSize: this.pushQueue.length,
354 inProgress: this.pushInProgress
355 }
356 }
357 }
358
359 // Singleton instance
360 const instance = new NRCCacheRelayService()
361 export default instance
362
363 export { NRCCacheRelayService }
364