@xtr-dev/rondevu-client 0.10.0 → 0.10.2

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.
@@ -48,7 +48,7 @@ export class RondevuService {
48
48
  // Register with API if no credentials provided
49
49
  if (!this.api['credentials']) {
50
50
  const credentials = await this.api.register();
51
- this.api.credentials = credentials;
51
+ this.api.setCredentials(credentials);
52
52
  }
53
53
  }
54
54
  /**
@@ -70,7 +70,7 @@ export class RondevuService {
70
70
  throw new Error(`Username "${this.username}" is already claimed by another user`);
71
71
  }
72
72
  // Generate signature for username claim
73
- const message = `claim-username-${this.username}-${Date.now()}`;
73
+ const message = `claim:${this.username}:${Date.now()}`;
74
74
  const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey);
75
75
  // Claim the username
76
76
  await this.api.claimUsername(this.username, this.keypair.publicKey, signature, message);
@@ -88,7 +88,7 @@ export class RondevuService {
88
88
  }
89
89
  const { serviceFqn, sdp, ttl, isPublic, metadata } = options;
90
90
  // Generate signature for service publication
91
- const message = `publish-${this.username}-${serviceFqn}-${Date.now()}`;
91
+ const message = `publish:${this.username}:${serviceFqn}:${Date.now()}`;
92
92
  const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey);
93
93
  // Create service request
94
94
  const serviceRequest = {
@@ -110,6 +110,12 @@ export class RondevuService {
110
110
  getKeypair() {
111
111
  return this.keypair;
112
112
  }
113
+ /**
114
+ * Get the username
115
+ */
116
+ getUsername() {
117
+ return this.username;
118
+ }
113
119
  /**
114
120
  * Get the public key
115
121
  */
