@xtr-dev/rondevu-client 0.13.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Node.js Crypto adapter for Node.js environments
3
+ * Requires Node.js 19+ or Node.js 18 with --experimental-global-webcrypto flag
4
+ */
5
+ import * as ed25519 from '@noble/ed25519';
6
+ /**
7
+ * Node.js Crypto implementation using Node.js built-in APIs
8
+ * Uses Buffer for base64 encoding and crypto.randomBytes for random generation
9
+ *
10
+ * Requirements:
11
+ * - Node.js 19+ (crypto.subtle available globally)
12
+ * - OR Node.js 18 with --experimental-global-webcrypto flag
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * import { RondevuAPI } from '@xtr-dev/rondevu-client'
17
+ * import { NodeCryptoAdapter } from '@xtr-dev/rondevu-client/node'
18
+ *
19
+ * const api = new RondevuAPI(
20
+ * 'https://signal.example.com',
21
+ * 'alice',
22
+ * keypair,
23
+ * new NodeCryptoAdapter()
24
+ * )
25
+ * ```
26
+ */
27
+ export class NodeCryptoAdapter {
28
+ constructor() {
29
+ // Set SHA-512 hash function for ed25519 using Node's crypto.subtle
30
+ if (typeof crypto === 'undefined' || !crypto.subtle) {
31
+ throw new Error('crypto.subtle is not available. ' +
32
+ 'Node.js 19+ is required, or Node.js 18 with --experimental-global-webcrypto flag');
33
+ }
34
+ ed25519.hashes.sha512Async = async (message) => {
35
+ const hash = await crypto.subtle.digest('SHA-512', message);
36
+ return new Uint8Array(hash);
37
+ };
38
+ }
39
+ async generateKeypair() {
40
+ const privateKey = ed25519.utils.randomSecretKey();
41
+ const publicKey = await ed25519.getPublicKeyAsync(privateKey);
42
+ return {
43
+ publicKey: this.bytesToBase64(publicKey),
44
+ privateKey: this.bytesToBase64(privateKey),
45
+ };
46
+ }
47
+ async signMessage(message, privateKeyBase64) {
48
+ const privateKey = this.base64ToBytes(privateKeyBase64);
49
+ const encoder = new TextEncoder();
50
+ const messageBytes = encoder.encode(message);
51
+ const signature = await ed25519.signAsync(messageBytes, privateKey);
52
+ return this.bytesToBase64(signature);
53
+ }
54
+ async verifySignature(message, signatureBase64, publicKeyBase64) {
55
+ try {
56
+ const signature = this.base64ToBytes(signatureBase64);
57
+ const publicKey = this.base64ToBytes(publicKeyBase64);
58
+ const encoder = new TextEncoder();
59
+ const messageBytes = encoder.encode(message);
60
+ return await ed25519.verifyAsync(signature, messageBytes, publicKey);
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ bytesToBase64(bytes) {
67
+ // Node.js Buffer provides native base64 encoding
68
+ // @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
69
+ return Buffer.from(bytes).toString('base64');
70
+ }
71
+ base64ToBytes(base64) {
72
+ // Node.js Buffer provides native base64 decoding
73
+ // @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
74
+ return new Uint8Array(Buffer.from(base64, 'base64'));
75
+ }
76
+ randomBytes(length) {
77
+ // Use Web Crypto API's getRandomValues (available in Node 19+)
78
+ return crypto.getRandomValues(new Uint8Array(length));
79
+ }
80
+ }
@@ -47,10 +47,11 @@ export declare class RondevuSignaler implements Signaler {
47
47
  private offerListeners;
48
48
  private answerListeners;
49
49
  private iceListeners;
50
- private answerPollingTimeout;
50
+ private pollingTimeout;
51
51
  private icePollingTimeout;
52
- private lastIceTimestamp;
52
+ private lastPollTimestamp;
53
53
  private isPolling;
54
+ private isOfferer;
54
55
  private pollingConfig;
55
56
  constructor(rondevu: Rondevu, service: string, host?: string | undefined, pollingConfig?: PollingConfig);
56
57
  /**
@@ -87,15 +88,17 @@ export declare class RondevuSignaler implements Signaler {
87
88
  */
88
89
  private searchForOffer;
89
90
  /**
90
- * Start polling for answer (offerer side) with exponential backoff
91
+ * Start combined polling for answers and ICE candidates (offerer side)
92
+ * Uses poll() for efficient batch polling
91
93
  */
92
- private startAnswerPolling;
94
+ private startPolling;
93
95
  /**
94
- * Stop polling for answer
96
+ * Stop combined polling
95
97
  */
96
- private stopAnswerPolling;
98
+ private stopPolling;
97
99
  /**
98
- * Start polling for ICE candidates with adaptive backoff
100
+ * Start polling for ICE candidates (answerer side only)
101
+ * Answerers use the separate endpoint since they don't have offers to poll
99
102
  */
100
103
  private startIcePolling;
101
104
  /**
@@ -39,10 +39,11 @@ export class RondevuSignaler {
39
39
  this.offerListeners = [];
40
40
  this.answerListeners = [];
41
41
  this.iceListeners = [];
42
- this.answerPollingTimeout = null;
42
+ this.pollingTimeout = null;
43
43
  this.icePollingTimeout = null;
44
- this.lastIceTimestamp = 0;
44
+ this.lastPollTimestamp = 0;
45
45
  this.isPolling = false;
46
+ this.isOfferer = false;
46
47
  this.pollingConfig = {
47
48
  initialInterval: pollingConfig?.initialInterval ?? 500,
48
49
  maxInterval: pollingConfig?.maxInterval ?? 5000,
@@ -71,10 +72,9 @@ export class RondevuSignaler {
71
72
  }
72
73
  this.offerId = publishedService.offers[0].offerId;
73
74
  this.serviceFqn = publishedService.serviceFqn;
74
- // Start polling for answer
75
- this.startAnswerPolling();
76
- // Start polling for ICE candidates
77
- this.startIcePolling();
75
+ this.isOfferer = true;
76
+ // Start combined polling for answers and ICE candidates
77
+ this.startPolling();
78
78
  }
79
79
  /**
80
80
  * Send an answer to the offerer
@@ -88,9 +88,9 @@ export class RondevuSignaler {
88
88
  throw new Error('No service FQN or offer ID available. Must receive offer first.');
89
89
  }
90
90
  // Send answer to the service
91
- const result = await this.rondevu.getAPI().postOfferAnswer(this.serviceFqn, this.offerId, answer.sdp);
92
- this.offerId = result.offerId;
93
- // Start polling for ICE candidates
91
+ await this.rondevu.getAPIPublic().answerOffer(this.serviceFqn, this.offerId, answer.sdp);
92
+ this.isOfferer = false;
93
+ // Start polling for ICE candidates (answerer uses separate endpoint)
94
94
  this.startIcePolling();
95
95
  }
96
96
  /**
@@ -139,7 +139,7 @@ export class RondevuSignaler {
139
139
  return;
140
140
  }
141
141
  try {
142
- await this.rondevu.getAPI().addOfferIceCandidates(this.serviceFqn, this.offerId, [candidateData]);
142
+ await this.rondevu.getAPIPublic().addOfferIceCandidates(this.serviceFqn, this.offerId, [candidateData]);
143
143
  }
144
144
  catch (err) {
145
145
  console.error('Failed to send ICE candidate:', err);
@@ -170,7 +170,7 @@ export class RondevuSignaler {
170
170
  try {
171
171
  // Get service by FQN (service should include @username)
172
172
  const serviceFqn = `${this.service}@${this.host}`;
173
- const serviceData = await this.rondevu.getAPI().getService(serviceFqn);
173
+ const serviceData = await this.rondevu.getAPIPublic().getService(serviceFqn);
174
174
  if (!serviceData) {
175
175
  console.warn(`No service found for ${serviceFqn}`);
176
176
  this.isPolling = false;
@@ -199,85 +199,117 @@ export class RondevuSignaler {
199
199
  }
200
200
  }
201
201
  /**
202
- * Start polling for answer (offerer side) with exponential backoff
202
+ * Start combined polling for answers and ICE candidates (offerer side)
203
+ * Uses poll() for efficient batch polling
203
204
  */
204
- startAnswerPolling() {
205
- if (this.answerPollingTimeout || !this.serviceFqn || !this.offerId) {
205
+ startPolling() {
206
+ if (this.pollingTimeout || !this.isOfferer) {
206
207
  return;
207
208
  }
208
209
  let interval = this.pollingConfig.initialInterval;
209
210
  let retries = 0;
211
+ let answerReceived = false;
210
212
  const poll = async () => {
211
- if (!this.serviceFqn || !this.offerId) {
212
- this.stopAnswerPolling();
213
- return;
214
- }
215
213
  try {
216
- const answer = await this.rondevu.getAPI().getOfferAnswer(this.serviceFqn, this.offerId);
217
- if (answer && answer.sdp) {
218
- // Store offerId if we didn't have it yet
219
- if (!this.offerId) {
220
- this.offerId = answer.offerId;
214
+ const result = await this.rondevu.poll(this.lastPollTimestamp);
215
+ let foundActivity = false;
216
+ // Process answers
217
+ if (result.answers.length > 0 && !answerReceived) {
218
+ foundActivity = true;
219
+ // Find answer for our offerId
220
+ const answer = result.answers.find(a => a.offerId === this.offerId);
221
+ if (answer && answer.sdp) {
222
+ answerReceived = true;
223
+ const answerDesc = {
224
+ type: 'answer',
225
+ sdp: answer.sdp,
226
+ };
227
+ this.answerListeners.forEach(listener => {
228
+ try {
229
+ listener(answerDesc);
230
+ }
231
+ catch (err) {
232
+ console.error('Answer listener error:', err);
233
+ }
234
+ });
235
+ this.lastPollTimestamp = Math.max(this.lastPollTimestamp, answer.answeredAt);
221
236
  }
222
- // Got answer - notify listeners and stop polling
223
- const answerDesc = {
224
- type: 'answer',
225
- sdp: answer.sdp,
226
- };
227
- this.answerListeners.forEach(listener => {
228
- try {
229
- listener(answerDesc);
230
- }
231
- catch (err) {
232
- console.error('Answer listener error:', err);
237
+ }
238
+ // Process ICE candidates for our offer
239
+ if (this.offerId && result.iceCandidates[this.offerId]) {
240
+ const candidates = result.iceCandidates[this.offerId];
241
+ // Filter for answerer candidates (offerer receives answerer's candidates)
242
+ const answererCandidates = candidates.filter(c => c.role === 'answerer');
243
+ if (answererCandidates.length > 0) {
244
+ foundActivity = true;
245
+ for (const item of answererCandidates) {
246
+ if (item.candidate && item.candidate.candidate && item.candidate.candidate !== '') {
247
+ try {
248
+ const rtcCandidate = new RTCIceCandidate(item.candidate);
249
+ this.iceListeners.forEach(listener => {
250
+ try {
251
+ listener(rtcCandidate);
252
+ }
253
+ catch (err) {
254
+ console.error('ICE listener error:', err);
255
+ }
256
+ });
257
+ this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt);
258
+ }
259
+ catch (err) {
260
+ console.warn('Failed to process ICE candidate:', err);
261
+ this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt);
262
+ }
263
+ }
233
264
  }
234
- });
235
- // Stop polling once we get the answer
236
- this.stopAnswerPolling();
237
- return;
265
+ }
238
266
  }
239
- // No answer yet - exponential backoff
240
- retries++;
241
- if (retries > this.pollingConfig.maxRetries) {
242
- console.warn('Max retries reached for answer polling');
243
- this.stopAnswerPolling();
244
- return;
267
+ // Adjust interval based on activity
268
+ if (foundActivity) {
269
+ interval = this.pollingConfig.initialInterval;
270
+ retries = 0;
271
+ }
272
+ else {
273
+ retries++;
274
+ if (retries > this.pollingConfig.maxRetries) {
275
+ console.warn('Max retries reached for polling');
276
+ this.stopPolling();
277
+ return;
278
+ }
279
+ interval = Math.min(interval * this.pollingConfig.backoffMultiplier, this.pollingConfig.maxInterval);
245
280
  }
246
- interval = Math.min(interval * this.pollingConfig.backoffMultiplier, this.pollingConfig.maxInterval);
247
281
  // Add jitter to prevent thundering herd
248
282
  const finalInterval = this.pollingConfig.jitter
249
283
  ? interval + Math.random() * 100
250
284
  : interval;
251
- this.answerPollingTimeout = setTimeout(poll, finalInterval);
285
+ this.pollingTimeout = setTimeout(poll, finalInterval);
252
286
  }
253
287
  catch (err) {
254
- // 404 is expected when answer isn't available yet
255
- if (err instanceof Error && !err.message?.includes('404')) {
256
- console.error('Error polling for answer:', err);
257
- }
288
+ console.error('Error polling offers:', err);
258
289
  // Retry with backoff
259
290
  const finalInterval = this.pollingConfig.jitter
260
291
  ? interval + Math.random() * 100
261
292
  : interval;
262
- this.answerPollingTimeout = setTimeout(poll, finalInterval);
293
+ this.pollingTimeout = setTimeout(poll, finalInterval);
263
294
  }
264
295
  };
265
296
  poll(); // Start immediately
266
297
  }
267
298
  /**
268
- * Stop polling for answer
299
+ * Stop combined polling
269
300
  */
270
- stopAnswerPolling() {
271
- if (this.answerPollingTimeout) {
272
- clearTimeout(this.answerPollingTimeout);
273
- this.answerPollingTimeout = null;
301
+ stopPolling() {
302
+ if (this.pollingTimeout) {
303
+ clearTimeout(this.pollingTimeout);
304
+ this.pollingTimeout = null;
274
305
  }
275
306
  }
276
307
  /**
277
- * Start polling for ICE candidates with adaptive backoff
308
+ * Start polling for ICE candidates (answerer side only)
309
+ * Answerers use the separate endpoint since they don't have offers to poll
278
310
  */
279
311
  startIcePolling() {
280
- if (this.icePollingTimeout || !this.serviceFqn || !this.offerId) {
312
+ if (this.icePollingTimeout || !this.serviceFqn || !this.offerId || this.isOfferer) {
281
313
  return;
282
314
  }
283
315
  let interval = this.pollingConfig.initialInterval;
@@ -288,8 +320,8 @@ export class RondevuSignaler {
288
320
  }
289
321
  try {
290
322
  const result = await this.rondevu
291
- .getAPI()
292
- .getOfferIceCandidates(this.serviceFqn, this.offerId, this.lastIceTimestamp);
323
+ .getAPIPublic()
324
+ .getOfferIceCandidates(this.serviceFqn, this.offerId, this.lastPollTimestamp);
293
325
  let foundCandidates = false;
294
326
  for (const item of result.candidates) {
295
327
  if (item.candidate && item.candidate.candidate && item.candidate.candidate !== '') {
@@ -304,15 +336,15 @@ export class RondevuSignaler {
304
336
  console.error('ICE listener error:', err);
305
337
  }
306
338
  });
307
- this.lastIceTimestamp = item.createdAt;
339
+ this.lastPollTimestamp = item.createdAt;
308
340
  }
309
341
  catch (err) {
310
342
  console.warn('Failed to process ICE candidate:', err);
311
- this.lastIceTimestamp = item.createdAt;
343
+ this.lastPollTimestamp = item.createdAt;
312
344
  }
313
345
  }
314
346
  else {
315
- this.lastIceTimestamp = item.createdAt;
347
+ this.lastPollTimestamp = item.createdAt;
316
348
  }
317
349
  }
318
350
  // If candidates found, reset interval to initial value
@@ -360,7 +392,7 @@ export class RondevuSignaler {
360
392
  * Stop all polling and cleanup
361
393
  */
362
394
  dispose() {
363
- this.stopAnswerPolling();
395
+ this.stopPolling();
364
396
  this.stopIcePolling();
365
397
  this.offerListeners = [];
366
398
  this.answerListeners = [];