@xtr-dev/rondevu-client 0.18.10 → 0.20.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.
Files changed (41) hide show
  1. package/README.md +324 -47
  2. package/dist/{api.d.ts → api/client.d.ts} +17 -8
  3. package/dist/{api.js → api/client.js} +114 -81
  4. package/dist/{answerer-connection.d.ts → connections/answerer.d.ts} +13 -5
  5. package/dist/{answerer-connection.js → connections/answerer.js} +17 -32
  6. package/dist/{connection.d.ts → connections/base.d.ts} +26 -5
  7. package/dist/{connection.js → connections/base.js} +45 -4
  8. package/dist/{offerer-connection.d.ts → connections/offerer.d.ts} +30 -5
  9. package/dist/{offerer-connection.js → connections/offerer.js} +93 -32
  10. package/dist/core/index.d.ts +22 -0
  11. package/dist/core/index.js +17 -0
  12. package/dist/core/offer-pool.d.ts +94 -0
  13. package/dist/core/offer-pool.js +267 -0
  14. package/dist/{rondevu.d.ts → core/rondevu.d.ts} +77 -85
  15. package/dist/core/rondevu.js +600 -0
  16. package/dist/{node-crypto-adapter.d.ts → crypto/node.d.ts} +1 -1
  17. package/dist/{web-crypto-adapter.d.ts → crypto/web.d.ts} +1 -1
  18. package/dist/utils/async-lock.d.ts +42 -0
  19. package/dist/utils/async-lock.js +75 -0
  20. package/dist/{message-buffer.d.ts → utils/message-buffer.d.ts} +1 -1
  21. package/package.json +4 -4
  22. package/dist/index.d.ts +0 -13
  23. package/dist/index.js +0 -10
  24. package/dist/rondevu-signaler.d.ts +0 -112
  25. package/dist/rondevu-signaler.js +0 -401
  26. package/dist/rondevu.js +0 -847
  27. /package/dist/{rpc-batcher.d.ts → api/batcher.d.ts} +0 -0
  28. /package/dist/{rpc-batcher.js → api/batcher.js} +0 -0
  29. /package/dist/{connection-config.d.ts → connections/config.d.ts} +0 -0
  30. /package/dist/{connection-config.js → connections/config.js} +0 -0
  31. /package/dist/{connection-events.d.ts → connections/events.d.ts} +0 -0
  32. /package/dist/{connection-events.js → connections/events.js} +0 -0
  33. /package/dist/{types.d.ts → core/types.d.ts} +0 -0
  34. /package/dist/{types.js → core/types.js} +0 -0
  35. /package/dist/{crypto-adapter.d.ts → crypto/adapter.d.ts} +0 -0
  36. /package/dist/{crypto-adapter.js → crypto/adapter.js} +0 -0
  37. /package/dist/{node-crypto-adapter.js → crypto/node.js} +0 -0
  38. /package/dist/{web-crypto-adapter.js → crypto/web.js} +0 -0
  39. /package/dist/{exponential-backoff.d.ts → utils/exponential-backoff.d.ts} +0 -0
  40. /package/dist/{exponential-backoff.js → utils/exponential-backoff.js} +0 -0
  41. /package/dist/{message-buffer.js → utils/message-buffer.js} +0 -0