@@ -0,0 +1,110 @@
1
+ import { Signaler } from './types.js';
2
+ import { RondevuService } from './rondevu-service.js';
3
+ import { Binnable } from './bin.js';
4
+ export interface PollingConfig {
5
+ initialInterval?: number;
6
+ maxInterval?: number;
7
+ backoffMultiplier?: number;
8
+ maxRetries?: number;
9
+ jitter?: boolean;
10
+ }
11
+ /**
12
+ * RondevuSignaler - Handles WebRTC signaling via Rondevu service
13
+ *
14
+ * Manages offer/answer exchange and ICE candidate polling for establishing
15
+ * WebRTC connections through the Rondevu signaling server.
16
+ *
17
+ * Supports configurable polling with exponential backoff and jitter to reduce
18
+ * server load and prevent thundering herd issues.
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * const signaler = new RondevuSignaler(
23
+ * rondevuService,
24
+ * 'chat.app@1.0.0',
25
+ * 'peer-username',
26
+ * { initialInterval: 500, maxInterval: 5000, jitter: true }
27
+ * )
28
+ *
29
+ * // For offerer:
30
+ * await signaler.setOffer(offer)
31
+ * signaler.addAnswerListener(answer => {
32
+ * // Handle remote answer
33
+ * })
34
+ *
35
+ * // For answerer:
36
+ * signaler.addOfferListener(offer => {
37
+ * // Handle remote offer
38
+ * })
39
+ * await signaler.setAnswer(answer)
40
+ * ```
41
+ */
42
+ export declare class RondevuSignaler implements Signaler {
43
+ private readonly rondevu;
44
+ private readonly service;
45
+ private readonly host?;
46
+ private offerId;
47
+ private serviceUuid;
48
+ private offerListeners;
49
+ private answerListeners;
50
+ private iceListeners;
51
+ private answerPollingTimeout;
52
+ private icePollingTimeout;
53
+ private lastIceTimestamp;
54
+ private isPolling;
55
+ private pollingConfig;
56
+ constructor(rondevu: RondevuService, service: string, host?: string | undefined, pollingConfig?: PollingConfig);
57
+ /**
58
+ * Publish an offer as a service
59
+ * Used by the offerer to make their offer available
60
+ */
61
+ setOffer(offer: RTCSessionDescriptionInit): Promise<void>;
62
+ /**
63
+ * Send an answer to the offerer
64
+ * Used by the answerer to respond to an offer
65
+ */
66
+ setAnswer(answer: RTCSessionDescriptionInit): Promise<void>;
67
+ /**
68
+ * Listen for incoming offers
69
+ * Used by the answerer to receive offers from the offerer
70
+ */
71
+ addOfferListener(callback: (offer: RTCSessionDescriptionInit) => void): Binnable;
72
+ /**
73
+ * Listen for incoming answers
74
+ * Used by the offerer to receive the answer from the answerer
75
+ */
76
+ addAnswerListener(callback: (answer: RTCSessionDescriptionInit) => void): Binnable;
77
+ /**
78
+ * Send an ICE candidate to the remote peer
79
+ */
80
+ addIceCandidate(candidate: RTCIceCandidate): Promise<void>;
81
+ /**
82
+ * Listen for ICE candidates from the remote peer
83
+ */
84
+ addListener(callback: (candidate: RTCIceCandidate) => void): Binnable;
85
+ /**
86
+ * Search for an offer from the host
87
+ * Used by the answerer to find the offerer's service
88
+ */
89
+ private searchForOffer;
90
+ /**
91
+ * Start polling for answer (offerer side) with exponential backoff
92
+ */
93
+ private startAnswerPolling;
94
+ /**
95
+ * Stop polling for answer
96
+ */
97
+ private stopAnswerPolling;
98
+ /**
99
+ * Start polling for ICE candidates with adaptive backoff
100
+ */
101
+ private startIcePolling;
102
+ /**
103
+ * Stop polling for ICE candidates
104
+ */
105
+ private stopIcePolling;
106
+ /**
107
+ * Stop all polling and cleanup
108
+ */
109
+ dispose(): void;
110
+ }
@@ -0,0 +1,361 @@
1
+ /**
2
+ * RondevuSignaler - Handles WebRTC signaling via Rondevu service
3
+ *
4
+ * Manages offer/answer exchange and ICE candidate polling for establishing
5
+ * WebRTC connections through the Rondevu signaling server.
6
+ *
7
+ * Supports configurable polling with exponential backoff and jitter to reduce
8
+ * server load and prevent thundering herd issues.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * const signaler = new RondevuSignaler(
13
+ * rondevuService,
14
+ * 'chat.app@1.0.0',
15
+ * 'peer-username',
16
+ * { initialInterval: 500, maxInterval: 5000, jitter: true }
17
+ * )
18
+ *
19
+ * // For offerer:
20
+ * await signaler.setOffer(offer)
21
+ * signaler.addAnswerListener(answer => {
22
+ * // Handle remote answer
23
+ * })
24
+ *
25
+ * // For answerer:
26
+ * signaler.addOfferListener(offer => {
27
+ * // Handle remote offer
28
+ * })
29
+ * await signaler.setAnswer(answer)
30
+ * ```
31
+ */
32
+ export class RondevuSignaler {
33
+ constructor(rondevu, service, host, pollingConfig) {
34
+ this.rondevu = rondevu;
35
+ this.service = service;
36
+ this.host = host;
37
+ this.offerId = null;
38
+ this.serviceUuid = null;
39
+ this.offerListeners = [];
40
+ this.answerListeners = [];
41
+ this.iceListeners = [];
42
+ this.answerPollingTimeout = null;
43
+ this.icePollingTimeout = null;
44
+ this.lastIceTimestamp = 0;
45
+ this.isPolling = false;
46
+ this.pollingConfig = {
47
+ initialInterval: pollingConfig?.initialInterval ?? 500,
48
+ maxInterval: pollingConfig?.maxInterval ?? 5000,
49
+ backoffMultiplier: pollingConfig?.backoffMultiplier ?? 1.5,
50
+ maxRetries: pollingConfig?.maxRetries ?? 50,
51
+ jitter: pollingConfig?.jitter ?? true
52
+ };
53
+ }
54
+ /**
55
+ * Publish an offer as a service
56
+ * Used by the offerer to make their offer available
57
+ */
58
+ async setOffer(offer) {
59
+ if (!offer.sdp) {
60
+ throw new Error('Offer SDP is required');
61
+ }
62
+ // Publish service with the offer SDP
63
+ const publishedService = await this.rondevu.publishService({
64
+ serviceFqn: this.service,
65
+ sdp: offer.sdp,
66
+ ttl: 300000, // 5 minutes
67
+ isPublic: true,
68
+ });
69
+ this.offerId = publishedService.offerId;
70
+ this.serviceUuid = publishedService.uuid;
71
+ // Start polling for answer
72
+ this.startAnswerPolling();
73
+ // Start polling for ICE candidates
74
+ this.startIcePolling();
75
+ }
76
+ /**
77
+ * Send an answer to the offerer
78
+ * Used by the answerer to respond to an offer
79
+ */
80
+ async setAnswer(answer) {
81
+ if (!answer.sdp) {
82
+ throw new Error('Answer SDP is required');
83
+ }
84
+ if (!this.offerId) {
85
+ throw new Error('No offer ID available. Must receive offer first.');
86
+ }
87
+ // Send answer to the offer
88
+ await this.rondevu.getAPI().answerOffer(this.offerId, answer.sdp);
89
+ // Start polling for ICE candidates
90
+ this.startIcePolling();
91
+ }
92
+ /**
93
+ * Listen for incoming offers
94
+ * Used by the answerer to receive offers from the offerer
95
+ */
96
+ addOfferListener(callback) {
97
+ this.offerListeners.push(callback);
98
+ // If we have a host, start searching for their service
99
+ if (this.host && !this.isPolling) {
100
+ this.searchForOffer();
101
+ }
102
+ // Return cleanup function
103
+ return () => {
104
+ const index = this.offerListeners.indexOf(callback);
105
+ if (index > -1) {
106
+ this.offerListeners.splice(index, 1);
107
+ }
108
+ };
109
+ }
110
+ /**
111
+ * Listen for incoming answers
112
+ * Used by the offerer to receive the answer from the answerer
113
+ */
114
+ addAnswerListener(callback) {
115
+ this.answerListeners.push(callback);
116
+ // Return cleanup function
117
+ return () => {
118
+ const index = this.answerListeners.indexOf(callback);
119
+ if (index > -1) {
120
+ this.answerListeners.splice(index, 1);
121
+ }
122
+ };
123
+ }
124
+ /**
125
+ * Send an ICE candidate to the remote peer
126
+ */
127
+ async addIceCandidate(candidate) {
128
+ if (!this.offerId) {
129
+ console.warn('Cannot send ICE candidate: no offer ID');
130
+ return;
131
+ }
132
+ const candidateData = candidate.toJSON();
133
+ // Skip empty candidates
134
+ if (!candidateData.candidate || candidateData.candidate === '') {
135
+ return;
136
+ }
137
+ try {
138
+ await this.rondevu.getAPI().addIceCandidates(this.offerId, [candidateData]);
139
+ }
140
+ catch (err) {
141
+ console.error('Failed to send ICE candidate:', err);
142
+ }
143
+ }
144
+ /**
145
+ * Listen for ICE candidates from the remote peer
146
+ */
147
+ addListener(callback) {
148
+ this.iceListeners.push(callback);
149
+ // Return cleanup function
150
+ return () => {
151
+ const index = this.iceListeners.indexOf(callback);
152
+ if (index > -1) {
153
+ this.iceListeners.splice(index, 1);
154
+ }
155
+ };
156
+ }
157
+ /**
158
+ * Search for an offer from the host
159
+ * Used by the answerer to find the offerer's service
160
+ */
161
+ async searchForOffer() {
162
+ if (!this.host) {
163
+ throw new Error('No host specified for offer search');
164
+ }
165
+ this.isPolling = true;
166
+ try {
167
+ // Search for services by username and service FQN
168
+ const services = await this.rondevu.getAPI().searchServices(this.host, this.service);
169
+ if (services.length === 0) {
170
+ console.warn(`No services found for ${this.host}/${this.service}`);
171
+ this.isPolling = false;
172
+ return;
173
+ }
174
+ // Get the first available service (already has full details from searchServices)
175
+ const service = services[0];
176
+ this.offerId = service.offerId;
177
+ this.serviceUuid = service.uuid;
178
+ // Notify offer listeners
179
+ const offer = {
180
+ type: 'offer',
181
+ sdp: service.sdp,
182
+ };
183
+ this.offerListeners.forEach(listener => {
184
+ try {
185
+ listener(offer);
186
+ }
187
+ catch (err) {
188
+ console.error('Offer listener error:', err);
189
+ }
190
+ });
191
+ }
192
+ catch (err) {
193
+ console.error('Failed to search for offer:', err);
194
+ this.isPolling = false;
195
+ }
196
+ }
197
+ /**
198
+ * Start polling for answer (offerer side) with exponential backoff
199
+ */
200
+ startAnswerPolling() {
201
+ if (this.answerPollingTimeout || !this.offerId) {
202
+ return;
203
+ }
204
+ let interval = this.pollingConfig.initialInterval;
205
+ let retries = 0;
206
+ const poll = async () => {
207
+ if (!this.offerId) {
208
+ this.stopAnswerPolling();
209
+ return;
210
+ }
211
+ try {
212
+ const answer = await this.rondevu.getAPI().getAnswer(this.offerId);
213
+ if (answer && answer.sdp) {
214
+ // Got answer - notify listeners and stop polling
215
+ const answerDesc = {
216
+ type: 'answer',
217
+ sdp: answer.sdp,
218
+ };
219
+ this.answerListeners.forEach(listener => {
220
+ try {
221
+ listener(answerDesc);
222
+ }
223
+ catch (err) {
224
+ console.error('Answer listener error:', err);
225
+ }
226
+ });
227
+ // Stop polling once we get the answer
228
+ this.stopAnswerPolling();
229
+ return;
230
+ }
231
+ // No answer yet - exponential backoff
232
+ retries++;
233
+ if (retries > this.pollingConfig.maxRetries) {
234
+ console.warn('Max retries reached for answer polling');
235
+ this.stopAnswerPolling();
236
+ return;
237
+ }
238
+ interval = Math.min(interval * this.pollingConfig.backoffMultiplier, this.pollingConfig.maxInterval);
239
+ // Add jitter to prevent thundering herd
240
+ const finalInterval = this.pollingConfig.jitter
241
+ ? interval + Math.random() * 100
242
+ : interval;
243
+ this.answerPollingTimeout = setTimeout(poll, finalInterval);
244
+ }
245
+ catch (err) {
246
+ // 404 is expected when answer isn't available yet
247
+ if (err instanceof Error && !err.message?.includes('404')) {
248
+ console.error('Error polling for answer:', err);
249
+ }
250
+ // Retry with backoff
251
+ const finalInterval = this.pollingConfig.jitter
252
+ ? interval + Math.random() * 100
253
+ : interval;
254
+ this.answerPollingTimeout = setTimeout(poll, finalInterval);
255
+ }
256
+ };
257
+ poll(); // Start immediately
258
+ }
259
+ /**
260
+ * Stop polling for answer
261
+ */
262
+ stopAnswerPolling() {
263
+ if (this.answerPollingTimeout) {
264
+ clearTimeout(this.answerPollingTimeout);
265
+ this.answerPollingTimeout = null;
266
+ }
267
+ }
268
+ /**
269
+ * Start polling for ICE candidates with adaptive backoff
270
+ */
271
+ startIcePolling() {
272
+ if (this.icePollingTimeout || !this.offerId) {
273
+ return;
274
+ }
275
+ let interval = this.pollingConfig.initialInterval;
276
+ const poll = async () => {
277
+ if (!this.offerId) {
278
+ this.stopIcePolling();
279
+ return;
280
+ }
281
+ try {
282
+ const candidates = await this.rondevu
283
+ .getAPI()
284
+ .getIceCandidates(this.offerId, this.lastIceTimestamp);
285
+ let foundCandidates = false;
286
+ for (const item of candidates) {
287
+ if (item.candidate && item.candidate.candidate && item.candidate.candidate !== '') {
288
+ foundCandidates = true;
289
+ try {
290
+ const rtcCandidate = new RTCIceCandidate(item.candidate);
291
+ this.iceListeners.forEach(listener => {
292
+ try {
293
+ listener(rtcCandidate);
294
+ }
295
+ catch (err) {
296
+ console.error('ICE listener error:', err);
297
+ }
298
+ });
299
+ this.lastIceTimestamp = item.createdAt;
300
+ }
301
+ catch (err) {
302
+ console.warn('Failed to process ICE candidate:', err);
303
+ this.lastIceTimestamp = item.createdAt;
304
+ }
305
+ }
306
+ else {
307
+ this.lastIceTimestamp = item.createdAt;
308
+ }
309
+ }
310
+ // If candidates found, reset interval to initial value
311
+ // Otherwise, increase interval with backoff
312
+ if (foundCandidates) {
313
+ interval = this.pollingConfig.initialInterval;
314
+ }
315
+ else {
316
+ interval = Math.min(interval * this.pollingConfig.backoffMultiplier, this.pollingConfig.maxInterval);
317
+ }
318
+ // Add jitter
319
+ const finalInterval = this.pollingConfig.jitter
320
+ ? interval + Math.random() * 100
321
+ : interval;
322
+ this.icePollingTimeout = setTimeout(poll, finalInterval);
323
+ }
324
+ catch (err) {
325
+ // 404/410 means offer expired, stop polling
326
+ if (err instanceof Error && (err.message?.includes('404') || err.message?.includes('410'))) {
327
+ console.warn('Offer not found or expired, stopping ICE polling');
328
+ this.stopIcePolling();
329
+ }
330
+ else if (err instanceof Error && !err.message?.includes('404')) {
331
+ console.error('Error polling for ICE candidates:', err);
332
+ // Continue polling despite errors
333
+ const finalInterval = this.pollingConfig.jitter
334
+ ? interval + Math.random() * 100
335
+ : interval;
336
+ this.icePollingTimeout = setTimeout(poll, finalInterval);
337
+ }
338
+ }
339
+ };
340
+ poll(); // Start immediately
341
+ }
342
+ /**
343
+ * Stop polling for ICE candidates
344
+ */
345
+ stopIcePolling() {
346
+ if (this.icePollingTimeout) {
347
+ clearTimeout(this.icePollingTimeout);
348
+ this.icePollingTimeout = null;
349
+ }
350
+ }
351
+ /**
352
+ * Stop all polling and cleanup
353
+ */
354
+ dispose() {
355
+ this.stopAnswerPolling();
356
+ this.stopIcePolling();
357
+ this.offerListeners = [];
358
+ this.answerListeners = [];
359
+ this.iceListeners = [];
360
+ }
361
+ }
@@ -1,20 +1,17 @@
1
- import { WebRTCRondevuConnection } from './connection.js';
2
1
  import { RondevuService } from './rondevu-service.js';
