nrc-client.service.ts raw
1 /**
2 * NRC (Nostr Relay Connect) Client Service
3 *
4 * Connects to a remote NRC listener and syncs events.
5 * Uses the nostr+relayconnect:// URI scheme to establish encrypted
6 * communication through a rendezvous relay.
7 */
8
9 import { Event, Filter } from 'nostr-tools'
10 import * as nip44 from 'nostr-tools/nip44'
11 import * as utils from '@noble/curves/abstract/utils'
12 import { finalizeEvent } from 'nostr-tools'
13 import {
14 KIND_NRC_REQUEST,
15 KIND_NRC_RESPONSE,
16 RequestMessage,
17 ResponseMessage,
18 ParsedConnectionURI,
19 EventManifestEntry
20 } from './nrc-types'
21 import { parseConnectionURI, deriveConversationKey } from './nrc-uri'
22
23 /**
24 * Generate a random subscription ID
25 */
26 function generateSubId(): string {
27 const bytes = crypto.getRandomValues(new Uint8Array(8))
28 return utils.bytesToHex(bytes)
29 }
30
31 /**
32 * Generate a random session ID
33 */
34 function generateSessionId(): string {
35 return crypto.randomUUID()
36 }
37
38 /**
39 * Sync progress callback
40 */
41 export interface SyncProgress {
42 phase: 'connecting' | 'requesting' | 'receiving' | 'sending' | 'complete' | 'error'
43 eventsReceived: number
44 eventsSent?: number
45 message?: string
46 }
47
48 /**
49 * Remote connection state
50 */
51 export interface RemoteConnection {
52 id: string
53 uri: string
54 label: string
55 relayPubkey: string
56 rendezvousUrl: string
57 lastSync?: number
58 eventCount?: number
59 }
60
61 // Chunk buffer for reassembling large messages
62 interface ChunkBuffer {
63 chunks: Map<number, string>
64 total: number
65 receivedAt: number
66 }
67
68 // Default sync timeout: 60 seconds
69 const DEFAULT_SYNC_TIMEOUT = 60000
70
71 /**
72 * NRC Client for connecting to remote devices
73 */
74 export class NRCClient {
75 private uri: ParsedConnectionURI
76 private ws: WebSocket | null = null
77 private sessionId: string
78 private connected = false
79 private subId: string | null = null
80 private pendingEvents: Event[] = []
81 private onProgress?: (progress: SyncProgress) => void
82 private resolveSync?: (events: Event[]) => void
83 private rejectSync?: (error: Error) => void
84 private chunkBuffers: Map<string, ChunkBuffer> = new Map()
85 private syncTimeout: ReturnType<typeof setTimeout> | null = null
86 private lastActivityTime: number = 0
87
88 constructor(connectionUri: string) {
89 this.uri = parseConnectionURI(connectionUri)
90 this.sessionId = generateSessionId()
91 }
92
93 /**
94 * Get the relay pubkey this client connects to
95 */
96 getRelayPubkey(): string {
97 return this.uri.relayPubkey
98 }
99
100 /**
101 * Get the rendezvous URL
102 */
103 getRendezvousUrl(): string {
104 return this.uri.rendezvousUrl
105 }
106
107 /**
108 * Connect to the rendezvous relay and sync events
109 */
110 async sync(
111 filters: Filter[],
112 onProgress?: (progress: SyncProgress) => void,
113 timeout: number = DEFAULT_SYNC_TIMEOUT
114 ): Promise<Event[]> {
115 this.onProgress = onProgress
116 this.pendingEvents = []
117 this.chunkBuffers.clear()
118 this.lastActivityTime = Date.now()
119
120 return new Promise<Event[]>((resolve, reject) => {
121 this.resolveSync = resolve
122 this.rejectSync = reject
123
124 // Set up sync timeout
125 this.syncTimeout = setTimeout(() => {
126 const timeSinceActivity = Date.now() - this.lastActivityTime
127 if (timeSinceActivity > 30000) {
128 // No activity for 30s, likely stalled
129 console.error('[NRC Client] Sync timeout - no activity for 30s')
130 this.disconnect()
131 reject(new Error('Sync timeout - connection stalled'))
132 } else {
133 // Still receiving data, extend timeout
134 console.log('[NRC Client] Sync still active, extending timeout')
135 this.syncTimeout = setTimeout(() => {
136 this.disconnect()
137 reject(new Error('Sync timeout'))
138 }, timeout)
139 }
140 }, timeout)
141
142 this.connect()
143 .then(() => {
144 this.sendREQ(filters)
145 })
146 .catch((err) => {
147 this.clearSyncTimeout()
148 reject(err)
149 })
150 })
151 }
152
153 // State for IDS request
154 private idsMode = false
155 private resolveIDs?: (manifest: EventManifestEntry[]) => void
156 private rejectIDs?: (error: Error) => void
157
158 // State for sending events
159 private sendingEvents = false
160 private eventsSentCount = 0
161 private eventsToSend: Event[] = []
162 private resolveSend?: (count: number) => void
163
164 /**
165 * Request event IDs from remote (for diffing)
166 */
167 async requestIDs(
168 filters: Filter[],
169 onProgress?: (progress: SyncProgress) => void,
170 timeout: number = DEFAULT_SYNC_TIMEOUT
171 ): Promise<EventManifestEntry[]> {
172 this.onProgress = onProgress
173 this.chunkBuffers.clear()
174 this.lastActivityTime = Date.now()
175 this.idsMode = true
176
177 return new Promise<EventManifestEntry[]>((resolve, reject) => {
178 this.resolveIDs = resolve
179 this.rejectIDs = reject
180
181 this.syncTimeout = setTimeout(() => {
182 this.disconnect()
183 reject(new Error('IDS request timeout'))
184 }, timeout)
185
186 this.connect()
187 .then(() => {
188 this.sendIDSRequest(filters)
189 })
190 .catch((err) => {
191 this.clearSyncTimeout()
192 reject(err)
193 })
194 })
195 }
196
197 /**
198 * Send IDS request
199 */
200 private sendIDSRequest(filters: Filter[]): void {
201 if (!this.ws || !this.connected) {
202 this.rejectIDs?.(new Error('Not connected'))
203 return
204 }
205
206 this.onProgress?.({
207 phase: 'requesting',
208 eventsReceived: 0,
209 message: 'Requesting event IDs...'
210 })
211
212 this.subId = generateSubId()
213
214 const request: RequestMessage = {
215 type: 'IDS',
216 payload: ['IDS', this.subId, ...filters]
217 }
218
219 this.sendEncryptedRequest(request).catch((err) => {
220 console.error('[NRC Client] Failed to send IDS:', err)
221 this.rejectIDs?.(err)
222 })
223 }
224
225 /**
226 * Send events to remote device
227 */
228 async sendEvents(
229 events: Event[],
230 onProgress?: (progress: SyncProgress) => void,
231 timeout: number = DEFAULT_SYNC_TIMEOUT
232 ): Promise<number> {
233 if (events.length === 0) return 0
234
235 this.onProgress = onProgress
236 this.chunkBuffers.clear()
237 this.lastActivityTime = Date.now()
238 this.sendingEvents = true
239 this.eventsSentCount = 0
240 this.eventsToSend = [...events]
241
242 return new Promise<number>((resolve, reject) => {
243 this.resolveSend = resolve
244
245 this.syncTimeout = setTimeout(() => {
246 this.disconnect()
247 reject(new Error('Send events timeout'))
248 }, timeout)
249
250 this.connect()
251 .then(() => {
252 this.sendNextEvent()
253 })
254 .catch((err) => {
255 this.clearSyncTimeout()
256 reject(err)
257 })
258 })
259 }
260
261 /**
262 * Send the next event in the queue
263 */
264 private sendNextEvent(): void {
265 if (this.eventsToSend.length === 0) {
266 // All done
267 this.clearSyncTimeout()
268 this.onProgress?.({
269 phase: 'complete',
270 eventsReceived: 0,
271 eventsSent: this.eventsSentCount,
272 message: `Sent ${this.eventsSentCount} events`
273 })
274 this.resolveSend?.(this.eventsSentCount)
275 this.disconnect()
276 return
277 }
278
279 const event = this.eventsToSend.shift()!
280 this.onProgress?.({
281 phase: 'sending',
282 eventsReceived: 0,
283 eventsSent: this.eventsSentCount,
284 message: `Sending event ${this.eventsSentCount + 1}...`
285 })
286
287 const request: RequestMessage = {
288 type: 'EVENT',
289 payload: ['EVENT', event]
290 }
291
292 this.sendEncryptedRequest(request).catch((err) => {
293 console.error('[NRC Client] Failed to send EVENT:', err)
294 // Continue with next event even if this one failed
295 this.sendNextEvent()
296 })
297 }
298
299 /**
300 * Clear the sync timeout
301 */
302 private clearSyncTimeout(): void {
303 if (this.syncTimeout) {
304 clearTimeout(this.syncTimeout)
305 this.syncTimeout = null
306 }
307 }
308
309 /**
310 * Update last activity time (called when receiving data)
311 */
312 private updateActivity(): void {
313 this.lastActivityTime = Date.now()
314 }
315
316 /**
317 * Connect to the rendezvous relay
318 */
319 private async connect(): Promise<void> {
320 if (this.connected) return
321
322 this.onProgress?.({
323 phase: 'connecting',
324 eventsReceived: 0,
325 message: 'Connecting to rendezvous relay...'
326 })
327
328 const relayUrl = this.uri.rendezvousUrl
329
330 return new Promise<void>((resolve, reject) => {
331 // Normalize WebSocket URL
332 let wsUrl = relayUrl
333 if (relayUrl.startsWith('http://')) {
334 wsUrl = 'ws://' + relayUrl.slice(7)
335 } else if (relayUrl.startsWith('https://')) {
336 wsUrl = 'wss://' + relayUrl.slice(8)
337 } else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {
338 wsUrl = 'wss://' + relayUrl
339 }
340
341 console.log(`[NRC Client] Connecting to: ${wsUrl}`)
342
343 const ws = new WebSocket(wsUrl)
344
345 const timeout = setTimeout(() => {
346 ws.close()
347 reject(new Error('Connection timeout'))
348 }, 10000)
349
350 ws.onopen = () => {
351 clearTimeout(timeout)
352 this.ws = ws
353 this.connected = true
354
355 // Subscribe to responses for our client pubkey
356 const responseSubId = generateSubId()
357 const clientPubkey = this.uri.clientPubkey
358
359 if (!clientPubkey) {
360 reject(new Error('Client pubkey not available'))
361 return
362 }
363
364 ws.send(
365 JSON.stringify([
366 'REQ',
367 responseSubId,
368 {
369 kinds: [KIND_NRC_RESPONSE],
370 '#p': [clientPubkey],
371 since: Math.floor(Date.now() / 1000) - 60
372 }
373 ])
374 )
375
376 console.log(`[NRC Client] Connected, subscribed for responses to ${clientPubkey.slice(0, 8)}...`)
377 resolve()
378 }
379
380 ws.onerror = (error) => {
381 clearTimeout(timeout)
382 console.error('[NRC Client] WebSocket error:', error)
383 reject(new Error('WebSocket error'))
384 }
385
386 ws.onclose = () => {
387 this.connected = false
388 this.ws = null
389 console.log('[NRC Client] WebSocket closed')
390 }
391
392 ws.onmessage = (event) => {
393 this.handleMessage(event.data)
394 }
395 })
396 }
397
398 /**
399 * Send a REQ message to the remote listener
400 */
401 private sendREQ(filters: Filter[]): void {
402 if (!this.ws || !this.connected) {
403 this.rejectSync?.(new Error('Not connected'))
404 return
405 }
406
407 console.log(`[NRC Client] Sending REQ to listener pubkey: ${this.uri.relayPubkey?.slice(0, 8)}...`)
408 console.log(`[NRC Client] Our client pubkey: ${this.uri.clientPubkey?.slice(0, 8)}...`)
409 console.log(`[NRC Client] Filters:`, JSON.stringify(filters))
410
411 this.onProgress?.({
412 phase: 'requesting',
413 eventsReceived: 0,
414 message: 'Requesting events...'
415 })
416
417 this.subId = generateSubId()
418
419 const request: RequestMessage = {
420 type: 'REQ',
421 payload: ['REQ', this.subId, ...filters]
422 }
423
424 this.sendEncryptedRequest(request).catch((err) => {
425 console.error('[NRC Client] Failed to send request:', err)
426 this.rejectSync?.(err)
427 })
428 }
429
430 /**
431 * Send an encrypted request to the remote listener
432 */
433 private async sendEncryptedRequest(request: RequestMessage): Promise<void> {
434 if (!this.ws) {
435 throw new Error('Not connected')
436 }
437
438 if (!this.uri.clientPrivkey || !this.uri.clientPubkey) {
439 throw new Error('Missing keys')
440 }
441
442 const plaintext = JSON.stringify(request)
443
444 // Derive conversation key
445 const conversationKey = deriveConversationKey(
446 this.uri.clientPrivkey,
447 this.uri.relayPubkey
448 )
449
450 const encrypted = nip44.v2.encrypt(plaintext, conversationKey)
451
452 // Build the request event
453 const unsignedEvent = {
454 kind: KIND_NRC_REQUEST,
455 content: encrypted,
456 tags: [
457 ['p', this.uri.relayPubkey],
458 ['encryption', 'nip44_v2'],
459 ['session', this.sessionId]
460 ],
461 created_at: Math.floor(Date.now() / 1000),
462 pubkey: this.uri.clientPubkey
463 }
464
465 const signedEvent = finalizeEvent(unsignedEvent, this.uri.clientPrivkey)
466
467 // Send to rendezvous relay
468 this.ws.send(JSON.stringify(['EVENT', signedEvent]))
469 console.log(`[NRC Client] Sent encrypted REQ, event id: ${signedEvent.id?.slice(0, 8)}..., p-tag: ${this.uri.relayPubkey?.slice(0, 8)}...`)
470 }
471
472 /**
473 * Handle incoming WebSocket messages
474 */
475 private handleMessage(data: string): void {
476 try {
477 const msg = JSON.parse(data)
478 if (!Array.isArray(msg)) return
479
480 const [type, ...rest] = msg
481
482 if (type === 'EVENT') {
483 const [subId, event] = rest as [string, Event]
484 console.log(`[NRC Client] Received EVENT on sub ${subId}, kind ${event.kind}, from ${event.pubkey?.slice(0, 8)}...`)
485
486 if (event.kind === KIND_NRC_RESPONSE) {
487 // Check p-tag to see who it's addressed to
488 const pTag = event.tags.find(t => t[0] === 'p')?.[1]
489 console.log(`[NRC Client] Response p-tag: ${pTag?.slice(0, 8)}..., our pubkey: ${this.uri.clientPubkey?.slice(0, 8)}...`)
490 this.handleResponse(event)
491 } else {
492 console.log(`[NRC Client] Ignoring event kind ${event.kind}`)
493 }
494 } else if (type === 'EOSE') {
495 console.log('[NRC Client] Received EOSE from relay subscription')
496 } else if (type === 'OK') {
497 console.log('[NRC Client] Event published:', rest)
498 } else if (type === 'NOTICE') {
499 console.log('[NRC Client] Relay notice:', rest[0])
500 }
501 } catch (err) {
502 console.error('[NRC Client] Failed to parse message:', err)
503 }
504 }
505
506 /**
507 * Handle a response event from the remote listener
508 */
509 private handleResponse(event: Event): void {
510 console.log(`[NRC Client] Attempting to decrypt response from ${event.pubkey?.slice(0, 8)}...`)
511
512 this.decryptAndProcessResponse(event).catch((err) => {
513 console.error('[NRC Client] Failed to handle response:', err)
514 })
515 }
516
517 /**
518 * Decrypt and process a response event
519 */
520 private async decryptAndProcessResponse(event: Event): Promise<void> {
521 if (!this.uri.clientPrivkey) {
522 throw new Error('Missing private key for decryption')
523 }
524
525 const conversationKey = deriveConversationKey(
526 this.uri.clientPrivkey,
527 this.uri.relayPubkey
528 )
529 const plaintext = nip44.v2.decrypt(event.content, conversationKey)
530
531 const response: ResponseMessage = JSON.parse(plaintext)
532 console.log(`[NRC Client] Received response: ${response.type}`)
533
534 // Handle chunked messages
535 if (response.type === 'CHUNK') {
536 this.handleChunk(response)
537 return
538 }
539
540 this.processResponse(response)
541 }
542
543 /**
544 * Handle a chunk message and reassemble when complete
545 */
546 private handleChunk(response: ResponseMessage): void {
547 const chunk = response.payload[0] as {
548 type: 'CHUNK'
549 messageId: string
550 index: number
551 total: number
552 data: string
553 }
554
555 if (!chunk || chunk.type !== 'CHUNK') {
556 console.error('[NRC Client] Invalid chunk message')
557 return
558 }
559
560 const { messageId, index, total, data } = chunk
561
562 // Get or create buffer for this message
563 let buffer = this.chunkBuffers.get(messageId)
564 if (!buffer) {
565 buffer = {
566 chunks: new Map(),
567 total,
568 receivedAt: Date.now()
569 }
570 this.chunkBuffers.set(messageId, buffer)
571 }
572
573 // Store the chunk
574 buffer.chunks.set(index, data)
575 this.updateActivity()
576 console.log(`[NRC Client] Received chunk ${index + 1}/${total} for message ${messageId.slice(0, 8)}`)
577
578 // Check if we have all chunks
579 if (buffer.chunks.size === buffer.total) {
580 // Reassemble the message
581 const parts: string[] = []
582 for (let i = 0; i < buffer.total; i++) {
583 const part = buffer.chunks.get(i)
584 if (!part) {
585 console.error(`[NRC Client] Missing chunk ${i} for message ${messageId}`)
586 this.chunkBuffers.delete(messageId)
587 return
588 }
589 parts.push(part)
590 }
591
592 // Decode from base64
593 const encoded = parts.join('')
594 try {
595 const plaintext = decodeURIComponent(escape(atob(encoded)))
596 const reassembled: ResponseMessage = JSON.parse(plaintext)
597 console.log(`[NRC Client] Reassembled chunked message: ${reassembled.type}`)
598 this.processResponse(reassembled)
599 } catch (err) {
600 console.error('[NRC Client] Failed to reassemble chunked message:', err)
601 }
602
603 // Clean up buffer
604 this.chunkBuffers.delete(messageId)
605 }
606
607 // Clean up old buffers (older than 60 seconds)
608 const now = Date.now()
609 for (const [id, buf] of this.chunkBuffers) {
610 if (now - buf.receivedAt > 60000) {
611 console.warn(`[NRC Client] Discarding stale chunk buffer: ${id}`)
612 this.chunkBuffers.delete(id)
613 }
614 }
615 }
616
617 /**
618 * Process a complete response message
619 */
620 private processResponse(response: ResponseMessage): void {
621 this.updateActivity()
622
623 switch (response.type) {
624 case 'EVENT': {
625 // Extract the event from payload: ["EVENT", subId, eventObject]
626 const [, , syncedEvent] = response.payload as [string, string, Event]
627 if (syncedEvent) {
628 this.pendingEvents.push(syncedEvent)
629 this.onProgress?.({
630 phase: 'receiving',
631 eventsReceived: this.pendingEvents.length,
632 message: `Received ${this.pendingEvents.length} events...`
633 })
634 }
635 break
636 }
637 case 'EOSE': {
638 console.log(`[NRC Client] EOSE received, got ${this.pendingEvents.length} events`)
639 this.complete()
640 break
641 }
642 case 'NOTICE': {
643 const [, message] = response.payload as [string, string]
644 console.log(`[NRC Client] Notice: ${message}`)
645 this.onProgress?.({
646 phase: 'error',
647 eventsReceived: this.pendingEvents.length,
648 message: message
649 })
650 break
651 }
652 case 'OK': {
653 // Response to EVENT publish
654 if (this.sendingEvents) {
655 const [, eventId, success, message] = response.payload as [string, string, boolean, string]
656 if (success) {
657 this.eventsSentCount++
658 console.log(`[NRC Client] Event ${eventId?.slice(0, 8)} stored successfully`)
659 } else {
660 console.warn(`[NRC Client] Event ${eventId?.slice(0, 8)} failed: ${message}`)
661 }
662 // Send next event
663 this.sendNextEvent()
664 }
665 break
666 }
667 case 'IDS': {
668 // Response to IDS request - contains event manifest
669 if (this.idsMode) {
670 const [, , manifest] = response.payload as [string, string, EventManifestEntry[]]
671 console.log(`[NRC Client] Received IDS response with ${manifest?.length || 0} entries`)
672 this.clearSyncTimeout()
673 this.resolveIDs?.(manifest || [])
674 this.disconnect()
675 }
676 break
677 }
678 default:
679 console.log(`[NRC Client] Unknown response type: ${response.type}`)
680 }
681 }
682
683 /**
684 * Complete the sync operation
685 */
686 private complete(): void {
687 this.clearSyncTimeout()
688
689 this.onProgress?.({
690 phase: 'complete',
691 eventsReceived: this.pendingEvents.length,
692 message: `Synced ${this.pendingEvents.length} events`
693 })
694
695 this.resolveSync?.(this.pendingEvents)
696 this.disconnect()
697 }
698
699 /**
700 * Disconnect from the rendezvous relay
701 */
702 disconnect(): void {
703 this.clearSyncTimeout()
704
705 if (this.ws) {
706 this.ws.close()
707 this.ws = null
708 }
709 this.connected = false
710 }
711 }
712
713 /**
714 * Sync events from a remote device
715 *
716 * @param connectionUri - The nostr+relayconnect:// URI
717 * @param filters - Nostr filters for events to sync
718 * @param onProgress - Optional progress callback
719 * @returns Array of synced events
720 */
721 export async function syncFromRemote(
722 connectionUri: string,
723 filters: Filter[],
724 onProgress?: (progress: SyncProgress) => void
725 ): Promise<Event[]> {
726 const client = new NRCClient(connectionUri)
727 return client.sync(filters, onProgress)
728 }
729
730 /**
731 * Test connection to a remote device
732 * Performs a minimal sync (kind 0 with limit 1) to verify the connection works
733 *
734 * @param connectionUri - The nostr+relayconnect:// URI
735 * @param onProgress - Optional progress callback
736 * @returns true if connection successful
737 */
738 export async function testConnection(
739 connectionUri: string,
740 onProgress?: (progress: SyncProgress) => void
741 ): Promise<boolean> {
742 const client = new NRCClient(connectionUri)
743 try {
744 // Request just one profile event to test the full round-trip
745 const events = await client.sync(
746 [{ kinds: [0], limit: 1 }],
747 onProgress,
748 15000 // 15 second timeout for test
749 )
750 console.log(`[NRC] Test connection successful, received ${events.length} events`)
751 return true
752 } catch (err) {
753 console.error('[NRC] Test connection failed:', err)
754 throw err
755 }
756 }
757
758 /**
759 * Request event IDs from a remote device (for diffing)
760 *
761 * @param connectionUri - The nostr+relayconnect:// URI
762 * @param filters - Filters to match events
763 * @param onProgress - Optional progress callback
764 * @returns Array of event manifest entries (id, kind, created_at, d)
765 */
766 export async function requestRemoteIDs(
767 connectionUri: string,
768 filters: Filter[],
769 onProgress?: (progress: SyncProgress) => void
770 ): Promise<EventManifestEntry[]> {
771 const client = new NRCClient(connectionUri)
772 return client.requestIDs(filters, onProgress)
773 }
774
775 /**
776 * Send events to a remote device
777 *
778 * @param connectionUri - The nostr+relayconnect:// URI
779 * @param events - Events to send
780 * @param onProgress - Optional progress callback
781 * @returns Number of events successfully stored
782 */
783 export async function sendEventsToRemote(
784 connectionUri: string,
785 events: Event[],
786 onProgress?: (progress: SyncProgress) => void
787 ): Promise<number> {
788 const client = new NRCClient(connectionUri)
789 return client.sendEvents(events, onProgress)
790 }
791