package/dist/rondevu.js DELETED
@@ -1,847 +0,0 @@
1
- import { RondevuAPI } from './api.js';
2
- import { EventEmitter } from 'eventemitter3';
3
- // ICE server presets
4
- export const ICE_SERVER_PRESETS = {
5
- 'ipv4-turn': [
6
- { urls: 'stun:57.129.61.67:3478' },
7
- {
8
- urls: [
9
- 'turn:57.129.61.67:3478?transport=tcp',
10
- 'turn:57.129.61.67:3478?transport=udp',
11
- ],
12
- username: 'webrtcuser',
13
- credential: 'supersecretpassword'
14
- }
15
- ],
16
- 'hostname-turns': [
17
- { urls: 'stun:turn.share.fish:3478' },
18
- {
19
- urls: [
20
- 'turns:turn.share.fish:5349?transport=tcp',
21
- 'turns:turn.share.fish:5349?transport=udp',
22
- 'turn:turn.share.fish:3478?transport=tcp',
23
- 'turn:turn.share.fish:3478?transport=udp',
24
- ],
25
- username: 'webrtcuser',
26
- credential: 'supersecretpassword'
27
- }
28
- ],
29
- 'google-stun': [
30
- { urls: 'stun:stun.l.google.com:19302' },
31
- { urls: 'stun:stun1.l.google.com:19302' }
32
- ],
33
- 'relay-only': [
34
- { urls: 'stun:57.129.61.67:3478' },
35
- {
36
- urls: [
37
- 'turn:57.129.61.67:3478?transport=tcp',
38
- 'turn:57.129.61.67:3478?transport=udp',
39
- ],
40
- username: 'webrtcuser',
41
- credential: 'supersecretpassword',
42
- // @ts-expect-error - iceTransportPolicy is valid but not in RTCIceServer type
43
- iceTransportPolicy: 'relay'
44
- }
45
- ]
46
- };
47
- /**
48
- * Base error class for Rondevu errors
49
- */
50
- export class RondevuError extends Error {
51
- constructor(message, context) {
52
- super(message);
53
- this.context = context;
54
- this.name = 'RondevuError';
55
- Object.setPrototypeOf(this, RondevuError.prototype);
56
- }
57
- }
58
- /**
59
- * Network-related errors (API calls, connectivity)
60
- */
61
- export class NetworkError extends RondevuError {
62
- constructor(message, context) {
63
- super(message, context);
64
- this.name = 'NetworkError';
65
- Object.setPrototypeOf(this, NetworkError.prototype);
66
- }
67
- }
68
- /**
69
- * Validation errors (invalid input, malformed data)
70
- */
71
- export class ValidationError extends RondevuError {
72
- constructor(message, context) {
73
- super(message, context);
74
- this.name = 'ValidationError';
75
- Object.setPrototypeOf(this, ValidationError.prototype);
76
- }
77
- }
78
- /**
79
- * WebRTC connection errors (peer connection failures, ICE issues)
80
- */
81
- export class ConnectionError extends RondevuError {
82
- constructor(message, context) {
83
- super(message, context);
84
- this.name = 'ConnectionError';
85
- Object.setPrototypeOf(this, ConnectionError.prototype);
86
- }
87
- }
88
- /**
89
- * Rondevu - Complete WebRTC signaling client
90
- *
91
- * Provides a unified API for:
92
- * - Implicit username claiming (auto-claimed on first authenticated request)
93
- * - Service publishing with automatic signature generation
94
- * - Service discovery (direct, random, paginated)
95
- * - WebRTC signaling (offer/answer exchange, ICE relay)
96
- * - Keypair management
97
- *
98
- * @example
99
- * ```typescript
100
- * // Create and initialize Rondevu instance with preset ICE servers
101
- * const rondevu = await Rondevu.connect({
102
- * apiUrl: 'https://signal.example.com',
103
- * username: 'alice',
104
- * iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
105
- * })
106
- *
107
- * // Or use custom ICE servers
108
- * const rondevu2 = await Rondevu.connect({
109
- * apiUrl: 'https://signal.example.com',
110
- * username: 'bob',
111
- * iceServers: [
112
- * { urls: 'stun:stun.l.google.com:19302' },
113
- * { urls: 'turn:turn.example.com:3478', username: 'user', credential: 'pass' }
114
- * ]
115
- * })
116
- *
117
- * // Publish a service with automatic offer management
118
- * await rondevu.publishService({
119
- * service: 'chat:2.0.0',
120
- * maxOffers: 5, // Maintain up to 5 concurrent offers
121
- * offerFactory: async (pc) => {
122
- * // pc is created by Rondevu with ICE handlers already attached
123
- * const dc = pc.createDataChannel('chat')
124
- * const offer = await pc.createOffer()
125
- * await pc.setLocalDescription(offer)
126
- * return { dc, offer }
127
- * }
128
- * })
129
- *
130
- * // Start accepting connections (auto-fills offers and polls)
131
- * await rondevu.startFilling()
132
- *
133
- * // Access active connections
134
- * for (const offer of rondevu.getActiveOffers()) {
135
- * offer.dc?.addEventListener('message', (e) => console.log(e.data))
136
- * }
137
- *
138
- * // Stop when done
139
- * rondevu.stopFilling()
140
- * ```
141
- */
142
- export class Rondevu extends EventEmitter {
143
- constructor(apiUrl, username, keypair, api, iceServers, cryptoAdapter, batchingOptions, debugEnabled = false, rtcPeerConnection, rtcIceCandidate) {
144
- super();
145
- this.usernameClaimed = false;
146
- // Service management
147
- this.currentService = null;
148
- this.maxOffers = 0;
149
- this.offerFactory = null;
150
- this.ttl = Rondevu.DEFAULT_TTL_MS;
151
- this.activeOffers = new Map();
152
- // Polling
153
- this.filling = false;
154
- this.pollingInterval = null;
155
- this.lastPollTimestamp = 0;
156
- this.isPolling = false; // Guard against concurrent poll execution
157
- this.apiUrl = apiUrl;
158
- this.username = username;
159
- this.keypair = keypair;
160
- this.api = api;
161
- this.iceServers = iceServers;
162
- this.cryptoAdapter = cryptoAdapter;
163
- this.batchingOptions = batchingOptions;
164
- this.debugEnabled = debugEnabled;
165
- this.rtcPeerConnection = rtcPeerConnection;
166
- this.rtcIceCandidate = rtcIceCandidate;
167
- this.debug('Instance created:', {
168
- username: this.username,
169
- publicKey: this.keypair.publicKey,
170
- hasIceServers: iceServers.length > 0,
171
- batchingEnabled: batchingOptions !== false
172
- });
173
- }
174
- /**
175
- * Internal debug logging - only logs if debug mode is enabled
176
- */
177
- debug(message, ...args) {
178
- if (this.debugEnabled) {
179
- console.log(`[Rondevu] ${message}`, ...args);
180
- }
181
- }
182
- /**
183
- * Create and initialize a Rondevu client
184
- *
185
- * @example
186
- * ```typescript
187
- * const rondevu = await Rondevu.connect({
188
- * apiUrl: 'https://api.ronde.vu',
189
- * username: 'alice'
190
- * })
191
- * ```
192
- */
193
- static async connect(options) {
194
- const username = options.username || Rondevu.generateAnonymousUsername();
195
- // Apply WebRTC polyfills to global scope if provided (Node.js environments)
196
- if (options.rtcPeerConnection) {
197
- globalThis.RTCPeerConnection = options.rtcPeerConnection;
198
- }
199
- if (options.rtcIceCandidate) {
200
- globalThis.RTCIceCandidate = options.rtcIceCandidate;
201
- }
202
- // Handle preset string or custom array
203
- let iceServers;
204
- if (typeof options.iceServers === 'string') {
205
- iceServers = ICE_SERVER_PRESETS[options.iceServers];
206
- }
207
- else {
208
- iceServers = options.iceServers || [
209
- { urls: 'stun:stun.l.google.com:19302' }
210
- ];
211
- }
212
- if (options.debug) {
213
- console.log('[Rondevu] Connecting:', {
214
- username,
215
- hasKeypair: !!options.keypair,
216
- iceServers: iceServers.length,
217
- batchingEnabled: options.batching !== false
218
- });
219
- }
220
- // Generate keypair if not provided
221
- let keypair = options.keypair;
222
- if (!keypair) {
223
- if (options.debug)
224
- console.log('[Rondevu] Generating new keypair...');
225
- keypair = await RondevuAPI.generateKeypair(options.cryptoAdapter);
226
- if (options.debug)
227
- console.log('[Rondevu] Generated keypair, publicKey:', keypair.publicKey);
228
- }
229
- else {
230
- if (options.debug)
231
- console.log('[Rondevu] Using existing keypair, publicKey:', keypair.publicKey);
232
- }
233
- // Create API instance
234
- const api = new RondevuAPI(options.apiUrl, username, keypair, options.cryptoAdapter, options.batching);
235
- if (options.debug)
236
- console.log('[Rondevu] Created API instance');
237
- return new Rondevu(options.apiUrl, username, keypair, api, iceServers, options.cryptoAdapter, options.batching, options.debug || false, options.rtcPeerConnection, options.rtcIceCandidate);
238
- }
239
- /**
240
- * Generate an anonymous username with timestamp and random component
241
- */
242
- static generateAnonymousUsername() {
243
- const timestamp = Date.now().toString(36);
244
- const random = Array.from(crypto.getRandomValues(new Uint8Array(3)))
245
- .map(b => b.toString(16).padStart(2, '0')).join('');
246
- return `anon-${timestamp}-${random}`;
247
- }
248
- // ============================================
249
- // Username Management
250
- // ============================================
251
- /**
252
- * Check if username has been claimed (checks with server)
253
- */
254
- async isUsernameClaimed() {
255
- try {
256
- const claimed = await this.api.isUsernameClaimed();
257
- // Update internal flag to match server state
258
- this.usernameClaimed = claimed;
259
- return claimed;
260
- }
261
- catch (err) {
262
- console.error('Failed to check username claim status:', err);
263
- return false;
264
- }
265
- }
266
- // ============================================
267
- // Service Publishing
268
- // ============================================
269
- /**
270
- * Default offer factory - creates a simple data channel connection
271
- * The RTCPeerConnection is created by Rondevu and passed in
272
- */
273
- async defaultOfferFactory(pc) {
274
- const dc = pc.createDataChannel('default');
275
- const offer = await pc.createOffer();
276
- await pc.setLocalDescription(offer);
277
- return { dc, offer };
278
- }
279
- /**
280
- * Publish a service with automatic offer management
281
- * Call startFilling() to begin accepting connections
282
- *
283
- * @example
284
- * ```typescript
285
- * await rondevu.publishService({
286
- * service: 'chat:2.0.0',
287
- * maxOffers: 5
288
- * })
289
- * await rondevu.startFilling()
290
- * ```
291
- */
292
- async publishService(options) {
293
- const { service, maxOffers, offerFactory, ttl } = options;
294
- this.currentService = service;
295
- this.maxOffers = maxOffers;
296
- this.offerFactory = offerFactory || this.defaultOfferFactory.bind(this);
297
- this.ttl = ttl || Rondevu.DEFAULT_TTL_MS;
298
- this.debug(`Publishing service: ${service} with maxOffers: ${maxOffers}`);
299
- this.usernameClaimed = true;
300
- }
301
- /**
302
- * Set up ICE candidate handler to send candidates to the server
303
- *
304
- * Note: This is used by connectToService() where the offerId is already known.
305
- * For createOffer(), we use inline ICE handling with early candidate queuing
306
- * since the offerId isn't available until after the factory completes.
307
- */
308
- setupIceCandidateHandler(pc, serviceFqn, offerId) {
309
- pc.onicecandidate = async (event) => {
310
- if (event.candidate) {
311
- try {
312
- // Handle both browser and Node.js (wrtc) environments
313
- // Browser: candidate.toJSON() exists
314
- // Node.js wrtc: candidate is already a plain object
315
- const candidateData = typeof event.candidate.toJSON === 'function'
316
- ? event.candidate.toJSON()
317
- : event.candidate;
318
- // Emit local ICE candidate event
319
- this.emit('ice:candidate:local', offerId, candidateData);
320
- await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData]);
321
- }
322
- catch (err) {
323
- console.error('[Rondevu] Failed to send ICE candidate:', err);
324
- }
325
- }
326
- };
327
- }
328
- /**
329
- * Create a single offer and publish it to the server
330
- */
331
- async createOffer() {
332
- if (!this.currentService || !this.offerFactory) {
333
- throw new Error('Service not published. Call publishService() first.');
334
- }
335
- const rtcConfig = {
336
- iceServers: this.iceServers
337
- };
338
- // Auto-append username to service
339
- const serviceFqn = `${this.currentService}@${this.username}`;
340
- this.debug('Creating new offer...');
341
- // 1. Create the RTCPeerConnection - Rondevu controls this to set up handlers early
342
- const pc = new RTCPeerConnection(rtcConfig);
343
- // 2. Set up ICE candidate handler with queuing BEFORE the factory runs
344
- // This ensures we capture all candidates, even those generated immediately
345
- // when setLocalDescription() is called in the factory
346
- const earlyIceCandidates = [];
347
- let offerId;
348
- pc.onicecandidate = async (event) => {
349
- if (event.candidate) {
350
- // Handle both browser and Node.js (wrtc) environments
351
- const candidateData = typeof event.candidate.toJSON === 'function'
352
- ? event.candidate.toJSON()
353
- : event.candidate;
354
- // Emit local ICE candidate event
355
- if (offerId) {
356
- this.emit('ice:candidate:local', offerId, candidateData);
357
- }
358
- if (offerId) {
359
- // We have the offerId, send directly
360
- try {
361
- await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData]);
362
- }
363
- catch (err) {
364
- console.error('[Rondevu] Failed to send ICE candidate:', err);
365
- }
366
- }
367
- else {
368
- // Queue for later - we don't have the offerId yet
369
- this.debug('Queuing early ICE candidate');
370
- earlyIceCandidates.push(candidateData);
371
- }
372
- }
373
- };
374
- // 3. Call the factory with the pc - factory creates data channel and offer
375
- // When factory calls setLocalDescription(), ICE gathering starts and
376
- // candidates are captured by the handler we set up above
377
- let dc;
378
- let offer;
379
- try {
380
- const factoryResult = await this.offerFactory(pc);
381
- dc = factoryResult.dc;
382
- offer = factoryResult.offer;
383
- }
384
- catch (err) {
385
- // Clean up the connection if factory fails
386
- pc.close();
387
- throw err;
388
- }
389
- // 4. Publish to server to get offerId
390
- const result = await this.api.publishService({
391
- serviceFqn,
392
- offers: [{ sdp: offer.sdp }],
393
- ttl: this.ttl,
394
- signature: '',
395
- message: '',
396
- });
397
- offerId = result.offers[0].offerId;
398
- // 5. Store active offer
399
- this.activeOffers.set(offerId, {
400
- offerId,
401
- serviceFqn,
402
- pc,
403
- dc,
404
- answered: false,
405
- createdAt: Date.now()
406
- });
407
- this.debug(`Offer created: ${offerId}`);
408
- this.emit('offer:created', offerId, serviceFqn);
409
- // Set up data channel open handler (offerer side)
410
- if (dc) {
411
- dc.onopen = () => {
412
- this.debug(`Data channel opened for offer ${offerId}`);
413
- this.emit('connection:opened', offerId, dc);
414
- };
415
- }
416
- // 6. Send any queued early ICE candidates
417
- if (earlyIceCandidates.length > 0) {
418
- this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`);
419
- try {
420
- await this.api.addOfferIceCandidates(serviceFqn, offerId, earlyIceCandidates);
421
- }
422
- catch (err) {
423
- console.error('[Rondevu] Failed to send early ICE candidates:', err);
424
- }
425
- }
426
- // 7. Monitor connection state
427
- pc.onconnectionstatechange = () => {
428
- this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`);
429
- if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
430
- this.emit('connection:closed', offerId);
431
- this.activeOffers.delete(offerId);
432
- this.fillOffers(); // Try to replace failed offer
433
- }
434
- };
435
- }
436
- /**
437
- * Fill offers to reach maxOffers count
438
- */
439
- async fillOffers() {
440
- if (!this.filling || !this.currentService)
441
- return;
442
- const currentCount = this.activeOffers.size;
443
- const needed = this.maxOffers - currentCount;
444
- this.debug(`Filling offers: current=${currentCount}, needed=${needed}`);
445
- for (let i = 0; i < needed; i++) {
446
- try {
447
- await this.createOffer();
448
- }
449
- catch (err) {
450
- console.error('[Rondevu] Failed to create offer:', err);
451
- }
452
- }
453
- }
454
- /**
455
- * Poll for answers and ICE candidates (internal use for automatic offer management)
456
- */
457
- async pollInternal() {
458
- if (!this.filling)
459
- return;
460
- // Prevent concurrent poll execution to avoid duplicate answer processing
461
- if (this.isPolling) {
462
- this.debug('Poll already in progress, skipping');
463
- return;
464
- }
465
- this.isPolling = true;
466
- try {
467
- const result = await this.api.poll(this.lastPollTimestamp);
468
- // Process answers
469
- for (const answer of result.answers) {
470
- const activeOffer = this.activeOffers.get(answer.offerId);
471
- if (activeOffer && !activeOffer.answered) {
472
- this.debug(`Received answer for offer ${answer.offerId}`);
473
- await activeOffer.pc.setRemoteDescription({
474
- type: 'answer',
475
- sdp: answer.sdp
476
- });
477
- activeOffer.answered = true;
478
- this.lastPollTimestamp = answer.answeredAt;
479
- this.emit('offer:answered', answer.offerId, answer.answererId);
480
- // Create replacement offer
481
- this.fillOffers();
482
- }
483
- }
484
- // Process ICE candidates
485
- for (const [offerId, candidates] of Object.entries(result.iceCandidates)) {
486
- const activeOffer = this.activeOffers.get(offerId);
487
- if (activeOffer) {
488
- const answererCandidates = candidates.filter(c => c.role === 'answerer');
489
- for (const item of answererCandidates) {
490
- if (item.candidate) {
491
- this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
492
- await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate));
493
- this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt);
494
- }
495
- }
496
- }
497
- }
498
- }
499
- catch (err) {
500
- console.error('[Rondevu] Polling error:', err);
501
- }
502
- finally {
503
- this.isPolling = false;
504
- }
505
- }
506
- /**
507
- * Start filling offers and polling for answers/ICE
508
- * Call this after publishService() to begin accepting connections
509
- */
510
- async startFilling() {
511
- if (this.filling) {
512
- this.debug('Already filling');
513
- return;
514
- }
515
- if (!this.currentService) {
516
- throw new Error('No service published. Call publishService() first.');
517
- }
518
- this.debug('Starting offer filling and polling');
519
- this.filling = true;
520
- // Fill initial offers
521
- await this.fillOffers();
522
- // Start polling
523
- this.pollingInterval = setInterval(() => {
524
- this.pollInternal();
525
- }, Rondevu.POLLING_INTERVAL_MS);
526
- }
527
- /**
528
- * Stop filling offers and polling
529
- * Closes all active peer connections
530
- */
531
- stopFilling() {
532
- this.debug('Stopping offer filling and polling');
533
- this.filling = false;
534
- this.isPolling = false; // Reset polling guard
535
- // Stop polling
536
- if (this.pollingInterval) {
537
- clearInterval(this.pollingInterval);
538
- this.pollingInterval = null;
539
- }
540
- // Close all active connections
541
- for (const [offerId, offer] of this.activeOffers.entries()) {
542
- this.debug(`Closing offer ${offerId}`);
543
- offer.dc?.close();
544
- offer.pc.close();
545
- }
546
- this.activeOffers.clear();
547
- }
548
- /**
549
- * Get the count of active offers
550
- * @returns Number of active offers
551
- */
552
- getOfferCount() {
553
- return this.activeOffers.size;
554
- }
555
- /**
556
- * Check if an offer is currently connected
557
- * @param offerId - The offer ID to check
558
- * @returns True if the offer exists and has been answered
559
- */
560
- isConnected(offerId) {
561
- const offer = this.activeOffers.get(offerId);
562
- return offer ? offer.answered : false;
563
- }
564
- /**
565
- * Disconnect all active offers
566
- * Similar to stopFilling() but doesn't stop the polling/filling process
567
- */
568
- async disconnectAll() {
569
- this.debug('Disconnecting all offers');
570
- for (const [offerId, offer] of this.activeOffers.entries()) {
571
- this.debug(`Closing offer ${offerId}`);
572
- offer.dc?.close();
573
- offer.pc.close();
574
- }
575
- this.activeOffers.clear();
576
- }
577
- /**
578
- * Get the current service status
579
- * @returns Object with service state information
580
- */
581
- getServiceStatus() {
582
- return {
583
- active: this.currentService !== null,
584
- offerCount: this.activeOffers.size,
585
- maxOffers: this.maxOffers,
586
- filling: this.filling
587
- };
588
- }
589
- /**
590
- * Resolve the full service FQN from various input options
591
- * Supports direct FQN, service+username, or service discovery
592
- */
593
- async resolveServiceFqn(options) {
594
- const { serviceFqn, service, username } = options;
595
- if (serviceFqn) {
596
- return serviceFqn;
597
- }
598
- else if (service && username) {
599
- return `${service}@${username}`;
600
- }
601
- else if (service) {
602
- // Discovery mode - get random service
603
- this.debug(`Discovering service: ${service}`);
604
- const discovered = await this.findService(service);
605
- return discovered.serviceFqn;
606
- }
607
- else {
608
- throw new Error('Either serviceFqn or service must be provided');
609
- }
610
- }
611
- /**
612
- * Start polling for remote ICE candidates
613
- * Returns the polling interval ID
614
- */
615
- startIcePolling(pc, serviceFqn, offerId) {
616
- let lastIceTimestamp = 0;
617
- return setInterval(async () => {
618
- try {
619
- const result = await this.api.getOfferIceCandidates(serviceFqn, offerId, lastIceTimestamp);
620
- for (const item of result.candidates) {
621
- if (item.candidate) {
622
- this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
623
- await pc.addIceCandidate(new RTCIceCandidate(item.candidate));
624
- lastIceTimestamp = item.createdAt;
625
- }
626
- }
627
- }
628
- catch (err) {
629
- console.error('[Rondevu] Failed to poll ICE candidates:', err);
630
- }
631
- }, Rondevu.POLLING_INTERVAL_MS);
632
- }
633
- /**
634
- * Automatically connect to a service (answerer side)
635
- * Handles the entire connection flow: discovery, WebRTC setup, answer exchange, ICE candidates
636
- *
637
- * @example
638
- * ```typescript
639
- * // Connect to specific user
640
- * const connection = await rondevu.connectToService({
641
- * serviceFqn: 'chat:2.0.0@alice',
642
- * onConnection: ({ dc, peerUsername }) => {
643
- * console.log('Connected to', peerUsername)
644
- * dc.addEventListener('message', (e) => console.log(e.data))
645
- * dc.addEventListener('open', () => dc.send('Hello!'))
646
- * }
647
- * })
648
- *
649
- * // Discover random service
650
- * const connection = await rondevu.connectToService({
651
- * service: 'chat:2.0.0',
652
- * onConnection: ({ dc, peerUsername }) => {
653
- * console.log('Connected to', peerUsername)
654
- * }
655
- * })
656
- * ```
657
- */
658
- async connectToService(options) {
659
- const { onConnection, rtcConfig } = options;
660
- // Validate inputs
661
- if (options.serviceFqn !== undefined && typeof options.serviceFqn === 'string' && !options.serviceFqn.trim()) {
662
- throw new Error('serviceFqn cannot be empty');
663
- }
664
- if (options.service !== undefined && typeof options.service === 'string' && !options.service.trim()) {
665
- throw new Error('service cannot be empty');
666
- }
667
- if (options.username !== undefined && typeof options.username === 'string' && !options.username.trim()) {
668
- throw new Error('username cannot be empty');
669
- }
670
- // Determine the full service FQN
671
- const fqn = await this.resolveServiceFqn(options);
672
- this.debug(`Connecting to service: ${fqn}`);
673
- // 1. Get service offer
674
- const serviceData = await this.api.getService(fqn);
675
- this.debug(`Found service from @${serviceData.username}`);
676
- // 2. Create RTCPeerConnection
677
- const rtcConfiguration = rtcConfig || {
678
- iceServers: this.iceServers
679
- };
680
- const pc = new RTCPeerConnection(rtcConfiguration);
681
- // 3. Set up data channel handler (answerer receives it from offerer)
682
- let dc = null;
683
- const dataChannelPromise = new Promise((resolve) => {
684
- pc.ondatachannel = (event) => {
685
- this.debug('Data channel received from offerer');
686
- dc = event.channel;
687
- this.emit('connection:opened', serviceData.offerId, dc);
688
- resolve(dc);
689
- };
690
- });
691
- // 4. Set up ICE candidate exchange
692
- this.setupIceCandidateHandler(pc, serviceData.serviceFqn, serviceData.offerId);
693
- // 5. Poll for remote ICE candidates
694
- const icePollInterval = this.startIcePolling(pc, serviceData.serviceFqn, serviceData.offerId);
695
- // 6. Set remote description
696
- await pc.setRemoteDescription({
697
- type: 'offer',
698
- sdp: serviceData.sdp
699
- });
700
- // 7. Create and send answer
701
- const answer = await pc.createAnswer();
702
- await pc.setLocalDescription(answer);
703
- await this.api.answerOffer(serviceData.serviceFqn, serviceData.offerId, answer.sdp);
704
- // 8. Wait for data channel to be established
705
- dc = await dataChannelPromise;
706
- // Create connection context
707
- const context = {
708
- pc,
709
- dc,
710
- serviceFqn: serviceData.serviceFqn,
711
- offerId: serviceData.offerId,
712
- peerUsername: serviceData.username
713
- };
714
- // 9. Set up connection state monitoring
715
- pc.onconnectionstatechange = () => {
716
- this.debug(`Connection state: ${pc.connectionState}`);
717
- if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
718
- clearInterval(icePollInterval);
719
- }
720
- };
721
- // 10. Wait for data channel to open and call onConnection
722
- if (dc.readyState === 'open') {
723
- this.debug('Data channel already open');
724
- if (onConnection) {
725
- await onConnection(context);
726
- }
727
- }
728
- else {
729
- await new Promise((resolve) => {
730
- dc.addEventListener('open', async () => {
731
- this.debug('Data channel opened');
732
- if (onConnection) {
733
- await onConnection(context);
734
- }
735
- resolve();
736
- });
737
- });
738
- }
739
- return context;
740
- }
741
- // ============================================
742
- // Service Discovery
743
- // ============================================
744
- /**
745
- * Find a service - unified discovery method
746
- *
747
- * Replaces getService(), discoverService(), and discoverServices() with a single method.
748
- *
749
- * @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
750
- * @param options - Discovery options
751
- *
752
- * @example
753
- * ```typescript
754
- * // Direct lookup (has username)
755
- * const service = await rondevu.findService('chat:1.0.0@alice')
756
- *
757
- * // Random discovery (no username)
758
- * const service = await rondevu.findService('chat:1.0.0')
759
- *
760
- * // Paginated discovery
761
- * const result = await rondevu.findService('chat:1.0.0', {
762
- * mode: 'paginated',
763
- * limit: 20,
764
- * offset: 0
765
- * })
766
- * ```
767
- */
768
- async findService(serviceFqn, options) {
769
- const { mode, limit = 10, offset = 0 } = options || {};
770
- // Auto-detect mode if not specified
771
- const hasUsername = serviceFqn.includes('@');
772
- const effectiveMode = mode || (hasUsername ? 'direct' : 'random');
773
- if (effectiveMode === 'paginated') {
774
- return await this.api.getService(serviceFqn, { limit, offset });
775
- }
776
- else {
777
- // Both 'direct' and 'random' use the same API call
778
- return await this.api.getService(serviceFqn);
779
- }
780
- }
781
- // ============================================
782
- // WebRTC Signaling
783
- // ============================================
784
- /**
785
- * Post answer SDP to specific offer
786
- */
787
- async postOfferAnswer(serviceFqn, offerId, sdp) {
788
- await this.api.answerOffer(serviceFqn, offerId, sdp);
789
- return { success: true, offerId };
790
- }
791
- /**
792
- * Get answer SDP (offerer polls this)
793
- */
794
- async getOfferAnswer(serviceFqn, offerId) {
795
- return await this.api.getOfferAnswer(serviceFqn, offerId);
796
- }
797
- /**
798
- * Combined polling for answers and ICE candidates
799
- * Returns all answered offers and ICE candidates for all peer's offers since timestamp
800
- */
801
- async poll(since) {
802
- return await this.api.poll(since);
803
- }
804
- /**
805
- * Add ICE candidates to specific offer
806
- */
807
- async addOfferIceCandidates(serviceFqn, offerId, candidates) {
808
- return await this.api.addOfferIceCandidates(serviceFqn, offerId, candidates);
809
- }
810
- /**
811
- * Get ICE candidates for specific offer (with polling support)
812
- */
813
- async getOfferIceCandidates(serviceFqn, offerId, since = 0) {
814
- return await this.api.getOfferIceCandidates(serviceFqn, offerId, since);
815
- }
816
- // ============================================
817
- // Utility Methods
818
- // ============================================
819
- /**
820
- * Get the current keypair (for backup/storage)
821
- */
822
- getKeypair() {
823
- return this.keypair;
824
- }
825
- /**
826
- * Get the username
827
- */
828
- getUsername() {
829
- return this.username;
830
- }
831
- /**
832
- * Get the public key
833
- */
834
- getPublicKey() {
835
- return this.keypair.publicKey;
836
- }
837
- /**
838
- * Access to underlying API for advanced operations
839
- * @deprecated Use direct methods on Rondevu instance instead
840
- */
841
- getAPIPublic() {
842
- return this.api;
843
- }
844
- }
845
- // Constants
846
- Rondevu.DEFAULT_TTL_MS = 300000; // 5 minutes
847
- Rondevu.POLLING_INTERVAL_MS = 1000; // 1 second