@xtr-dev/rondevu-client 0.13.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/rondevu.js CHANGED
@@ -1,9 +1,53 @@
1
1
  import { RondevuAPI } from './api.js';
2
+ // ICE server presets
3
+ export const ICE_SERVER_PRESETS = {
4
+ 'ipv4-turn': [
5
+ { urls: 'stun:57.129.61.67:3478' },
6
+ {
7
+ urls: [
8
+ 'turn:57.129.61.67:3478?transport=tcp',
9
+ 'turn:57.129.61.67:3478?transport=udp',
10
+ ],
11
+ username: 'webrtcuser',
12
+ credential: 'supersecretpassword'
13
+ }
14
+ ],
15
+ 'hostname-turns': [
16
+ { urls: 'stun:turn.share.fish:3478' },
17
+ {
18
+ urls: [
19
+ 'turns:turn.share.fish:5349?transport=tcp',
20
+ 'turns:turn.share.fish:5349?transport=udp',
21
+ 'turn:turn.share.fish:3478?transport=tcp',
22
+ 'turn:turn.share.fish:3478?transport=udp',
23
+ ],
24
+ username: 'webrtcuser',
25
+ credential: 'supersecretpassword'
26
+ }
27
+ ],
28
+ 'google-stun': [
29
+ { urls: 'stun:stun.l.google.com:19302' },
30
+ { urls: 'stun:stun1.l.google.com:19302' }
31
+ ],
32
+ 'relay-only': [
33
+ { urls: 'stun:57.129.61.67:3478' },
34
+ {
35
+ urls: [
36
+ 'turn:57.129.61.67:3478?transport=tcp',
37
+ 'turn:57.129.61.67:3478?transport=udp',
38
+ ],
39
+ username: 'webrtcuser',
40
+ credential: 'supersecretpassword',
41
+ // @ts-expect-error - iceTransportPolicy is valid but not in RTCIceServer type
42
+ iceTransportPolicy: 'relay'
43
+ }
44
+ ]
45
+ };
2
46
  /**
3
47
  * Rondevu - Complete WebRTC signaling client
4
48
  *
5
49
  * Provides a unified API for:
6
- * - Username claiming with Ed25519 signatures
50
+ * - Implicit username claiming (auto-claimed on first authenticated request)
7
51
  * - Service publishing with automatic signature generation
8
52
  * - Service discovery (direct, random, paginated)
9
53
  * - WebRTC signaling (offer/answer exchange, ICE relay)
@@ -11,114 +55,152 @@ import { RondevuAPI } from './api.js';
11
55
  *
12
56
  * @example
13
57
  * ```typescript
14
- * // Initialize (generates keypair automatically)
15
- * const rondevu = new Rondevu({
58
+ * // Create and initialize Rondevu instance with preset ICE servers
59
+ * const rondevu = await Rondevu.connect({
16
60
  * apiUrl: 'https://signal.example.com',
17
61
  * username: 'alice',
62
+ * iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
18
63
  * })
19
64
  *
20
- * await rondevu.initialize()
21
- *
22
- * // Claim username (one time)
23
- * await rondevu.claimUsername()
65
+ * // Or use custom ICE servers
66
+ * const rondevu2 = await Rondevu.connect({
67
+ * apiUrl: 'https://signal.example.com',
68
+ * username: 'bob',
69
+ * iceServers: [
70
+ * { urls: 'stun:stun.l.google.com:19302' },
71
+ * { urls: 'turn:turn.example.com:3478', username: 'user', credential: 'pass' }
72
+ * ]
73
+ * })
24
74
  *
25
- * // Publish a service
26
- * const publishedService = await rondevu.publishService({
27
- * serviceFqn: 'chat:1.0.0@alice',
28
- * offers: [{ sdp: offerSdp }],
29
- * ttl: 300000,
75
+ * // Publish a service with automatic offer management
76
+ * await rondevu.publishService({
77
+ * service: 'chat:2.0.0',
78
+ * maxOffers: 5, // Maintain up to 5 concurrent offers
79
+ * offerFactory: async (rtcConfig) => {
80
+ * const pc = new RTCPeerConnection(rtcConfig)
81
+ * const dc = pc.createDataChannel('chat')
82
+ * const offer = await pc.createOffer()
83
+ * await pc.setLocalDescription(offer)
84
+ * return { pc, dc, offer }
85
+ * }
30
86
  * })
31
87
  *
32
- * // Discover a service
33
- * const service = await rondevu.getService('chat:1.0.0@bob')
88
+ * // Start accepting connections (auto-fills offers and polls)
89
+ * await rondevu.startFilling()
90
+ *
91
+ * // Access active connections
92
+ * for (const offer of rondevu.getActiveOffers()) {
93
+ * offer.dc?.addEventListener('message', (e) => console.log(e.data))
94
+ * }
34
95
  *
35
- * // Post answer
36
- * await rondevu.postOfferAnswer(service.serviceFqn, service.offerId, answerSdp)
96
+ * // Stop when done
97
+ * rondevu.stopFilling()
37
98
  * ```
38
99
  */
