@xtr-dev/rondevu-client 0.18.6 → 0.18.8

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.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Message buffering system for storing messages during disconnections
3
+ */
4
+ export class MessageBuffer {
5
+ constructor(config) {
6
+ this.config = config;
7
+ this.buffer = [];
8
+ this.messageIdCounter = 0;
9
+ }
10
+ /**
11
+ * Add a message to the buffer
12
+ * Returns the buffered message with metadata
13
+ */
14
+ add(data) {
15
+ const message = {
16
+ id: `msg_${Date.now()}_${this.messageIdCounter++}`,
17
+ data,
18
+ timestamp: Date.now(),
19
+ attempts: 0,
20
+ };
21
+ // Check if buffer is full
22
+ if (this.buffer.length >= this.config.maxSize) {
23
+ // Remove oldest message
24
+ const discarded = this.buffer.shift();
25
+ if (discarded) {
26
+ return message; // Signal overflow by returning the new message
27
+ }
28
+ }
29
+ this.buffer.push(message);
30
+ return message;
31
+ }
32
+ /**
33
+ * Get all messages in the buffer
34
+ */
35
+ getAll() {
36
+ return [...this.buffer];
37
+ }
38
+ /**
39
+ * Get messages that haven't exceeded max age
40
+ */
41
+ getValid() {
42
+ const now = Date.now();
43
+ return this.buffer.filter((msg) => now - msg.timestamp < this.config.maxAge);
44
+ }
45
+ /**
46
+ * Get and remove expired messages
47
+ */
48
+ getExpired() {
49
+ const now = Date.now();
50
+ const expired = [];
51
+ this.buffer = this.buffer.filter((msg) => {
52
+ if (now - msg.timestamp >= this.config.maxAge) {
53
+ expired.push(msg);
54
+ return false;
55
+ }
56
+ return true;
57
+ });
58
+ return expired;
59
+ }
60
+ /**
61
+ * Remove a specific message by ID
62
+ */
63
+ remove(messageId) {
64
+ const index = this.buffer.findIndex((msg) => msg.id === messageId);
65
+ if (index === -1)
66
+ return null;
67
+ const [removed] = this.buffer.splice(index, 1);
68
+ return removed;
69
+ }
70
+ /**
71
+ * Clear all messages from the buffer
72
+ */
73
+ clear() {
74
+ const cleared = [...this.buffer];
75
+ this.buffer = [];
76
+ return cleared;
77
+ }
78
+ /**
79
+ * Increment attempt count for a message
80
+ */
81
+ incrementAttempt(messageId) {
82
+ const message = this.buffer.find((msg) => msg.id === messageId);
83
+ if (!message)
84
+ return false;
85
+ message.attempts++;
86
+ return true;
87
+ }
88
+ /**
89
+ * Get the current size of the buffer
90
+ */
91
+ size() {
92
+ return this.buffer.length;
93
+ }
94
+ /**
95
+ * Check if buffer is empty
96
+ */
97
+ isEmpty() {
98
+ return this.buffer.length === 0;
99
+ }
100
+ /**
101
+ * Check if buffer is full
102
+ */
103
+ isFull() {
104
+ return this.buffer.length >= this.config.maxSize;
105
+ }
106
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Offerer-side WebRTC connection with offer creation and answer processing
3
+ */
4
+ import { RondevuConnection } from './connection.js';
5
+ import { RondevuAPI } from './api.js';
6
+ import { ConnectionConfig } from './connection-config.js';
7
+ export interface OffererOptions {
8
+ api: RondevuAPI;
9
+ serviceFqn: string;
10
+ offerId: string;
11
+ rtcConfig?: RTCConfiguration;
12
+ config?: Partial<ConnectionConfig>;
13
+ }
14
+ /**
15
+ * Offerer connection - creates offers and waits for answers
16
+ */
17
+ export declare class OffererConnection extends RondevuConnection {
18
+ private api;
19
+ private serviceFqn;
20
+ private offerId;
21
+ constructor(options: OffererOptions);
22
+ /**
23
+ * Initialize the connection by creating offer
24
+ */
25
+ initialize(): Promise<void>;
26
+ /**
27
+ * Process an answer from the answerer
28
+ */
29
+ processAnswer(sdp: string, answererId: string): Promise<void>;
30
+ /**
31
+ * Generate a hash fingerprint of SDP for deduplication
32
+ */
33
+ private hashSdp;
34
+ /**
35
+ * Handle local ICE candidate generation
36
+ */
37
+ protected onLocalIceCandidate(candidate: RTCIceCandidate): void;
38
+ /**
39
+ * Poll for remote ICE candidates
40
+ */
41
+ protected pollIceCandidates(): void;
42
+ /**
43
+ * Attempt to reconnect
44
+ */
45
+ protected attemptReconnect(): void;
46
+ /**
47
+ * Get the offer ID
48
+ */
49
+ getOfferId(): string;
50
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Offerer-side WebRTC connection with offer creation and answer processing
3
+ */
4
+ import { RondevuConnection } from './connection.js';
5
+ import { ConnectionState } from './connection-events.js';
6
+ /**
7
+ * Offerer connection - creates offers and waits for answers
8
+ */
9
+ export class OffererConnection extends RondevuConnection {
10
+ constructor(options) {
11
+ super(options.rtcConfig, options.config);
12
+ this.api = options.api;
13
+ this.serviceFqn = options.serviceFqn;
14
+ this.offerId = options.offerId;
15
+ }
16
+ /**
17
+ * Initialize the connection by creating offer
18
+ */
19
+ async initialize() {
20
+ 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
+ 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);
33
+ // Start connection timeout
34
+ 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');
39
+ }
40
+ /**
41
+ * Process an answer from the answerer
42
+ */
43
+ async processAnswer(sdp, answererId) {
44
+ if (!this.pc) {
45
+ this.debug('Cannot process answer: peer connection not initialized');
46
+ return;
47
+ }
48
+ // Generate SDP fingerprint for deduplication
49
+ const fingerprint = await this.hashSdp(sdp);
50
+ // Check for duplicate answer
51
+ if (this.answerProcessed) {
52
+ if (this.answerSdpFingerprint === fingerprint) {
53
+ this.debug('Duplicate answer detected (same fingerprint), skipping');
54
+ this.emit('answer:duplicate', this.offerId);
55
+ return;
56
+ }
57
+ else {
58
+ throw new Error('Received different answer after already processing one (protocol violation)');
59
+ }
60
+ }
61
+ // Validate state
62
+ if (this.state !== ConnectionState.SIGNALING && this.state !== ConnectionState.CHECKING) {
63
+ this.debug(`Cannot process answer in state ${this.state}`);
64
+ return;
65
+ }
66
+ // Mark as processed BEFORE setRemoteDescription to prevent race conditions
67
+ this.answerProcessed = true;
68
+ this.answerSdpFingerprint = fingerprint;
69
+ try {
70
+ await this.pc.setRemoteDescription({
71
+ type: 'answer',
72
+ sdp,
73
+ });
74
+ this.debug(`Answer processed successfully from ${answererId}`);
75
+ this.emit('answer:processed', this.offerId, answererId);
76
+ }
77
+ catch (error) {
78
+ // Reset flags on error so we can try again
79
+ this.answerProcessed = false;
80
+ this.answerSdpFingerprint = null;
81
+ this.debug('Failed to set remote description:', error);
82
+ throw error;
83
+ }
84
+ }
85
+ /**
86
+ * Generate a hash fingerprint of SDP for deduplication
87
+ */
88
+ async hashSdp(sdp) {
89
+ // Simple hash using built-in crypto if available
90
+ if (typeof crypto !== 'undefined' && crypto.subtle) {
91
+ const encoder = new TextEncoder();
92
+ const data = encoder.encode(sdp);
93
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
94
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
95
+ return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
96
+ }
97
+ else {
98
+ // Fallback: use simple string hash
99
+ let hash = 0;
100
+ for (let i = 0; i < sdp.length; i++) {
101
+ const char = sdp.charCodeAt(i);
102
+ hash = (hash << 5) - hash + char;
103
+ hash = hash & hash;
104
+ }
105
+ return hash.toString(16);
106
+ }
107
+ }
108
+ /**
109
+ * Handle local ICE candidate generation
110
+ */
111
+ onLocalIceCandidate(candidate) {
112
+ this.debug('Generated local ICE candidate');
113
+ // Send ICE candidate to server
114
+ this.api
115
+ .addOfferIceCandidates(this.serviceFqn, this.offerId, [
116
+ {
117
+ candidate: candidate.candidate,
118
+ sdpMLineIndex: candidate.sdpMLineIndex,
119
+ sdpMid: candidate.sdpMid,
120
+ },
121
+ ])
122
+ .catch((error) => {
123
+ this.debug('Failed to send ICE candidate:', error);
124
+ });
125
+ }
126
+ /**
127
+ * Poll for remote ICE candidates
128
+ */
129
+ pollIceCandidates() {
130
+ this.api
131
+ .getOfferIceCandidates(this.serviceFqn, this.offerId, this.lastIcePollTime)
132
+ .then((result) => {
133
+ if (result.candidates.length > 0) {
134
+ this.debug(`Received ${result.candidates.length} remote ICE candidates`);
135
+ for (const iceCandidate of result.candidates) {
136
+ if (iceCandidate.candidate && this.pc) {
137
+ const candidate = iceCandidate.candidate;
138
+ this.pc
139
+ .addIceCandidate(new RTCIceCandidate(candidate))
140
+ .then(() => {
141
+ this.emit('ice:candidate:remote', new RTCIceCandidate(candidate));
142
+ })
143
+ .catch((error) => {
144
+ this.debug('Failed to add ICE candidate:', error);
145
+ });
146
+ }
147
+ // Update last poll time
148
+ if (iceCandidate.createdAt > this.lastIcePollTime) {
149
+ this.lastIcePollTime = iceCandidate.createdAt;
150
+ }
151
+ }
152
+ }
153
+ })
154
+ .catch((error) => {
155
+ this.debug('Failed to poll ICE candidates:', error);
156
+ });
157
+ }
158
+ /**
159
+ * Attempt to reconnect
160
+ */
161
+ 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
+ });
186
+ }
187
+ /**
188
+ * Get the offer ID
189
+ */
190
+ getOfferId() {
191
+ return this.offerId;
192
+ }
193
+ }
package/dist/rondevu.d.ts CHANGED
@@ -1,6 +1,9 @@
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';
4
7
  export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only';
