@xtr-dev/rondevu-client 0.13.0 → 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.
- package/README.md +100 -381
- package/dist/api.d.ts +67 -116
- package/dist/api.js +201 -244
- package/dist/crypto-adapter.d.ts +37 -0
- package/dist/crypto-adapter.js +4 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -1
- package/dist/node-crypto-adapter.d.ts +35 -0
- package/dist/node-crypto-adapter.js +80 -0
- package/dist/rondevu-signaler.d.ts +10 -7
- package/dist/rondevu-signaler.js +96 -64
- package/dist/rondevu.d.ts +199 -37
- package/dist/rondevu.js +513 -103
- package/dist/rpc-batcher.d.ts +61 -0
- package/dist/rpc-batcher.js +111 -0
- package/dist/web-crypto-adapter.d.ts +16 -0
- package/dist/web-crypto-adapter.js +52 -0
- package/package.json +1 -1
|
@@ -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
|
|
50
|
+
private pollingTimeout;
|
|
51
51
|
private icePollingTimeout;
|
|
52
|
-
private
|
|
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
|
|
91
|
+
* Start combined polling for answers and ICE candidates (offerer side)
|
|
92
|
+
* Uses poll() for efficient batch polling
|
|
91
93
|
*/
|
|
92
|
-
private
|
|
94
|
+
private startPolling;
|
|
93
95
|
/**
|
|
94
|
-
* Stop polling
|
|
96
|
+
* Stop combined polling
|
|
95
97
|
*/
|
|
96
|
-
private
|
|
98
|
+
private stopPolling;
|
|
97
99
|
/**
|
|
98
|
-
* Start polling for ICE candidates
|
|
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
|
/**
|
package/dist/rondevu-signaler.js
CHANGED
|
@@ -39,10 +39,11 @@ export class RondevuSignaler {
|
|
|
39
39
|
this.offerListeners = [];
|
|
40
40
|
this.answerListeners = [];
|
|
41
41
|
this.iceListeners = [];
|
|
42
|
-
this.
|
|
42
|
+
this.pollingTimeout = null;
|
|
43
43
|
this.icePollingTimeout = null;
|
|
44
|
-
this.
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
92
|
-
this.
|
|
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.
|
|
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.
|
|
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
|
|
202
|
+
* Start combined polling for answers and ICE candidates (offerer side)
|
|
203
|
+
* Uses poll() for efficient batch polling
|
|
203
204
|
*/
|
|
204
|
-
|
|
205
|
-
if (this.
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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.
|
|
285
|
+
this.pollingTimeout = setTimeout(poll, finalInterval);
|
|
252
286
|
}
|
|
253
287
|
catch (err) {
|
|
254
|
-
|
|
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.
|
|
293
|
+
this.pollingTimeout = setTimeout(poll, finalInterval);
|
|
263
294
|
}
|
|
264
295
|
};
|
|
265
296
|
poll(); // Start immediately
|
|
266
297
|
}
|
|
267
298
|
/**
|
|
268
|
-
* Stop polling
|
|
299
|
+
* Stop combined polling
|
|
269
300
|
*/
|
|
270
|
-
|
|
271
|
-
if (this.
|
|
272
|
-
clearTimeout(this.
|
|
273
|
-
this.
|
|
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
|
|
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
|
-
.
|
|
292
|
-
.getOfferIceCandidates(this.serviceFqn, this.offerId, this.
|
|
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.
|
|
339
|
+
this.lastPollTimestamp = item.createdAt;
|
|
308
340
|
}
|
|
309
341
|
catch (err) {
|
|
310
342
|
console.warn('Failed to process ICE candidate:', err);
|
|
311
|
-
this.
|
|
343
|
+
this.lastPollTimestamp = item.createdAt;
|
|
312
344
|
}
|
|
313
345
|
}
|
|
314
346
|
else {
|
|
315
|
-
this.
|
|
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.
|
|
395
|
+
this.stopPolling();
|
|
364
396
|
this.stopIcePolling();
|
|
365
397
|
this.offerListeners = [];
|
|
366
398
|
this.answerListeners = [];
|