39
100
  export class Rondevu {
40
- constructor(options) {
41
- this.keypair = null;
101
+ constructor(apiUrl, username, keypair, api, iceServers, cryptoAdapter, batchingOptions, debugEnabled = false) {
42
102
  this.usernameClaimed = false;
43
- this.username = options.username;
44
- this.keypair = options.keypair || null;
45
- this.api = new RondevuAPI(options.apiUrl, options.credentials);
46
- console.log('[Rondevu] Constructor called:', {
103
+ // Service management
104
+ this.currentService = null;
105
+ this.maxOffers = 0;
106
+ this.offerFactory = null;
107
+ this.ttl = Rondevu.DEFAULT_TTL_MS;
108
+ this.activeOffers = new Map();
109
+ // Polling
110
+ this.filling = false;
111
+ this.pollingInterval = null;
112
+ this.lastPollTimestamp = 0;
113
+ this.apiUrl = apiUrl;
114
+ this.username = username;
115
+ this.keypair = keypair;
116
+ this.api = api;
117
+ this.iceServers = iceServers;
118
+ this.cryptoAdapter = cryptoAdapter;
119
+ this.batchingOptions = batchingOptions;
120
+ this.debugEnabled = debugEnabled;
121
+ this.debug('Instance created:', {
47
122
  username: this.username,
48
- hasKeypair: !!this.keypair,
49
- publicKey: this.keypair?.publicKey
123
+ publicKey: this.keypair.publicKey,
124
+ hasIceServers: iceServers.length > 0,
125
+ batchingEnabled: batchingOptions !== false
50
126
  });
51
127
  }
52
- // ============================================
53
- // Initialization
54
- // ============================================
55
128
  /**
56
- * Initialize the service - generates keypair if not provided
57
- * Call this before using other methods
129
+ * Internal debug logging - only logs if debug mode is enabled
58
130
  */
59
- async initialize() {
60
- console.log('[Rondevu] Initialize called, hasKeypair:', !!this.keypair);
61
- if (!this.keypair) {
62
- console.log('[Rondevu] Generating new keypair...');
63
- this.keypair = await RondevuAPI.generateKeypair();
64
- console.log('[Rondevu] Generated keypair, publicKey:', this.keypair.publicKey);
131
+ debug(message, ...args) {
132
+ if (this.debugEnabled) {
133
+ console.log(`[Rondevu] ${message}`, ...args);
134
+ }
135
+ }
136
+ /**
137
+ * Create and initialize a Rondevu client
138
+ *
139
+ * @example
140
+ * ```typescript
141
+ * const rondevu = await Rondevu.connect({
142
+ * apiUrl: 'https://api.ronde.vu',
143
+ * username: 'alice'
144
+ * })
145
+ * ```
146
+ */
147
+ static async connect(options) {
148
+ const username = options.username || Rondevu.generateAnonymousUsername();
149
+ // Handle preset string or custom array
150
+ let iceServers;
151
+ if (typeof options.iceServers === 'string') {
152
+ iceServers = ICE_SERVER_PRESETS[options.iceServers];
65
153
  }
66
154
  else {
67
- console.log('[Rondevu] Using existing keypair, publicKey:', this.keypair.publicKey);
155
+ iceServers = options.iceServers || [
156
+ { urls: 'stun:stun.l.google.com:19302' }
157
+ ];
68
158
  }
69
- // Register with API if no credentials provided
70
- if (!this.api['credentials']) {
71
- const credentials = await this.api.register();
72
- this.api.setCredentials(credentials);
159
+ if (options.debug) {
160
+ console.log('[Rondevu] Connecting:', {
161
+ username,
162
+ hasKeypair: !!options.keypair,
163
+ iceServers: iceServers.length,
164
+ batchingEnabled: options.batching !== false
165
+ });
73
166
  }
167
+ // Generate keypair if not provided
168
+ let keypair = options.keypair;
169
+ if (!keypair) {
170
+ if (options.debug)
171
+ console.log('[Rondevu] Generating new keypair...');
172
+ keypair = await RondevuAPI.generateKeypair(options.cryptoAdapter);
173
+ if (options.debug)
174
+ console.log('[Rondevu] Generated keypair, publicKey:', keypair.publicKey);
175
+ }
176
+ else {
177
+ if (options.debug)
178
+ console.log('[Rondevu] Using existing keypair, publicKey:', keypair.publicKey);
179
+ }
180
+ // Create API instance
181
+ const api = new RondevuAPI(options.apiUrl, username, keypair, options.cryptoAdapter, options.batching);
182
+ if (options.debug)
183
+ console.log('[Rondevu] Created API instance');
184
+ return new Rondevu(options.apiUrl, username, keypair, api, iceServers, options.cryptoAdapter, options.batching, options.debug || false);
74
185
  }
75
- // ============================================
76
- // Username Management
77
- // ============================================
78
186
  /**
79
- * Claim the username with Ed25519 signature
80
- * Should be called once before publishing services
187
+ * Generate an anonymous username with timestamp and random component
81
188
  */
82
- async claimUsername() {
83
- if (!this.keypair) {
84
- throw new Error('Not initialized. Call initialize() first.');
85
- }
86
- // Check if username is already claimed
87
- const check = await this.api.checkUsername(this.username);
88
- if (!check.available) {
89
- // Verify it's claimed by us
90
- if (check.publicKey === this.keypair.publicKey) {
91
- this.usernameClaimed = true;
92
- return;
93
- }
94
- throw new Error(`Username "${this.username}" is already claimed by another user`);
95
- }
96
- // Generate signature for username claim
97
- const message = `claim:${this.username}:${Date.now()}`;
98
- const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey);
99
- // Claim the username
100
- await this.api.claimUsername(this.username, this.keypair.publicKey, signature, message);
101
- this.usernameClaimed = true;
189
+ static generateAnonymousUsername() {
190
+ const timestamp = Date.now().toString(36);
191
+ const random = Array.from(crypto.getRandomValues(new Uint8Array(3)))
192
+ .map(b => b.toString(16).padStart(2, '0')).join('');
193
+ return `anon-${timestamp}-${random}`;
102
194
  }
195
+ // ============================================
196
+ // Username Management
197
+ // ============================================
103
198
  /**
104
199
  * Check if username has been claimed (checks with server)
105
200
  */
106
201
  async isUsernameClaimed() {
107
- if (!this.keypair) {
108
- return false;
109
- }
110
202
  try {
111
- const check = await this.api.checkUsername(this.username);
112
- // Debug logging
113
- console.log('[Rondevu] Username check:', {
114
- username: this.username,
115
- available: check.available,
116
- serverPublicKey: check.publicKey,
117
- localPublicKey: this.keypair.publicKey,
118
- match: check.publicKey === this.keypair.publicKey
119
- });
120
- // Username is claimed if it's not available and owned by our public key
121
- const claimed = !check.available && check.publicKey === this.keypair.publicKey;
203
+ const claimed = await this.api.isUsernameClaimed();
122
204
  // Update internal flag to match server state
123
205
  this.usernameClaimed = claimed;
124
206
  return claimed;
@@ -132,29 +214,352 @@ export class Rondevu {
132
214
  // Service Publishing
133
215
  // ============================================
134
216
  /**
135
- * Publish a service with automatic signature generation
217
+ * Default offer factory - creates a simple data channel connection
218
+ */
219
+ async defaultOfferFactory(rtcConfig) {
220
+ const pc = new RTCPeerConnection(rtcConfig);
221
+ const dc = pc.createDataChannel('default');
222
+ const offer = await pc.createOffer();
223
+ await pc.setLocalDescription(offer);
224
+ return { pc, dc, offer };
225
+ }
226
+ /**
227
+ * Publish a service with automatic offer management
228
+ * Call startFilling() to begin accepting connections
229
+ *
230
+ * @example
231
+ * ```typescript
232
+ * await rondevu.publishService({
233
+ * service: 'chat:2.0.0',
234
+ * maxOffers: 5
235
+ * })
236
+ * await rondevu.startFilling()
237
+ * ```
136
238
  */
137
239
  async publishService(options) {
138
- if (!this.keypair) {
139
- throw new Error('Not initialized. Call initialize() first.');
140
- }
141
- if (!this.usernameClaimed) {
142
- throw new Error('Username not claimed. Call claimUsername() first or the server will reject the service.');
143
- }
144
- const { serviceFqn, offers, ttl } = options;
145
- // Generate signature for service publication
146
- const message = `publish:${this.username}:${serviceFqn}:${Date.now()}`;
147
- const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey);
148
- // Create service request
149
- const serviceRequest = {
150
- serviceFqn, // Must include @username
151
- offers,
152
- signature,
153
- message,
154
- ttl,
240
+ const { service, maxOffers, offerFactory, ttl } = options;
241
+ this.currentService = service;
242
+ this.maxOffers = maxOffers;
243
+ this.offerFactory = offerFactory || this.defaultOfferFactory.bind(this);
244
+ this.ttl = ttl || Rondevu.DEFAULT_TTL_MS;
245
+ this.debug(`Publishing service: ${service} with maxOffers: ${maxOffers}`);
246
+ this.usernameClaimed = true;
247
+ }
248
+ /**
249
+ * Set up ICE candidate handler to send candidates to the server
250
+ */
251
+ setupIceCandidateHandler(pc, serviceFqn, offerId) {
252
+ pc.onicecandidate = async (event) => {
253
+ if (event.candidate) {
254
+ try {
255
+ // Handle both browser and Node.js (wrtc) environments
256
+ // Browser: candidate.toJSON() exists
257
+ // Node.js wrtc: candidate is already a plain object
258
+ const candidateData = typeof event.candidate.toJSON === 'function'
259
+ ? event.candidate.toJSON()
260
+ : event.candidate;
261
+ await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData]);
262
+ }
263
+ catch (err) {
264
+ console.error('[Rondevu] Failed to send ICE candidate:', err);
265
+ }
266
+ }
267
+ };
268
+ }
269
+ /**
270
+ * Create a single offer and publish it to the server
271
+ */
272
+ async createOffer() {
273
+ if (!this.currentService || !this.offerFactory) {
274
+ throw new Error('Service not published. Call publishService() first.');
275
+ }
276
+ const rtcConfig = {
277
+ iceServers: this.iceServers
155
278
  };
279
+ this.debug('Creating new offer...');
280
+ // Create the offer using the factory
281
+ const { pc, dc, offer } = await this.offerFactory(rtcConfig);
282
+ // Auto-append username to service
283
+ const serviceFqn = `${this.currentService}@${this.username}`;
156
284
  // Publish to server
157
- return await this.api.publishService(serviceRequest);
285
+ const result = await this.api.publishService({
286
+ serviceFqn,
287
+ offers: [{ sdp: offer.sdp }],
288
+ ttl: this.ttl,
289
+ signature: '',
290
+ message: '',
291
+ });
292
+ const offerId = result.offers[0].offerId;
293
+ // Store active offer
294
+ this.activeOffers.set(offerId, {
295
+ offerId,
296
+ serviceFqn,
297
+ pc,
298
+ dc,
299
+ answered: false,
300
+ createdAt: Date.now()
301
+ });
302
+ this.debug(`Offer created: ${offerId}`);
303
+ // Set up ICE candidate handler
304
+ this.setupIceCandidateHandler(pc, serviceFqn, offerId);
305
+ // Monitor connection state
306
+ pc.onconnectionstatechange = () => {
307
+ this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`);
308
+ if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
309
+ this.activeOffers.delete(offerId);
310
+ this.fillOffers(); // Try to replace failed offer
311
+ }
312
+ };
313
+ }
314
+ /**
315
+ * Fill offers to reach maxOffers count
316
+ */
317
+ async fillOffers() {
318
+ if (!this.filling || !this.currentService)
319
+ return;
320
+ const currentCount = this.activeOffers.size;
321
+ const needed = this.maxOffers - currentCount;
322
+ this.debug(`Filling offers: current=${currentCount}, needed=${needed}`);
323
+ for (let i = 0; i < needed; i++) {
324
+ try {
325
+ await this.createOffer();
326
+ }
327
+ catch (err) {
328
+ console.error('[Rondevu] Failed to create offer:', err);
329
+ }
330
+ }
331
+ }
332
+ /**
333
+ * Poll for answers and ICE candidates (internal use for automatic offer management)
334
+ */
335
+ async pollInternal() {
336
+ if (!this.filling)
337
+ return;
338
+ try {
339
+ const result = await this.api.poll(this.lastPollTimestamp);
340
+ // Process answers
341
+ for (const answer of result.answers) {
342
+ const activeOffer = this.activeOffers.get(answer.offerId);
343
+ if (activeOffer && !activeOffer.answered) {
344
+ this.debug(`Received answer for offer ${answer.offerId}`);
345
+ await activeOffer.pc.setRemoteDescription({
346
+ type: 'answer',
347
+ sdp: answer.sdp
348
+ });
349
+ activeOffer.answered = true;
350
+ this.lastPollTimestamp = answer.answeredAt;
351
+ // Create replacement offer
352
+ this.fillOffers();
353
+ }
354
+ }
355
+ // Process ICE candidates
356
+ for (const [offerId, candidates] of Object.entries(result.iceCandidates)) {
357
+ const activeOffer = this.activeOffers.get(offerId);
358
+ if (activeOffer) {
359
+ const answererCandidates = candidates.filter(c => c.role === 'answerer');
360
+ for (const item of answererCandidates) {
361
+ if (item.candidate) {
362
+ await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate));
363
+ this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt);
364
+ }
365
+ }
366
+ }
367
+ }
368
+ }
369
+ catch (err) {
370
+ console.error('[Rondevu] Polling error:', err);
371
+ }
372
+ }
373
+ /**
374
+ * Start filling offers and polling for answers/ICE
375
+ * Call this after publishService() to begin accepting connections
376
+ */
377
+ async startFilling() {
378
+ if (this.filling) {
379
+ this.debug('Already filling');
380
+ return;
381
+ }
382
+ if (!this.currentService) {
383
+ throw new Error('No service published. Call publishService() first.');
384
+ }
385
+ this.debug('Starting offer filling and polling');
386
+ this.filling = true;
387
+ // Fill initial offers
388
+ await this.fillOffers();
389
+ // Start polling
390
+ this.pollingInterval = setInterval(() => {
391
+ this.pollInternal();
392
+ }, Rondevu.POLLING_INTERVAL_MS);
393
+ }
394
+ /**
395
+ * Stop filling offers and polling
396
+ * Closes all active peer connections
397
+ */
398
+ stopFilling() {
399
+ this.debug('Stopping offer filling and polling');
400
+ this.filling = false;
401
+ // Stop polling
402
+ if (this.pollingInterval) {
403
+ clearInterval(this.pollingInterval);
404
+ this.pollingInterval = null;
405
+ }
406
+ // Close all active connections
407
+ for (const [offerId, offer] of this.activeOffers.entries()) {
408
+ this.debug(`Closing offer ${offerId}`);
409
+ offer.dc?.close();
410
+ offer.pc.close();
411
+ }
412
+ this.activeOffers.clear();
413
+ }
414
+ /**
415
+ * Resolve the full service FQN from various input options
416
+ * Supports direct FQN, service+username, or service discovery
417
+ */
418
+ async resolveServiceFqn(options) {
419
+ const { serviceFqn, service, username } = options;
420
+ if (serviceFqn) {
421
+ return serviceFqn;
422
+ }
423
+ else if (service && username) {
424
+ return `${service}@${username}`;
425
+ }
426
+ else if (service) {
427
+ // Discovery mode - get random service
428
+ this.debug(`Discovering service: ${service}`);
429
+ const discovered = await this.discoverService(service);
430
+ return discovered.serviceFqn;
431
+ }
432
+ else {
433
+ throw new Error('Either serviceFqn or service must be provided');
434
+ }
435
+ }
436
+ /**
437
+ * Start polling for remote ICE candidates
438
+ * Returns the polling interval ID
439
+ */
440
+ startIcePolling(pc, serviceFqn, offerId) {
441
+ let lastIceTimestamp = 0;
442
+ return setInterval(async () => {
443
+ try {
444
+ const result = await this.api.getOfferIceCandidates(serviceFqn, offerId, lastIceTimestamp);
445
+ for (const item of result.candidates) {
446
+ if (item.candidate) {
447
+ await pc.addIceCandidate(new RTCIceCandidate(item.candidate));
448
+ lastIceTimestamp = item.createdAt;
449
+ }
450
+ }
451
+ }
452
+ catch (err) {
453
+ console.error('[Rondevu] Failed to poll ICE candidates:', err);
454
+ }
455
+ }, Rondevu.POLLING_INTERVAL_MS);
456
+ }
457
+ /**
458
+ * Automatically connect to a service (answerer side)
459
+ * Handles the entire connection flow: discovery, WebRTC setup, answer exchange, ICE candidates
460
+ *
461
+ * @example
462
+ * ```typescript
463
+ * // Connect to specific user
464
+ * const connection = await rondevu.connectToService({
465
+ * serviceFqn: 'chat:2.0.0@alice',
466
+ * onConnection: ({ dc, peerUsername }) => {
467
+ * console.log('Connected to', peerUsername)
468
+ * dc.addEventListener('message', (e) => console.log(e.data))
469
+ * dc.addEventListener('open', () => dc.send('Hello!'))
470
+ * }
471
+ * })
472
+ *
473
+ * // Discover random service
474
+ * const connection = await rondevu.connectToService({
475
+ * service: 'chat:2.0.0',
476
+ * onConnection: ({ dc, peerUsername }) => {
477
+ * console.log('Connected to', peerUsername)
478
+ * }
479
+ * })
480
+ * ```
481
+ */
482
+ async connectToService(options) {
483
+ const { onConnection, rtcConfig } = options;
484
+ // Validate inputs
485
+ if (options.serviceFqn !== undefined && typeof options.serviceFqn === 'string' && !options.serviceFqn.trim()) {
486
+ throw new Error('serviceFqn cannot be empty');
487
+ }
488
+ if (options.service !== undefined && typeof options.service === 'string' && !options.service.trim()) {
489
+ throw new Error('service cannot be empty');
490
+ }
491
+ if (options.username !== undefined && typeof options.username === 'string' && !options.username.trim()) {
492
+ throw new Error('username cannot be empty');
493
+ }
494
+ // Determine the full service FQN
495
+ const fqn = await this.resolveServiceFqn(options);
496
+ this.debug(`Connecting to service: ${fqn}`);
497
+ // 1. Get service offer
498
+ const serviceData = await this.api.getService(fqn);
499
+ this.debug(`Found service from @${serviceData.username}`);
500
+ // 2. Create RTCPeerConnection
501
+ const rtcConfiguration = rtcConfig || {
502
+ iceServers: this.iceServers
503
+ };
504
+ const pc = new RTCPeerConnection(rtcConfiguration);
505
+ // 3. Set up data channel handler (answerer receives it from offerer)
506
+ let dc = null;
507
+ const dataChannelPromise = new Promise((resolve) => {
508
+ pc.ondatachannel = (event) => {
509
+ this.debug('Data channel received from offerer');
510
+ dc = event.channel;
511
+ resolve(dc);
512
+ };
513
+ });
514
+ // 4. Set up ICE candidate exchange
515
+ this.setupIceCandidateHandler(pc, serviceData.serviceFqn, serviceData.offerId);
516
+ // 5. Poll for remote ICE candidates
517
+ const icePollInterval = this.startIcePolling(pc, serviceData.serviceFqn, serviceData.offerId);
518
+ // 6. Set remote description
519
+ await pc.setRemoteDescription({
520
+ type: 'offer',
521
+ sdp: serviceData.sdp
522
+ });
523
+ // 7. Create and send answer
524
+ const answer = await pc.createAnswer();
525
+ await pc.setLocalDescription(answer);
526
+ await this.api.answerOffer(serviceData.serviceFqn, serviceData.offerId, answer.sdp);
527
+ // 8. Wait for data channel to be established
528
+ dc = await dataChannelPromise;
529
+ // Create connection context
530
+ const context = {
531
+ pc,
532
+ dc,
533
+ serviceFqn: serviceData.serviceFqn,
534
+ offerId: serviceData.offerId,
535
+ peerUsername: serviceData.username
536
+ };
537
+ // 9. Set up connection state monitoring
538
+ pc.onconnectionstatechange = () => {
539
+ this.debug(`Connection state: ${pc.connectionState}`);
540
+ if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
541
+ clearInterval(icePollInterval);
542
+ }
543
+ };
544
+ // 10. Wait for data channel to open and call onConnection
545
+ if (dc.readyState === 'open') {
546
+ this.debug('Data channel already open');
547
+ if (onConnection) {
548
+ await onConnection(context);
549
+ }
550
+ }
551
+ else {
552
+ await new Promise((resolve) => {
553
+ dc.addEventListener('open', async () => {
554
+ this.debug('Data channel opened');
555
+ if (onConnection) {
556
+ await onConnection(context);
557
+ }
558
+ resolve();
559
+ });
560
+ });
561
+ }
562
+ return context;
158
563
  }
159
564
  // ============================================
160
565
  // Service Discovery
@@ -171,14 +576,14 @@ export class Rondevu {
171
576
  * Example: chat:1.0.0 (without @username)
172
577
  */
173
578
  async discoverService(serviceVersion) {
174
- return await this.api.discoverService(serviceVersion);
579
+ return await this.api.getService(serviceVersion);
175
580
  }
176
581
  /**
177
582
  * Discover multiple available services with pagination
178
583
  * Example: chat:1.0.0 (without @username)
179
584
  */
180
585
  async discoverServices(serviceVersion, limit = 10, offset = 0) {
181
- return await this.api.discoverServices(serviceVersion, limit, offset);
586
+ return await this.api.getService(serviceVersion, { limit, offset });
182
587
  }
183
588
  // ============================================
184
589
  // WebRTC Signaling
@@ -187,7 +592,8 @@ export class Rondevu {
187
592
  * Post answer SDP to specific offer
188
593
  */
189
594
  async postOfferAnswer(serviceFqn, offerId, sdp) {
190
- return await this.api.postOfferAnswer(serviceFqn, offerId, sdp);
595
+ await this.api.answerOffer(serviceFqn, offerId, sdp);
596
+ return { success: true, offerId };
191
597
  }
192
598
  /**
193
599
  * Get answer SDP (offerer polls this)
@@ -195,6 +601,13 @@ export class Rondevu {
195
601
  async getOfferAnswer(serviceFqn, offerId) {
196
602
  return await this.api.getOfferAnswer(serviceFqn, offerId);
197
603
  }
604
+ /**
605
+ * Combined polling for answers and ICE candidates
606
+ * Returns all answered offers and ICE candidates for all peer's offers since timestamp
607
+ */
608
+ async poll(since) {
609
+ return await this.api.poll(since);
610
+ }
198
611
  /**
199
612
  * Add ICE candidates to specific offer
200
613
  */
@@ -226,13 +639,16 @@ export class Rondevu {
226
639
  * Get the public key
227
640
  */
228
641
  getPublicKey() {
229
- return this.keypair?.publicKey || null;
642
+ return this.keypair.publicKey;
230
643
  }
231
644
  /**
232
645
  * Access to underlying API for advanced operations
233
646
  * @deprecated Use direct methods on Rondevu instance instead
234
647
  */
235
- getAPI() {
648
+ getAPIPublic() {
236
649
  return this.api;
237
650
  }
238
651
  }
652
+ // Constants
653
+ Rondevu.DEFAULT_TTL_MS = 300000; // 5 minutes
654
+ Rondevu.POLLING_INTERVAL_MS = 1000; // 1 second