@xtr-dev/rondevu-client 0.12.4 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +100 -381
  2. package/dist/api.d.ts +75 -96
  3. package/dist/api.js +202 -243
  4. package/dist/crypto-adapter.d.ts +37 -0
  5. package/dist/crypto-adapter.js +4 -0
  6. package/dist/index.d.ts +8 -15
  7. package/dist/index.js +5 -8
  8. package/dist/node-crypto-adapter.d.ts +35 -0
  9. package/dist/node-crypto-adapter.js +80 -0
  10. package/dist/rondevu-signaler.d.ts +14 -12
  11. package/dist/rondevu-signaler.js +111 -95
  12. package/dist/rondevu.d.ts +329 -0
  13. package/dist/rondevu.js +648 -0
  14. package/dist/rpc-batcher.d.ts +61 -0
  15. package/dist/rpc-batcher.js +111 -0
  16. package/dist/types.d.ts +8 -21
  17. package/dist/types.js +4 -6
  18. package/dist/web-crypto-adapter.d.ts +16 -0
  19. package/dist/web-crypto-adapter.js +52 -0
  20. package/package.json +1 -1
  21. package/dist/bin.d.ts +0 -35
  22. package/dist/bin.js +0 -35
  23. package/dist/connection-manager.d.ts +0 -104
  24. package/dist/connection-manager.js +0 -324
  25. package/dist/connection.d.ts +0 -112
  26. package/dist/connection.js +0 -194
  27. package/dist/durable-connection.d.ts +0 -120
  28. package/dist/durable-connection.js +0 -244
  29. package/dist/event-bus.d.ts +0 -52
  30. package/dist/event-bus.js +0 -84
  31. package/dist/noop-signaler.d.ts +0 -14
  32. package/dist/noop-signaler.js +0 -27
  33. package/dist/quick-start.d.ts +0 -29
  34. package/dist/quick-start.js +0 -44
  35. package/dist/rondevu-context.d.ts +0 -10
  36. package/dist/rondevu-context.js +0 -20
  37. package/dist/rondevu-service.d.ts +0 -87
  38. package/dist/rondevu-service.js +0 -170
  39. package/dist/service-client.d.ts +0 -77
  40. package/dist/service-client.js +0 -158
  41. package/dist/service-host.d.ts +0 -67
  42. package/dist/service-host.js +0 -120
  43. package/dist/signaler.d.ts +0 -25
  44. package/dist/signaler.js +0 -89
  45. package/dist/webrtc-context.d.ts +0 -5
  46. package/dist/webrtc-context.js +0 -35
