@xtr-dev/rondevu-client 0.18.8 → 0.18.10

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/index.d.ts CHANGED
@@ -5,18 +5,9 @@
5
5
  export { Rondevu, RondevuError, NetworkError, ValidationError, ConnectionError } from './rondevu.js';
6
6
  export { RondevuAPI } from './api.js';
7
7
  export { RpcBatcher } from './rpc-batcher.js';
8
- export { RondevuConnection } from './connection.js';
9
- export { OffererConnection } from './offerer-connection.js';
10
- export { AnswererConnection } from './answerer-connection.js';
11
- export { ExponentialBackoff } from './exponential-backoff.js';
12
- export { MessageBuffer } from './message-buffer.js';
13
8
  export { WebCryptoAdapter } from './web-crypto-adapter.js';
14
9
  export { NodeCryptoAdapter } from './node-crypto-adapter.js';
15
10
  export type { Signaler, Binnable, } from './types.js';
16
11
  export type { Keypair, OfferRequest, ServiceRequest, Service, ServiceOffer, IceCandidate, } from './api.js';
17
12
  export type { RondevuOptions, PublishServiceOptions, ConnectToServiceOptions, ConnectionContext, OfferContext, OfferFactory, ActiveOffer, FindServiceOptions, ServiceResult, PaginatedServiceResult } from './rondevu.js';
18
13
  export type { CryptoAdapter } from './crypto-adapter.js';
19
- export type { ConnectionConfig, } from './connection-config.js';
20
- export type { ConnectionState, BufferedMessage, ReconnectInfo, StateChangeInfo, ConnectionEventMap, ConnectionEventName, ConnectionEventArgs, } from './connection-events.js';
21
- export type { OffererOptions, } from './offerer-connection.js';
22
- export type { AnswererOptions, } from './answerer-connection.js';
package/dist/index.js CHANGED
@@ -5,13 +5,6 @@
5
5
  export { Rondevu, RondevuError, NetworkError, ValidationError, ConnectionError } from './rondevu.js';
6
6
  export { RondevuAPI } from './api.js';
7
7
  export { RpcBatcher } from './rpc-batcher.js';
8
- // Export connection classes
9
- export { RondevuConnection } from './connection.js';
10
- export { OffererConnection } from './offerer-connection.js';
11
- export { AnswererConnection } from './answerer-connection.js';
12
- // Export utilities
13
- export { ExponentialBackoff } from './exponential-backoff.js';
14
- export { MessageBuffer } from './message-buffer.js';
15
8
  // Export crypto adapters
16
9
  export { WebCryptoAdapter } from './web-crypto-adapter.js';
17
10
  export { NodeCryptoAdapter } from './node-crypto-adapter.js';
@@ -8,11 +8,12 @@ export interface OffererOptions {
8
8
  api: RondevuAPI;
9
9
  serviceFqn: string;
10
10
  offerId: string;
11
- rtcConfig?: RTCConfiguration;
11
+ pc: RTCPeerConnection;
12
+ dc?: RTCDataChannel;
12
13
  config?: Partial<ConnectionConfig>;
13
14
  }
14
15
  /**
15
- * Offerer connection - creates offers and waits for answers
16
+ * Offerer connection - manages already-created offers and waits for answers
16
17
  */