2
+ import { RTCDurableConnection } from './durable-connection.js';
3
3
  import { EventBus } from './event-bus.js';
4
- import { ConnectionInterface } from './types.js';
5
4
  export interface ServiceClientOptions {
6
5
  username: string;
7
6
  serviceFqn: string;
8
7
  rondevuService: RondevuService;
9
8
  autoReconnect?: boolean;
10
- reconnectDelay?: number;
11
9
  maxReconnectAttempts?: number;
10
+ rtcConfiguration?: RTCConfiguration;
12
11
  }
13
12
  export interface ServiceClientEvents {
14
- connected: ConnectionInterface;
15
- disconnected: {
16
- reason: string;
17
- };
13
+ connected: RTCDurableConnection;
14
+ disconnected: void;
18
15
  reconnecting: {
19
16
  attempt: number;
20
17
  maxAttempts: number;
@@ -22,71 +19,59 @@ export interface ServiceClientEvents {
22
19
  error: Error;
23
20
  }
24
21
  /**
25
- * ServiceClient - Connects to a hosted service
22
+ * ServiceClient - High-level wrapper for connecting to a WebRTC service
26
23
  *
27
- * Searches for available service offers and establishes a WebRTC connection.
28
- * Optionally supports automatic reconnection on failure.
24
+ * Simplifies client connection by handling:
25
+ * - Service discovery
26
+ * - Offer/answer exchange
27
+ * - ICE candidate polling
28
+ * - Automatic reconnection
29
29
  *
30
30
  * @example
31
31
  * ```typescript
32
- * const rondevuService = new RondevuService({
33
- * apiUrl: 'https://signal.example.com',
34
- * username: 'client-user',
32
+ * const client = new ServiceClient({
33
+ * username: 'host-user',
34
+ * serviceFqn: 'chat.app@1.0.0',
35
+ * rondevuService: myService
35
36
  * })
36
37
  *
37
- * await rondevuService.initialize()
38
- *
39
- * const client = new ServiceClient({
40
- * username: 'host-user',
41
- * serviceFqn: 'chat.app@1.0.0',
42
- * rondevuService,
43
- * autoReconnect: true,
38
+ * client.events.on('connected', conn => {
39
+ * conn.events.on('message', msg => console.log('Received:', msg))
40
+ * conn.sendMessage('Hello from client!')
44
41
  * })
45
42
  *
46
43
  * await client.connect()
47
- *
48
- * client.events.on('connected', (conn) => {
49
- * console.log('Connected to service')
50
- * conn.sendMessage('Hello!')
51
- * })
52
44
  * ```
53
45
  */
54
46
  export declare class ServiceClient {
55
- private readonly username;
56
- private readonly serviceFqn;
57
- private readonly rondevuService;
58
- private readonly autoReconnect;
59
- private readonly reconnectDelay;
60
- private readonly maxReconnectAttempts;
47
+ private options;
48
+ events: EventBus<ServiceClientEvents>;
49
+ private signaler;
50
+ private webrtcContext;
61
51
  private connection;
52
+ private autoReconnect;
53
+ private maxReconnectAttempts;
62
54
  private reconnectAttempts;
63
- private reconnectTimeout;
64
- private readonly bin;
65
55
  private isConnecting;
66
- readonly events: EventBus<ServiceClientEvents>;
67
56
  constructor(options: ServiceClientOptions);
68
57
  /**
69
58
  * Connect to the service
70
59
  */
71
- connect(): Promise<WebRTCRondevuConnection>;
60
+ connect(): Promise<RTCDurableConnection>;
72
61
  /**
73
62
  * Disconnect from the service
74
63
  */
75
- disconnect(): void;
76
- /**
77
- * Get the current connection
78
- */
79
- getConnection(): WebRTCRondevuConnection | null;
64
+ dispose(): void;
80
65
  /**
81
- * Check if currently connected
66
+ * @deprecated Use dispose() instead
82
67
  */
83
- isConnected(): boolean;
68
+ disconnect(): void;
84
69
  /**
85
- * Handle connection state changes
70
+ * Attempt to reconnect
86
71
  */
87
- private handleConnectionStateChange;
72
+ private attemptReconnect;
88
73
  /**
89
- * Schedule a reconnection attempt
74
+ * Get the current connection
90
75
  */
91
- private scheduleReconnect;
76
+ getConnection(): RTCDurableConnection | null;
92
77
  }