@@ -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
+ }
@@ -1,6 +1,5 @@
1
- import { Signaler } from './types.js';
2
- import { RondevuService } from './rondevu-service.js';
3
- import { Binnable } from './bin.js';
1
+ import { Signaler, Binnable } from './types.js';
2
+ import { Rondevu } from './rondevu.js';
4
3
  export interface PollingConfig {
5
4
  initialInterval?: number;
6
5
  maxInterval?: number;
@@ -44,16 +43,17 @@ export declare class RondevuSignaler implements Signaler {
44
43
  private readonly service;
45
44
  private readonly host?;
46
45
  private offerId;
47
- private serviceUuid;
46
+ private serviceFqn;
48
47
  private offerListeners;
49
48
  private answerListeners;
50
49
  private iceListeners;
51
- private answerPollingTimeout;
50
+ private pollingTimeout;
52
51
  private icePollingTimeout;
53
- private lastIceTimestamp;
52
+ private lastPollTimestamp;
54
53
  private isPolling;
54
+ private isOfferer;
55
55
  private pollingConfig;
56
- constructor(rondevu: RondevuService, service: string, host?: string | undefined, pollingConfig?: PollingConfig);
56
+ constructor(rondevu: Rondevu, service: string, host?: string | undefined, pollingConfig?: PollingConfig);
57
57
  /**
58
58
  * Publish an offer as a service
59
59
  * Used by the offerer to make their offer available
@@ -88,15 +88,17 @@ export declare class RondevuSignaler implements Signaler {
88
88
  */
89
89
  private searchForOffer;
90
90
  /**
91
- * 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
92
93
  */
93
- private startAnswerPolling;
94
+ private startPolling;
94
95
  /**
95
- * Stop polling for answer
96
+ * Stop combined polling
96
97
  */
97
- private stopAnswerPolling;
98
+ private stopPolling;
98
99
  /**
99
- * 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
100
102
  */
101
103
  private startIcePolling;
102
104
  /**
@@ -35,14 +35,15 @@ export class RondevuSignaler {
35
35
  this.service = service;
36
36
  this.host = host;
37
37
  this.offerId = null;
38
- this.serviceUuid = null;
38
+ this.serviceFqn = null;
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,
@@ -64,18 +65,16 @@ export class RondevuSignaler {
64
65
  serviceFqn: this.service,
65
66
  offers: [{ sdp: offer.sdp }],
66
67
  ttl: 300000, // 5 minutes
67
- isPublic: true,
68
68
  });
69
69
  // Get the first offer from the published service
70
70
  if (!publishedService.offers || publishedService.offers.length === 0) {
71
71
  throw new Error('No offers returned from service publication');
72
72
  }
73
73
  this.offerId = publishedService.offers[0].offerId;
74
- this.serviceUuid = publishedService.uuid;
75
- // Start polling for answer
76
- this.startAnswerPolling();
77
- // Start polling for ICE candidates
78
- this.startIcePolling();
74
+ this.serviceFqn = publishedService.serviceFqn;
75
+ this.isOfferer = true;
76
+ // Start combined polling for answers and ICE candidates
77
+ this.startPolling();
79
78
  }
80
79
  /**
81
80
  * Send an answer to the offerer
@@ -85,13 +84,13 @@ export class RondevuSignaler {
85
84
  if (!answer.sdp) {
86
85
  throw new Error('Answer SDP is required');
87
86
  }
88
- if (!this.serviceUuid) {
89
- throw new Error('No service UUID available. Must receive offer first.');
87
+ if (!this.serviceFqn || !this.offerId) {
88
+ throw new Error('No service FQN or offer ID available. Must receive offer first.');
90
89
  }
91
90
  // Send answer to the service
92
- const result = await this.rondevu.getAPI().answerService(this.serviceUuid, answer.sdp);
93
- this.offerId = result.offerId;
94
- // 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)
95
94
  this.startIcePolling();
96
95
  }
97
96
  /**
@@ -130,8 +129,8 @@ export class RondevuSignaler {
130
129
  * Send an ICE candidate to the remote peer
131
130
  */
132
131
  async addIceCandidate(candidate) {
133
- if (!this.serviceUuid) {
134
- console.warn('Cannot send ICE candidate: no service UUID');
132
+ if (!this.serviceFqn || !this.offerId) {
133
+ console.warn('Cannot send ICE candidate: no service FQN or offer ID');
135
134
  return;
136
135
  }
137
136
  const candidateData = candidate.toJSON();
@@ -140,11 +139,7 @@ export class RondevuSignaler {
140
139
  return;
141
140
  }
142
141
  try {
143
- const result = await this.rondevu.getAPI().addServiceIceCandidates(this.serviceUuid, [candidateData], this.offerId || undefined);
144
- // Store offerId if we didn't have it yet
145
- if (!this.offerId) {
146
- this.offerId = result.offerId;
147
- }
142
+ await this.rondevu.getAPIPublic().addOfferIceCandidates(this.serviceFqn, this.offerId, [candidateData]);
148
143
  }
149
144
  catch (err) {
150
145
  console.error('Failed to send ICE candidate:', err);
@@ -173,28 +168,21 @@ export class RondevuSignaler {
173
168
  }
174
169
  this.isPolling = true;
175
170
  try {
176
- // Search for services by username and service FQN
177
- const services = await this.rondevu.getAPI().searchServices(this.host, this.service);
178
- if (services.length === 0) {
179
- console.warn(`No services found for ${this.host}/${this.service}`);
180
- this.isPolling = false;
181
- return;
182
- }
183
- // Get the first available service (already has full details from searchServices)
184
- const service = services[0];
185
- // Get the first available offer from the service
186
- if (!service.offers || service.offers.length === 0) {
187
- console.warn(`No offers available for service ${this.host}/${this.service}`);
171
+ // Get service by FQN (service should include @username)
172
+ const serviceFqn = `${this.service}@${this.host}`;
173
+ const serviceData = await this.rondevu.getAPIPublic().getService(serviceFqn);
174
+ if (!serviceData) {
175
+ console.warn(`No service found for ${serviceFqn}`);
188
176
  this.isPolling = false;
189
177
  return;
190
178
  }
191
- const firstOffer = service.offers[0];
192
- this.offerId = firstOffer.offerId;
193
- this.serviceUuid = service.uuid;
179
+ // Store service details
180
+ this.offerId = serviceData.offerId;
181
+ this.serviceFqn = serviceData.serviceFqn;
194
182
  // Notify offer listeners
195
183
  const offer = {
196
184
  type: 'offer',
197
- sdp: firstOffer.sdp,
185
+ sdp: serviceData.sdp,
198
186
  };
199
187
  this.offerListeners.forEach(listener => {
200
188
  try {
@@ -211,101 +199,129 @@ export class RondevuSignaler {
211
199
  }
212
200
  }
213
201
  /**
214
- * 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
215
204
  */
216
- startAnswerPolling() {
217
- if (this.answerPollingTimeout || !this.serviceUuid) {
205
+ startPolling() {
206
+ if (this.pollingTimeout || !this.isOfferer) {
218
207
  return;
219
208
  }
220
209
  let interval = this.pollingConfig.initialInterval;
221
210
  let retries = 0;
211
+ let answerReceived = false;
222
212
  const poll = async () => {
223
- if (!this.serviceUuid) {
224
- this.stopAnswerPolling();
225
- return;
226
- }
227
213
  try {
228
- const answer = await this.rondevu.getAPI().getServiceAnswer(this.serviceUuid);
229
- if (answer && answer.sdp) {
230
- // Store offerId if we didn't have it yet
231
- if (!this.offerId) {
232
- 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);
233
236
  }
234
- // Got answer - notify listeners and stop polling
235
- const answerDesc = {
236
- type: 'answer',
237
- sdp: answer.sdp,
238
- };
239
- this.answerListeners.forEach(listener => {
240
- try {
241
- listener(answerDesc);
242
- }
243
- catch (err) {
244
- 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
+ }
245
264
  }
246
- });
247
- // Stop polling once we get the answer
248
- this.stopAnswerPolling();
249
- return;
265
+ }
250
266
  }
251
- // No answer yet - exponential backoff
252
- retries++;
253
- if (retries > this.pollingConfig.maxRetries) {
254
- console.warn('Max retries reached for answer polling');
255
- this.stopAnswerPolling();
256
- 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);
257
280
  }
258
- interval = Math.min(interval * this.pollingConfig.backoffMultiplier, this.pollingConfig.maxInterval);
259
281
  // Add jitter to prevent thundering herd
260
282
  const finalInterval = this.pollingConfig.jitter
261
283
  ? interval + Math.random() * 100
262
284
  : interval;
263
- this.answerPollingTimeout = setTimeout(poll, finalInterval);
285
+ this.pollingTimeout = setTimeout(poll, finalInterval);
264
286
  }
265
287
  catch (err) {
266
- // 404 is expected when answer isn't available yet
267
- if (err instanceof Error && !err.message?.includes('404')) {
268
- console.error('Error polling for answer:', err);
269
- }
288
+ console.error('Error polling offers:', err);
270
289
  // Retry with backoff
271
290
  const finalInterval = this.pollingConfig.jitter
272
291
  ? interval + Math.random() * 100
273
292
  : interval;
274
- this.answerPollingTimeout = setTimeout(poll, finalInterval);
293
+ this.pollingTimeout = setTimeout(poll, finalInterval);
275
294
  }
276
295
  };
277
296
  poll(); // Start immediately
278
297
  }
279
298
  /**
280
- * Stop polling for answer
299
+ * Stop combined polling
281
300
  */
282
- stopAnswerPolling() {
283
- if (this.answerPollingTimeout) {
284
- clearTimeout(this.answerPollingTimeout);
285
- this.answerPollingTimeout = null;
301
+ stopPolling() {
302
+ if (this.pollingTimeout) {
303
+ clearTimeout(this.pollingTimeout);
304
+ this.pollingTimeout = null;
286
305
  }
287
306
  }
288
307
  /**
289
- * 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
290
310
  */
291
311
  startIcePolling() {
292
- if (this.icePollingTimeout || !this.serviceUuid) {
312
+ if (this.icePollingTimeout || !this.serviceFqn || !this.offerId || this.isOfferer) {
293
313
  return;
294
314
  }
295
315
  let interval = this.pollingConfig.initialInterval;
296
316
  const poll = async () => {
297
- if (!this.serviceUuid) {
317
+ if (!this.serviceFqn || !this.offerId) {
298
318
  this.stopIcePolling();
299
319
  return;
300
320
  }
301
321
  try {
302
322
  const result = await this.rondevu
303
- .getAPI()
304
- .getServiceIceCandidates(this.serviceUuid, this.lastIceTimestamp, this.offerId || undefined);
305
- // Store offerId if we didn't have it yet
306
- if (!this.offerId) {
307
- this.offerId = result.offerId;
308
- }
323
+ .getAPIPublic()
324
+ .getOfferIceCandidates(this.serviceFqn, this.offerId, this.lastPollTimestamp);
309
325
  let foundCandidates = false;
310
326
  for (const item of result.candidates) {
311
327
  if (item.candidate && item.candidate.candidate && item.candidate.candidate !== '') {
@@ -320,15 +336,15 @@ export class RondevuSignaler {
320
336
  console.error('ICE listener error:', err);
321
337
  }
322
338
  });
323
- this.lastIceTimestamp = item.createdAt;
339
+ this.lastPollTimestamp = item.createdAt;
324
340
  }
325
341
  catch (err) {
326
342
  console.warn('Failed to process ICE candidate:', err);
327
- this.lastIceTimestamp = item.createdAt;
343
+ this.lastPollTimestamp = item.createdAt;
328
344
  }
329
345
  }
330
346
  else {
331
- this.lastIceTimestamp = item.createdAt;
347
+ this.lastPollTimestamp = item.createdAt;
332
348
  }
333
349
  }
334
350
  // If candidates found, reset interval to initial value
@@ -376,7 +392,7 @@ export class RondevuSignaler {
376
392
  * Stop all polling and cleanup
377
393
  */
378
394
  dispose() {
379
- this.stopAnswerPolling();
395
+ this.stopPolling();
380
396
  this.stopIcePolling();
381
397
  this.offerListeners = [];
382
398
  this.answerListeners = [];