5
8
  export declare const ICE_SERVER_PRESETS: Record<IceServerPreset, RTCIceServer[]>;
6
9
  export interface RondevuOptions {
@@ -32,6 +35,7 @@ export interface PublishServiceOptions {
32
35
  maxOffers: number;
33
36
  offerFactory?: OfferFactory;
34
37
  ttl?: number;
38
+ connectionConfig?: Partial<ConnectionConfig>;
35
39
  }
36
40
  export interface ConnectionContext {
37
41
  pc: RTCPeerConnection;
@@ -44,8 +48,8 @@ export interface ConnectToServiceOptions {
44
48
  serviceFqn?: string;
45
49
  service?: string;
46
50
  username?: string;
47
- onConnection?: (context: ConnectionContext) => void | Promise<void>;
48
51
  rtcConfig?: RTCConfiguration;
52
+ connectionConfig?: Partial<ConnectionConfig>;
49
53
  }
50
54
  export interface ActiveOffer {
51
55
  offerId: string;
@@ -101,14 +105,13 @@ export declare class ConnectionError extends RondevuError {
101
105
  constructor(message: string, context?: Record<string, any>);
102
106
  }
103
107
  /**
104
- * Rondevu - Complete WebRTC signaling client
108
+ * Rondevu - Complete WebRTC signaling client with durable connections
105
109
  *
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
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.)
112
115
  *
113
116
  * @example
114
117
  * ```typescript
@@ -119,39 +122,39 @@ export declare class ConnectionError extends RondevuError {
119
122
  * iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
120
123
  * })
121
124
  *
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
- *
132
125
  * // Publish a service with automatic offer management
133
126
  * await rondevu.publishService({
134
127
  * service: 'chat:2.0.0',
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
- * }
128
+ * maxOffers: 5 // Maintain up to 5 concurrent offers
143
129
  * })
144
130
  *
145
131
  * // Start accepting connections (auto-fills offers and polls)
146
132
  * await rondevu.startFilling()
147
133
  *
148
- * // Access active connections
149
- * for (const offer of rondevu.getActiveOffers()) {
150
- * offer.dc?.addEventListener('message', (e) => console.log(e.data))
151
- * }
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
+ * })
152
154
  *
153
- * // Stop when done
154
- * rondevu.stopFilling()
155
+ * connection.on('reconnecting', (attempt) => {
156
+ * console.log(`Reconnecting, attempt ${attempt}`)
157
+ * })
155
158
  * ```
156
159
  */
157
160
  export declare class Rondevu extends EventEmitter {
@@ -172,11 +175,12 @@ export declare class Rondevu extends EventEmitter {
172
175
  private maxOffers;
173
176
  private offerFactory;
174
177
  private ttl;
175
- private activeOffers;
178
+ private activeConnections;
179
+ private connectionConfig?;
176
180
  private filling;
181
+ private fillingSemaphore;
177
182
  private pollingInterval;
178
183
  private lastPollTimestamp;
179
- private isPolling;
180
184
  private constructor();
181
185
  /**
182
186
  * Internal debug logging - only logs if debug mode is enabled
@@ -215,26 +219,22 @@ export declare class Rondevu extends EventEmitter {
215
219
  * ```typescript
216
220
  * await rondevu.publishService({
217
221
  * service: 'chat:2.0.0',
218
- * maxOffers: 5
222
+ * maxOffers: 5,
223
+ * connectionConfig: {
224
+ * reconnectEnabled: true,
225
+ * bufferEnabled: true
226
+ * }
219
227
  * })
220
228
  * await rondevu.startFilling()
221
229
  * ```
222
230
  */
223
231
  publishService(options: PublishServiceOptions): Promise<void>;
224
232
  /**
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
233
+ * Create a single offer and publish it to the server using OffererConnection
234
234
  */
235
235
  private createOffer;
236
236
  /**
237
- * Fill offers to reach maxOffers count
237
+ * Fill offers to reach maxOffers count with semaphore protection
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 has been answered
262
+ * @returns True if the offer exists and is connected
263
263
  */
264
264
  isConnected(offerId: string): boolean;
265
265
  /**
@@ -283,41 +283,45 @@ export declare class Rondevu extends EventEmitter {
283
283
  */
284
284
  private resolveServiceFqn;
285
285
  /**
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
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
293
290
  *
294
291
  * @example
295
292
  * ```typescript
296
293
  * // Connect to specific user
297
294
  * const connection = await rondevu.connectToService({
298
295
  * serviceFqn: 'chat:2.0.0@alice',
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!'))
296
+ * connectionConfig: {
297
+ * reconnectEnabled: true,
298
+ * bufferEnabled: true
303
299
  * }
304
300
  * })
305
301
  *
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
+ *
306
315
  * // Discover random service
307
316
  * const connection = await rondevu.connectToService({
308
- * service: 'chat:2.0.0',
309
- * onConnection: ({ dc, peerUsername }) => {
310
- * console.log('Connected to', peerUsername)
311
- * }
317
+ * service: 'chat:2.0.0'
312
318
  * })
313
319
  * ```
314
320
  */
315
- connectToService(options: ConnectToServiceOptions): Promise<ConnectionContext>;
321
+ connectToService(options: ConnectToServiceOptions): Promise<AnswererConnection>;
316
322
  /**
317
323
  * Find a service - unified discovery method
318
324
  *
319
- * Replaces getService(), discoverService(), and discoverServices() with a single method.
320
- *
321
325
  * @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
322
326
  * @param options - Discovery options
323
327
  *
@@ -399,6 +403,15 @@ export declare class Rondevu extends EventEmitter {
399
403
  * Get the public key
400
404
  */
401
405
  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[];
402
415
  /**
403
416
  * Access to underlying API for advanced operations
404
417
  * @deprecated Use direct methods on Rondevu instance instead