17
18
  export declare class OffererConnection extends RondevuConnection {
18
19
  private api;
@@ -20,7 +21,7 @@ export declare class OffererConnection extends RondevuConnection {
20
21
  private offerId;
21
22
  constructor(options: OffererOptions);
22
23
  /**
23
- * Initialize the connection by creating offer
24
+ * Initialize the connection - setup handlers for already-created offer
24
25
  */
25
26
  initialize(): Promise<void>;
26
27
  /**
@@ -41,6 +42,9 @@ export declare class OffererConnection extends RondevuConnection {
41
42
  protected pollIceCandidates(): void;
42
43
  /**
43
44
  * Attempt to reconnect
45
+ *
46
+ * Note: For offerer connections, reconnection is handled by the Rondevu instance
47
+ * creating a new offer via fillOffers(). This method is a no-op.
44
48
  */
45
49
  protected attemptReconnect(): void;
46
50
  /**
@@ -4,38 +4,38 @@
4
4
  import { RondevuConnection } from './connection.js';
5
5
  import { ConnectionState } from './connection-events.js';
6
6
  /**
7
- * Offerer connection - creates offers and waits for answers
7
+ * Offerer connection - manages already-created offers and waits for answers
8
8
  */
9
9
  export class OffererConnection extends RondevuConnection {
10
10
  constructor(options) {
11
- super(options.rtcConfig, options.config);
11
+ super(undefined, options.config); // rtcConfig not needed, PC already created
12
12
  this.api = options.api;
13
13
  this.serviceFqn = options.serviceFqn;
14
14
  this.offerId = options.offerId;
15
+ // Use the already-created peer connection and data channel
16
+ this.pc = options.pc;
17
+ this.dc = options.dc || null;
15
18
  }
16
19
  /**
17
- * Initialize the connection by creating offer
20
+ * Initialize the connection - setup handlers for already-created offer
18
21
  */
19
22
  async initialize() {
20
23
  this.debug('Initializing offerer connection');
21
- // Create peer connection
22
- this.createPeerConnection();
23
- // Create data channel BEFORE creating offer
24
- // This is critical to avoid race conditions
25
24
  if (!this.pc)
26
- throw new Error('Peer connection not created');
27
- this.dc = this.pc.createDataChannel('data', {
28
- ordered: true,
29
- maxRetransmits: 3,
30
- });
31
- // Setup data channel handlers IMMEDIATELY after creation
32
- this.setupDataChannelHandlers(this.dc);
25
+ throw new Error('Peer connection not provided');
26
+ // Setup peer connection event handlers
27
+ this.pc.onicecandidate = (event) => this.handleIceCandidate(event);
28
+ this.pc.oniceconnectionstatechange = () => this.handleIceConnectionStateChange();
29
+ this.pc.onconnectionstatechange = () => this.handleConnectionStateChange();
30
+ this.pc.onicegatheringstatechange = () => this.handleIceGatheringStateChange();
31
+ // Setup data channel handlers if we have one
32
+ if (this.dc) {
33
+ this.setupDataChannelHandlers(this.dc);
34
+ }
33
35
  // Start connection timeout
34
36
  this.startConnectionTimeout();
35
- // Create and set local description
36
- const offer = await this.pc.createOffer();
37
- await this.pc.setLocalDescription(offer);
38
- this.transitionTo(ConnectionState.SIGNALING, 'Offer created, waiting for answer');
37
+ // Transition to signaling state (offer already created and published)
38
+ this.transitionTo(ConnectionState.SIGNALING, 'Offer published, waiting for answer');
39
39
  }
40
40
  /**
41
41
  * Process an answer from the answerer
@@ -157,32 +157,16 @@ export class OffererConnection extends RondevuConnection {
157
157
  }
158
158
  /**
159
159
  * Attempt to reconnect
160
+ *
161
+ * Note: For offerer connections, reconnection is handled by the Rondevu instance
162
+ * creating a new offer via fillOffers(). This method is a no-op.
160
163
  */
161
164
  attemptReconnect() {
162
- this.debug('Attempting to reconnect');
163
- // For offerer, we need to create a new offer
164
- // Clean up old connection
165
- if (this.pc) {
166
- this.pc.close();
167
- this.pc = null;
168
- }
169
- if (this.dc) {
170
- this.dc.close();
171
- this.dc = null;
172
- }
173
- // Reset answer processing flags
174
- this.answerProcessed = false;
175
- this.answerSdpFingerprint = null;
176
- // Reinitialize
177
- this.initialize()
178
- .then(() => {
179
- this.emit('reconnect:success');
180
- })
181
- .catch((error) => {
182
- this.debug('Reconnection failed:', error);
183
- this.emit('reconnect:failed', error);
184
- this.scheduleReconnect();
185
- });
165
+ this.debug('Reconnection not applicable for offerer - new offer will be created by Rondevu instance');
166
+ // Offerer reconnection is handled externally by Rondevu.fillOffers()
167
+ // which creates entirely new offers. We don't reconnect the same offer.
168
+ // Just emit failure and let the parent handle it.
169
+ this.emit('reconnect:failed', new Error('Offerer reconnection handled by parent'));
186
170
  }
187
171
  /**
188
172
  * Get the offer ID
package/dist/rondevu.d.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  import { RondevuAPI, Keypair, IceCandidate, BatcherOptions } from './api.js';
2
2
  import { CryptoAdapter } from './crypto-adapter.js';
3
3
  import { EventEmitter } from 'eventemitter3';
4
- import { OffererConnection } from './offerer-connection.js';
5
- import { AnswererConnection } from './answerer-connection.js';
6
- import { ConnectionConfig } from './connection-config.js';
7
4
  export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only';
8
5
  export declare const ICE_SERVER_PRESETS: Record<IceServerPreset, RTCIceServer[]>;
9
6
  export interface RondevuOptions {
@@ -35,7 +32,6 @@ export interface PublishServiceOptions {
35
32
  maxOffers: number;
36
33
  offerFactory?: OfferFactory;
37
34
  ttl?: number;
38
- connectionConfig?: Partial<ConnectionConfig>;
39
35
  }
40
36
  export interface ConnectionContext {
41
37
  pc: RTCPeerConnection;
@@ -48,8 +44,8 @@ export interface ConnectToServiceOptions {
48
44
  serviceFqn?: string;
49
45
  service?: string;
50
46
  username?: string;
47
+ onConnection?: (context: ConnectionContext) => void | Promise<void>;
51
48
  rtcConfig?: RTCConfiguration;
52
- connectionConfig?: Partial<ConnectionConfig>;
53
49
  }
54
50
  export interface ActiveOffer {
55
51
  offerId: string;
@@ -105,13 +101,14 @@ export declare class ConnectionError extends RondevuError {
105
101
  constructor(message: string, context?: Record<string, any>);
106
102
  }
107
103
  /**
108
- * Rondevu - Complete WebRTC signaling client with durable connections
104
+ * Rondevu - Complete WebRTC signaling client
109
105
  *
110
- * v1.0.0 introduces breaking changes:
111
- * - connectToService() now returns AnswererConnection instead of ConnectionContext
112
- * - Automatic reconnection and message buffering built-in
113
- * - Connection objects expose .send() method instead of raw DataChannel
114
- * - Rich event system for connection lifecycle (connected, disconnected, reconnecting, etc.)
106
+ * Provides a unified API for:
107
+ * - Implicit username claiming (auto-claimed on first authenticated request)
108
+ * - Service publishing with automatic signature generation
109
+ * - Service discovery (direct, random, paginated)
110
+ * - WebRTC signaling (offer/answer exchange, ICE relay)
111
+ * - Keypair management
115
112
  *
116
113
  * @example
117
114
  * ```typescript
@@ -122,39 +119,39 @@ export declare class ConnectionError extends RondevuError {
122
119
  * iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
123
120
  * })
124
121
  *
122
+ * // Or use custom ICE servers
123
+ * const rondevu2 = await Rondevu.connect({
124
+ * apiUrl: 'https://signal.example.com',
125
+ * username: 'bob',
126
+ * iceServers: [
127
+ * { urls: 'stun:stun.l.google.com:19302' },
128
+ * { urls: 'turn:turn.example.com:3478', username: 'user', credential: 'pass' }
129
+ * ]
130
+ * })
131
+ *
125
132
  * // Publish a service with automatic offer management
126
133
  * await rondevu.publishService({
127
134
  * service: 'chat:2.0.0',
128
- * maxOffers: 5 // Maintain up to 5 concurrent offers
135
+ * maxOffers: 5, // Maintain up to 5 concurrent offers
136
+ * offerFactory: async (pc) => {
137
+ * // pc is created by Rondevu with ICE handlers already attached
138
+ * const dc = pc.createDataChannel('chat')
139
+ * const offer = await pc.createOffer()
140
+ * await pc.setLocalDescription(offer)
141
+ * return { dc, offer }
142
+ * }
129
143
  * })
130
144
  *
131
145
  * // Start accepting connections (auto-fills offers and polls)
132
146
  * await rondevu.startFilling()
133
147
  *
134
- * // Listen for connections (v1.0.0 API)
135
- * rondevu.on('connection:opened', (offerId, connection) => {
136
- * connection.on('connected', () => console.log('Connected!'))
137
- * connection.on('message', (data) => console.log('Received:', data))
138
- * connection.send('Hello!')
139
- * })
140
- *
141
- * // Connect to a service (v1.0.0 - returns AnswererConnection)
142
- * const connection = await rondevu.connectToService({
143
- * serviceFqn: 'chat:2.0.0@bob'
144
- * })
145
- *
146
- * connection.on('connected', () => {
147
- * console.log('Connected!')
148
- * connection.send('Hello!')
149
- * })
150
- *
151
- * connection.on('message', (data) => {
152
- * console.log('Received:', data)
153
- * })
148
+ * // Access active connections
149
+ * for (const offer of rondevu.getActiveOffers()) {
150
+ * offer.dc?.addEventListener('message', (e) => console.log(e.data))
151
+ * }
154
152
  *
155
- * connection.on('reconnecting', (attempt) => {
156
- * console.log(`Reconnecting, attempt ${attempt}`)
157
- * })
153
+ * // Stop when done
154
+ * rondevu.stopFilling()
158
155
  * ```
159
156
  */
160
157
  export declare class Rondevu extends EventEmitter {
@@ -175,12 +172,11 @@ export declare class Rondevu extends EventEmitter {
175
172
  private maxOffers;
176
173
  private offerFactory;
177
174
  private ttl;
178
- private activeConnections;
179
- private connectionConfig?;
175
+ private activeOffers;
180
176
  private filling;
181
- private fillingSemaphore;
182
177
  private pollingInterval;
183
178
  private lastPollTimestamp;
179
+ private isPolling;
184
180
  private constructor();
185
181
  /**
186
182
  * Internal debug logging - only logs if debug mode is enabled
@@ -219,22 +215,26 @@ export declare class Rondevu extends EventEmitter {
219
215
  * ```typescript
220
216
  * await rondevu.publishService({
221
217
  * service: 'chat:2.0.0',
222
- * maxOffers: 5,
223
- * connectionConfig: {
224
- * reconnectEnabled: true,
225
- * bufferEnabled: true
226
- * }
218
+ * maxOffers: 5
227
219
  * })
228
220
  * await rondevu.startFilling()
229
221
  * ```
230
222
  */
231
223
  publishService(options: PublishServiceOptions): Promise<void>;
232
224
  /**
233
- * Create a single offer and publish it to the server using OffererConnection
225
+ * Set up ICE candidate handler to send candidates to the server
226
+ *
227
+ * Note: This is used by connectToService() where the offerId is already known.
228
+ * For createOffer(), we use inline ICE handling with early candidate queuing
229
+ * since the offerId isn't available until after the factory completes.
230
+ */
231
+ private setupIceCandidateHandler;
232
+ /**
233
+ * Create a single offer and publish it to the server
234
234
  */
235
235
  private createOffer;
236
236
  /**
237
- * Fill offers to reach maxOffers count with semaphore protection
237
+ * Fill offers to reach maxOffers count
238
238
  */
239
239
  private fillOffers;
240
240
  /**
@@ -259,7 +259,7 @@ export declare class Rondevu extends EventEmitter {
259
259
  /**
260
260
  * Check if an offer is currently connected
261
261
  * @param offerId - The offer ID to check
262
- * @returns True if the offer exists and is connected
262
+ * @returns True if the offer exists and has been answered
263
263
  */
264
264
  isConnected(offerId: string): boolean;
265
265
  /**
@@ -283,45 +283,41 @@ export declare class Rondevu extends EventEmitter {
283
283
  */
284
284
  private resolveServiceFqn;
285
285
  /**
286
- * Connect to a service (answerer side) - v1.0.0 API
287
- * Returns an AnswererConnection with automatic reconnection and buffering
288
- *
289
- * BREAKING CHANGE: This now returns AnswererConnection instead of ConnectionContext
286
+ * Start polling for remote ICE candidates
287
+ * Returns the polling interval ID
288
+ */
289
+ private startIcePolling;
290
+ /**
291
+ * Automatically connect to a service (answerer side)
292
+ * Handles the entire connection flow: discovery, WebRTC setup, answer exchange, ICE candidates
290
293
  *
291
294
  * @example
292
295
  * ```typescript
293
296
  * // Connect to specific user
294
297
  * const connection = await rondevu.connectToService({
295
298
  * serviceFqn: 'chat:2.0.0@alice',
296
- * connectionConfig: {
297
- * reconnectEnabled: true,
298
- * bufferEnabled: true
299
+ * onConnection: ({ dc, peerUsername }) => {
300
+ * console.log('Connected to', peerUsername)
301
+ * dc.addEventListener('message', (e) => console.log(e.data))
302
+ * dc.addEventListener('open', () => dc.send('Hello!'))
299
303
  * }
300
304
  * })
301
305
  *
302
- * connection.on('connected', () => {
303
- * console.log('Connected!')
304
- * connection.send('Hello!')
305
- * })
306
- *
307
- * connection.on('message', (data) => {
308
- * console.log('Received:', data)
309
- * })
310
- *
311
- * connection.on('reconnecting', (attempt) => {
312
- * console.log(`Reconnecting, attempt ${attempt}`)
313
- * })
314
- *
315
306
  * // Discover random service
316
307
  * const connection = await rondevu.connectToService({
317
- * service: 'chat:2.0.0'
308
+ * service: 'chat:2.0.0',
309
+ * onConnection: ({ dc, peerUsername }) => {
310
+ * console.log('Connected to', peerUsername)
311
+ * }
318
312
  * })
319
313
  * ```
320
314
  */
321
- connectToService(options: ConnectToServiceOptions): Promise<AnswererConnection>;
315
+ connectToService(options: ConnectToServiceOptions): Promise<ConnectionContext>;
322
316
  /**
323
317
  * Find a service - unified discovery method
324
318
  *
319
+ * Replaces getService(), discoverService(), and discoverServices() with a single method.
320
+ *
325
321
  * @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
326
322
  * @param options - Discovery options
327
323
  *
@@ -403,15 +399,6 @@ export declare class Rondevu extends EventEmitter {
403
399
  * Get the public key
404
400
  */
405
401
  getPublicKey(): string;
406
- /**
407
- * Get active connections (for offerer side)
408
- */
409
- getActiveConnections(): Map<string, OffererConnection>;
410
- /**
411
- * Get all active offers (legacy compatibility)
412
- * @deprecated Use getActiveConnections() instead
413
- */
414
- getActiveOffers(): ActiveOffer[];
415
402
  /**
416
403
  * Access to underlying API for advanced operations
417
404
  * @deprecated Use direct methods on Rondevu instance instead
package/dist/rondevu.js CHANGED
@@ -1,7 +1,5 @@
1
1
  import { RondevuAPI } from './api.js';
2
2
  import { EventEmitter } from 'eventemitter3';
3
- import { OffererConnection } from './offerer-connection.js';
4
- import { AnswererConnection } from './answerer-connection.js';
5
3
  // ICE server presets
6
4
  export const ICE_SERVER_PRESETS = {
7
5
  'ipv4-turn': [
@@ -88,13 +86,14 @@ export class ConnectionError extends RondevuError {
88
86
  }
89
87
  }
90
88
  /**
91
- * Rondevu - Complete WebRTC signaling client with durable connections
89
+ * Rondevu - Complete WebRTC signaling client
92
90
  *
93
- * v1.0.0 introduces breaking changes:
94
- * - connectToService() now returns AnswererConnection instead of ConnectionContext
95
- * - Automatic reconnection and message buffering built-in
96
- * - Connection objects expose .send() method instead of raw DataChannel
97
- * - Rich event system for connection lifecycle (connected, disconnected, reconnecting, etc.)
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
98
97
  *
99
98
  * @example
100
99
  * ```typescript
@@ -105,39 +104,39 @@ export class ConnectionError extends RondevuError {
105
104
  * iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
106
105
  * })
107
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
+ *
108
117
  * // Publish a service with automatic offer management
109
118
  * await rondevu.publishService({
110
119
  * service: 'chat:2.0.0',
111
- * maxOffers: 5 // Maintain up to 5 concurrent offers
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
+ * }
112
128
  * })
113
129
  *
114
130
  * // Start accepting connections (auto-fills offers and polls)
115
131
  * await rondevu.startFilling()
116
132
  *
117
- * // Listen for connections (v1.0.0 API)
118
- * rondevu.on('connection:opened', (offerId, connection) => {
119
- * connection.on('connected', () => console.log('Connected!'))
120
- * connection.on('message', (data) => console.log('Received:', data))
121
- * connection.send('Hello!')
122
- * })
133
+ * // Access active connections
134
+ * for (const offer of rondevu.getActiveOffers()) {
135
+ * offer.dc?.addEventListener('message', (e) => console.log(e.data))
136
+ * }
123
137
  *
124
- * // Connect to a service (v1.0.0 - returns AnswererConnection)
125
- * const connection = await rondevu.connectToService({
126
- * serviceFqn: 'chat:2.0.0@bob'
127
- * })
128
- *
129
- * connection.on('connected', () => {
130
- * console.log('Connected!')
131
- * connection.send('Hello!')
132
- * })
133
- *
134
- * connection.on('message', (data) => {
135
- * console.log('Received:', data)
136
- * })
137
- *
138
- * connection.on('reconnecting', (attempt) => {
139
- * console.log(`Reconnecting, attempt ${attempt}`)
140
- * })
138
+ * // Stop when done
139
+ * rondevu.stopFilling()
141
140
  * ```
142
141
  */
143
142
  export class Rondevu extends EventEmitter {
@@ -149,12 +148,12 @@ export class Rondevu extends EventEmitter {
149
148
  this.maxOffers = 0;
150
149
  this.offerFactory = null;
151
150
  this.ttl = Rondevu.DEFAULT_TTL_MS;
152
- this.activeConnections = new Map();
151
+ this.activeOffers = new Map();
153
152
  // Polling
154
153
  this.filling = false;
155
- this.fillingSemaphore = false; // Semaphore to prevent concurrent fillOffers calls
156
154
  this.pollingInterval = null;
157
155
  this.lastPollTimestamp = 0;
156
+ this.isPolling = false; // Guard against concurrent poll execution
158
157
  this.apiUrl = apiUrl;
159
158
  this.username = username;
160
159
  this.keypair = keypair;
@@ -285,27 +284,49 @@ export class Rondevu extends EventEmitter {
285
284
  * ```typescript
286
285
  * await rondevu.publishService({
287
286
  * service: 'chat:2.0.0',
288
- * maxOffers: 5,
289
- * connectionConfig: {
290
- * reconnectEnabled: true,
291
- * bufferEnabled: true
292
- * }
287
+ * maxOffers: 5
293
288
  * })
294
289
  * await rondevu.startFilling()
295
290
  * ```
296
291
  */
297
292
  async publishService(options) {
298
- const { service, maxOffers, offerFactory, ttl, connectionConfig } = options;
293
+ const { service, maxOffers, offerFactory, ttl } = options;
299
294
  this.currentService = service;
300
295
  this.maxOffers = maxOffers;
301
296
  this.offerFactory = offerFactory || this.defaultOfferFactory.bind(this);
302
297
  this.ttl = ttl || Rondevu.DEFAULT_TTL_MS;
303
- this.connectionConfig = connectionConfig;
304
298
  this.debug(`Publishing service: ${service} with maxOffers: ${maxOffers}`);
305
299
  this.usernameClaimed = true;
306
300
  }
307
301
  /**
308
- * Create a single offer and publish it to the server using OffererConnection
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
309
330
  */
310
331
  async createOffer() {
311
332
  if (!this.currentService || !this.offerFactory) {
@@ -317,9 +338,42 @@ export class Rondevu extends EventEmitter {
317
338
  // Auto-append username to service
318
339
  const serviceFqn = `${this.currentService}@${this.username}`;
319
340
  this.debug('Creating new offer...');
320
- // 1. Create RTCPeerConnection using factory (for now, keep compatibility)
341
+ // 1. Create the RTCPeerConnection - Rondevu controls this to set up handlers early
321
342
  const pc = new RTCPeerConnection(rtcConfig);
322
- // 2. Call the factory to create offer
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
323
377
  let dc;
324
378
  let offer;
325
379
  try {
@@ -328,10 +382,11 @@ export class Rondevu extends EventEmitter {
328
382
  offer = factoryResult.offer;
329
383
  }
330
384
  catch (err) {
385
+ // Clean up the connection if factory fails
331
386
  pc.close();
332
387
  throw err;
333
388
  }
334
- // 3. Publish to server to get offerId
389
+ // 4. Publish to server to get offerId
335
390
  const result = await this.api.publishService({
336
391
  serviceFqn,
337
392
  offers: [{ sdp: offer.sdp }],
@@ -339,67 +394,61 @@ export class Rondevu extends EventEmitter {
339
394
  signature: '',
340
395
  message: '',
341
396
  });
342
- const offerId = result.offers[0].offerId;
343
- // 4. Create OffererConnection instance
344
- const connection = new OffererConnection({
345
- api: this.api,
346
- serviceFqn,
397
+ offerId = result.offers[0].offerId;
398
+ // 5. Store active offer
399
+ this.activeOffers.set(offerId, {
347
400
  offerId,
348
- rtcConfig,
349
- config: {
350
- ...this.connectionConfig,
351
- debug: this.debugEnabled,
352
- },
353
- });
354
- // Setup connection event handlers
355
- connection.on('connected', () => {
356
- this.debug(`Connection established for offer ${offerId}`);
357
- this.emit('connection:opened', offerId, connection);
358
- });
359
- connection.on('failed', (error) => {
360
- this.debug(`Connection failed for offer ${offerId}:`, error);
361
- this.activeConnections.delete(offerId);
362
- this.fillOffers(); // Replace failed offer
363
- });
364
- connection.on('closed', () => {
365
- this.debug(`Connection closed for offer ${offerId}`);
366
- this.activeConnections.delete(offerId);
367
- this.fillOffers(); // Replace closed offer
401
+ serviceFqn,
402
+ pc,
403
+ dc,
404
+ answered: false,
405
+ createdAt: Date.now()
368
406
  });
369
- // Store active connection
370
- this.activeConnections.set(offerId, connection);
371
- // Initialize the connection
372
- await connection.initialize();
373
407
  this.debug(`Offer created: ${offerId}`);
374
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
+ };
375
435
  }
376
436
  /**
377
- * Fill offers to reach maxOffers count with semaphore protection
437
+ * Fill offers to reach maxOffers count
378
438
  */
379
439
  async fillOffers() {
380
440
  if (!this.filling || !this.currentService)
381
441
  return;
382
- // Semaphore to prevent concurrent fills
383
- if (this.fillingSemaphore) {
384
- this.debug('fillOffers already in progress, skipping');
385
- return;
386
- }
387
- this.fillingSemaphore = true;
388
- try {
389
- const currentCount = this.activeConnections.size;
390
- const needed = this.maxOffers - currentCount;
391
- this.debug(`Filling offers: current=${currentCount}, needed=${needed}`);
392
- for (let i = 0; i < needed; i++) {
393
- try {
394
- await this.createOffer();
395
- }
396
- catch (err) {
397
- console.error('[Rondevu] Failed to create offer:', err);
398
- }
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);
399
451
  }
400
- }
401
- finally {
402
- this.fillingSemaphore = false;
403
452
  }
404
453
  }
405
454
  /**
@@ -408,20 +457,41 @@ export class Rondevu extends EventEmitter {
408
457
  async pollInternal() {
409
458
  if (!this.filling)
410
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;
411
466
  try {
412
467
  const result = await this.api.poll(this.lastPollTimestamp);
413
- // Process answers - delegate to OffererConnections
468
+ // Process answers
414
469
  for (const answer of result.answers) {
415
- const connection = this.activeConnections.get(answer.offerId);
416
- if (connection) {
417
- try {
418
- await connection.processAnswer(answer.sdp, answer.answererId);
419
- this.lastPollTimestamp = Math.max(this.lastPollTimestamp, answer.answeredAt);
420
- // Create replacement offer
421
- this.fillOffers();
422
- }
423
- catch (err) {
424
- this.debug(`Failed to process answer for offer ${answer.offerId}:`, err);
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
+ }
425
495
  }
426
496
  }
427
497
  }
@@ -429,6 +499,9 @@ export class Rondevu extends EventEmitter {
429
499
  catch (err) {
430
500
  console.error('[Rondevu] Polling error:', err);
431
501
  }
502
+ finally {
503
+ this.isPolling = false;
504
+ }
432
505
  }
433
506
  /**
434
507
  * Start filling offers and polling for answers/ICE
@@ -458,34 +531,35 @@ export class Rondevu extends EventEmitter {
458
531
  stopFilling() {
459
532
  this.debug('Stopping offer filling and polling');
460
533
  this.filling = false;
461
- this.fillingSemaphore = false;
534
+ this.isPolling = false; // Reset polling guard
462
535
  // Stop polling
463
536
  if (this.pollingInterval) {
464
537
  clearInterval(this.pollingInterval);
465
538
  this.pollingInterval = null;
466
539
  }
467
540
  // Close all active connections
468
- for (const [offerId, connection] of this.activeConnections.entries()) {
469
- this.debug(`Closing connection ${offerId}`);
470
- connection.close();
541
+ for (const [offerId, offer] of this.activeOffers.entries()) {
542
+ this.debug(`Closing offer ${offerId}`);
543
+ offer.dc?.close();
544
+ offer.pc.close();
471
545
  }
472
- this.activeConnections.clear();
546
+ this.activeOffers.clear();
473
547
  }
474
548
  /**
475
549
  * Get the count of active offers
476
550
  * @returns Number of active offers
477
551
  */
478
552
  getOfferCount() {
479
- return this.activeConnections.size;
553
+ return this.activeOffers.size;
480
554
  }
481
555
  /**
482
556
  * Check if an offer is currently connected
483
557
  * @param offerId - The offer ID to check
484
- * @returns True if the offer exists and is connected
558
+ * @returns True if the offer exists and has been answered
485
559
  */
486
560
  isConnected(offerId) {
487
- const connection = this.activeConnections.get(offerId);
488
- return connection ? connection.getState() === 'connected' : false;
561
+ const offer = this.activeOffers.get(offerId);
562
+ return offer ? offer.answered : false;
489
563
  }
490
564
  /**
491
565
  * Disconnect all active offers
@@ -493,11 +567,12 @@ export class Rondevu extends EventEmitter {
493
567
  */
494
568
  async disconnectAll() {
495
569
  this.debug('Disconnecting all offers');
496
- for (const [offerId, connection] of this.activeConnections.entries()) {
497
- this.debug(`Closing connection ${offerId}`);
498
- connection.close();
570
+ for (const [offerId, offer] of this.activeOffers.entries()) {
571
+ this.debug(`Closing offer ${offerId}`);
572
+ offer.dc?.close();
573
+ offer.pc.close();
499
574
  }
500
- this.activeConnections.clear();
575
+ this.activeOffers.clear();
501
576
  }
502
577
  /**
503
578
  * Get the current service status
@@ -506,7 +581,7 @@ export class Rondevu extends EventEmitter {
506
581
  getServiceStatus() {
507
582
  return {
508
583
  active: this.currentService !== null,
509
- offerCount: this.activeConnections.size,
584
+ offerCount: this.activeOffers.size,
510
585
  maxOffers: this.maxOffers,
511
586
  filling: this.filling
512
587
  };
@@ -534,43 +609,54 @@ export class Rondevu extends EventEmitter {
534
609
  }
535
610
  }
536
611
  /**
537
- * Connect to a service (answerer side) - v1.0.0 API
538
- * Returns an AnswererConnection with automatic reconnection and buffering
539
- *
540
- * BREAKING CHANGE: This now returns AnswererConnection instead of ConnectionContext
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
541
636
  *
542
637
  * @example
543
638
  * ```typescript
544
639
  * // Connect to specific user
545
640
  * const connection = await rondevu.connectToService({
546
641
  * serviceFqn: 'chat:2.0.0@alice',
547
- * connectionConfig: {
548
- * reconnectEnabled: true,
549
- * bufferEnabled: true
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!'))
550
646
  * }
551
647
  * })
552
648
  *
553
- * connection.on('connected', () => {
554
- * console.log('Connected!')
555
- * connection.send('Hello!')
556
- * })
557
- *
558
- * connection.on('message', (data) => {
559
- * console.log('Received:', data)
560
- * })
561
- *
562
- * connection.on('reconnecting', (attempt) => {
563
- * console.log(`Reconnecting, attempt ${attempt}`)
564
- * })
565
- *
566
649
  * // Discover random service
567
650
  * const connection = await rondevu.connectToService({
568
- * service: 'chat:2.0.0'
651
+ * service: 'chat:2.0.0',
652
+ * onConnection: ({ dc, peerUsername }) => {
653
+ * console.log('Connected to', peerUsername)
654
+ * }
569
655
  * })
570
656
  * ```
571
657
  */
572
658
  async connectToService(options) {
573
- const { rtcConfig, connectionConfig } = options;
659
+ const { onConnection, rtcConfig } = options;
574
660
  // Validate inputs
575
661
  if (options.serviceFqn !== undefined && typeof options.serviceFqn === 'string' && !options.serviceFqn.trim()) {
576
662
  throw new Error('serviceFqn cannot be empty');
@@ -584,28 +670,73 @@ export class Rondevu extends EventEmitter {
584
670
  // Determine the full service FQN
585
671
  const fqn = await this.resolveServiceFqn(options);
586
672
  this.debug(`Connecting to service: ${fqn}`);
587
- // Get service offer
673
+ // 1. Get service offer
588
674
  const serviceData = await this.api.getService(fqn);
589
675
  this.debug(`Found service from @${serviceData.username}`);
590
- // Create RTCConfiguration
676
+ // 2. Create RTCPeerConnection
591
677
  const rtcConfiguration = rtcConfig || {
592
678
  iceServers: this.iceServers
593
679
  };
594
- // Create AnswererConnection
595
- const connection = new AnswererConnection({
596
- api: this.api,
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,
597
710
  serviceFqn: serviceData.serviceFqn,
598
711
  offerId: serviceData.offerId,
599
- offerSdp: serviceData.sdp,
600
- rtcConfig: rtcConfiguration,
601
- config: {
602
- ...connectionConfig,
603
- debug: this.debugEnabled,
604
- },
605
- });
606
- // Initialize the connection
607
- await connection.initialize();
608
- return connection;
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;
609
740
  }
610
741
  // ============================================
611
742
  // Service Discovery
@@ -613,6 +744,8 @@ export class Rondevu extends EventEmitter {
613
744
  /**
614
745
  * Find a service - unified discovery method
615
746
  *
747
+ * Replaces getService(), discoverService(), and discoverServices() with a single method.
748
+ *
616
749
  * @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
617
750
  * @param options - Discovery options
618
751
  *
@@ -701,34 +834,6 @@ export class Rondevu extends EventEmitter {
701
834
  getPublicKey() {
702
835
  return this.keypair.publicKey;
703
836
  }
704
- /**
705
- * Get active connections (for offerer side)
706
- */
707
- getActiveConnections() {
708
- return this.activeConnections;
709
- }
710
- /**
711
- * Get all active offers (legacy compatibility)
712
- * @deprecated Use getActiveConnections() instead
713
- */
714
- getActiveOffers() {
715
- const offers = [];
716
- for (const [offerId, connection] of this.activeConnections.entries()) {
717
- const pc = connection.getPeerConnection();
718
- const dc = connection.getDataChannel();
719
- if (pc) {
720
- offers.push({
721
- offerId,
722
- serviceFqn: this.currentService ? `${this.currentService}@${this.username}` : '',
723
- pc,
724
- dc: dc || undefined,
725
- answered: connection.getState() === 'connected',
726
- createdAt: Date.now(),
727
- });
728
- }
729
- }
730
- return offers;
731
- }
732
837
  /**
733
838
  * Access to underlying API for advanced operations
734
839
  * @deprecated Use direct methods on Rondevu instance instead
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xtr-dev/rondevu-client",
3
- "version": "0.18.8",
4
- "description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
3
+ "version": "0.18.10",
4
+ "description": "TypeScript client for Rondevu WebRTC signaling with username-based discovery",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",