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