@xtr-dev/rondevu-client 0.18.10 → 0.20.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.
- package/README.md +324 -47
- package/dist/{api.d.ts → api/client.d.ts} +17 -8
- package/dist/{api.js → api/client.js} +114 -81
- package/dist/{answerer-connection.d.ts → connections/answerer.d.ts} +13 -5
- package/dist/{answerer-connection.js → connections/answerer.js} +17 -32
- package/dist/{connection.d.ts → connections/base.d.ts} +26 -5
- package/dist/{connection.js → connections/base.js} +45 -4
- package/dist/{offerer-connection.d.ts → connections/offerer.d.ts} +30 -5
- package/dist/{offerer-connection.js → connections/offerer.js} +93 -32
- package/dist/core/index.d.ts +22 -0
- package/dist/core/index.js +17 -0
- package/dist/core/offer-pool.d.ts +94 -0
- package/dist/core/offer-pool.js +267 -0
- package/dist/{rondevu.d.ts → core/rondevu.d.ts} +77 -85
- package/dist/core/rondevu.js +600 -0
- package/dist/{node-crypto-adapter.d.ts → crypto/node.d.ts} +1 -1
- package/dist/{web-crypto-adapter.d.ts → crypto/web.d.ts} +1 -1
- package/dist/utils/async-lock.d.ts +42 -0
- package/dist/utils/async-lock.js +75 -0
- package/dist/{message-buffer.d.ts → utils/message-buffer.d.ts} +1 -1
- package/package.json +4 -4
- package/dist/index.d.ts +0 -13
- package/dist/index.js +0 -10
- package/dist/rondevu-signaler.d.ts +0 -112
- package/dist/rondevu-signaler.js +0 -401
- package/dist/rondevu.js +0 -847
- /package/dist/{rpc-batcher.d.ts → api/batcher.d.ts} +0 -0
- /package/dist/{rpc-batcher.js → api/batcher.js} +0 -0
- /package/dist/{connection-config.d.ts → connections/config.d.ts} +0 -0
- /package/dist/{connection-config.js → connections/config.js} +0 -0
- /package/dist/{connection-events.d.ts → connections/events.d.ts} +0 -0
- /package/dist/{connection-events.js → connections/events.js} +0 -0
- /package/dist/{types.d.ts → core/types.d.ts} +0 -0
- /package/dist/{types.js → core/types.js} +0 -0
- /package/dist/{crypto-adapter.d.ts → crypto/adapter.d.ts} +0 -0
- /package/dist/{crypto-adapter.js → crypto/adapter.js} +0 -0
- /package/dist/{node-crypto-adapter.js → crypto/node.js} +0 -0
- /package/dist/{web-crypto-adapter.js → crypto/web.js} +0 -0
- /package/dist/{exponential-backoff.d.ts → utils/exponential-backoff.d.ts} +0 -0
- /package/dist/{exponential-backoff.js → utils/exponential-backoff.js} +0 -0
- /package/dist/{message-buffer.js → utils/message-buffer.js} +0 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import { RondevuAPI } from '../api/client.js';
|
|
2
|
+
import { EventEmitter } from 'eventemitter3';
|
|
3
|
+
import { AnswererConnection } from '../connections/answerer.js';
|
|
4
|
+
import { OfferPool } from './offer-pool.js';
|
|
5
|
+
// ICE server presets
|
|
6
|
+
export const ICE_SERVER_PRESETS = {
|
|
7
|
+
'ipv4-turn': [
|
|
8
|
+
{ urls: 'stun:57.129.61.67:3478' },
|
|
9
|
+
{
|
|
10
|
+
urls: [
|
|
11
|
+
'turn:57.129.61.67:3478?transport=tcp',
|
|
12
|
+
'turn:57.129.61.67:3478?transport=udp',
|
|
13
|
+
],
|
|
14
|
+
username: 'webrtcuser',
|
|
15
|
+
credential: 'supersecretpassword'
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
'hostname-turns': [
|
|
19
|
+
{ urls: 'stun:turn.share.fish:3478' },
|
|
20
|
+
{
|
|
21
|
+
urls: [
|
|
22
|
+
'turns:turn.share.fish:5349?transport=tcp',
|
|
23
|
+
'turns:turn.share.fish:5349?transport=udp',
|
|
24
|
+
'turn:turn.share.fish:3478?transport=tcp',
|
|
25
|
+
'turn:turn.share.fish:3478?transport=udp',
|
|
26
|
+
],
|
|
27
|
+
username: 'webrtcuser',
|
|
28
|
+
credential: 'supersecretpassword'
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
'google-stun': [
|
|
32
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
33
|
+
{ urls: 'stun:stun1.l.google.com:19302' }
|
|
34
|
+
],
|
|
35
|
+
'relay-only': [
|
|
36
|
+
{ urls: 'stun:57.129.61.67:3478' },
|
|
37
|
+
{
|
|
38
|
+
urls: [
|
|
39
|
+
'turn:57.129.61.67:3478?transport=tcp',
|
|
40
|
+
'turn:57.129.61.67:3478?transport=udp',
|
|
41
|
+
],
|
|
42
|
+
username: 'webrtcuser',
|
|
43
|
+
credential: 'supersecretpassword',
|
|
44
|
+
// @ts-expect-error - iceTransportPolicy is valid but not in RTCIceServer type
|
|
45
|
+
iceTransportPolicy: 'relay'
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Base error class for Rondevu errors
|
|
51
|
+
*/
|
|
52
|
+
export class RondevuError extends Error {
|
|
53
|
+
constructor(message, context) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.context = context;
|
|
56
|
+
this.name = 'RondevuError';
|
|
57
|
+
Object.setPrototypeOf(this, RondevuError.prototype);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Network-related errors (API calls, connectivity)
|
|
62
|
+
*/
|
|
63
|
+
export class NetworkError extends RondevuError {
|
|
64
|
+
constructor(message, context) {
|
|
65
|
+
super(message, context);
|
|
66
|
+
this.name = 'NetworkError';
|
|
67
|
+
Object.setPrototypeOf(this, NetworkError.prototype);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Validation errors (invalid input, malformed data)
|
|
72
|
+
*/
|
|
73
|
+
export class ValidationError extends RondevuError {
|
|
74
|
+
constructor(message, context) {
|
|
75
|
+
super(message, context);
|
|
76
|
+
this.name = 'ValidationError';
|
|
77
|
+
Object.setPrototypeOf(this, ValidationError.prototype);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* WebRTC connection errors (peer connection failures, ICE issues)
|
|
82
|
+
*/
|
|
83
|
+
export class ConnectionError extends RondevuError {
|
|
84
|
+
constructor(message, context) {
|
|
85
|
+
super(message, context);
|
|
86
|
+
this.name = 'ConnectionError';
|
|
87
|
+
Object.setPrototypeOf(this, ConnectionError.prototype);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Rondevu - Complete WebRTC signaling client with durable connections
|
|
92
|
+
*
|
|
93
|
+
* v1.0.0 introduces breaking changes:
|
|
94
|
+
* - connectToService() now returns AnswererConnection instead of ConnectionContext
|
|
95
|
+
* - Automatic reconnection and message buffering built-in
|
|
96
|
+
* - Connection objects expose .send() method instead of raw DataChannel
|
|
97
|
+
* - Rich event system for connection lifecycle (connected, disconnected, reconnecting, etc.)
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```typescript
|
|
101
|
+
* // Create and initialize Rondevu instance with preset ICE servers
|
|
102
|
+
* const rondevu = await Rondevu.connect({
|
|
103
|
+
* apiUrl: 'https://signal.example.com',
|
|
104
|
+
* username: 'alice',
|
|
105
|
+
* iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
|
|
106
|
+
* })
|
|
107
|
+
*
|
|
108
|
+
* // Publish a service with automatic offer management
|
|
109
|
+
* await rondevu.publishService({
|
|
110
|
+
* service: 'chat:2.0.0',
|
|
111
|
+
* maxOffers: 5 // Maintain up to 5 concurrent offers
|
|
112
|
+
* })
|
|
113
|
+
*
|
|
114
|
+
* // Start accepting connections (auto-fills offers and polls)
|
|
115
|
+
* await rondevu.startFilling()
|
|
116
|
+
*
|
|
117
|
+
* // Listen for connections (v1.0.0 API)
|
|
118
|
+
* rondevu.on('connection:opened', (offerId, connection) => {
|
|
119
|
+
* connection.on('connected', () => console.log('Connected!'))
|
|
120
|
+
* connection.on('message', (data) => console.log('Received:', data))
|
|
121
|
+
* connection.send('Hello!')
|
|
122
|
+
* })
|
|
123
|
+
*
|
|
124
|
+
* // Connect to a service (v1.0.0 - returns AnswererConnection)
|
|
125
|
+
* const connection = await rondevu.connectToService({
|
|
126
|
+
* serviceFqn: 'chat:2.0.0@bob'
|
|
127
|
+
* })
|
|
128
|
+
*
|
|
129
|
+
* connection.on('connected', () => {
|
|
130
|
+
* console.log('Connected!')
|
|
131
|
+
* connection.send('Hello!')
|
|
132
|
+
* })
|
|
133
|
+
*
|
|
134
|
+
* connection.on('message', (data) => {
|
|
135
|
+
* console.log('Received:', data)
|
|
136
|
+
* })
|
|
137
|
+
*
|
|
138
|
+
* connection.on('reconnecting', (attempt) => {
|
|
139
|
+
* console.log(`Reconnecting, attempt ${attempt}`)
|
|
140
|
+
* })
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
export class Rondevu extends EventEmitter {
|
|
144
|
+
constructor(apiUrl, username, keypair, api, iceServers, cryptoAdapter, batchingOptions, debugEnabled = false, rtcPeerConnection, rtcIceCandidate) {
|
|
145
|
+
super();
|
|
146
|
+
this.usernameClaimed = false;
|
|
147
|
+
// Service management
|
|
148
|
+
this.currentService = null;
|
|
149
|
+
this.offerPool = null;
|
|
150
|
+
this.apiUrl = apiUrl;
|
|
151
|
+
this.username = username;
|
|
152
|
+
this.keypair = keypair;
|
|
153
|
+
this.api = api;
|
|
154
|
+
this.iceServers = iceServers;
|
|
155
|
+
this.cryptoAdapter = cryptoAdapter;
|
|
156
|
+
this.batchingOptions = batchingOptions;
|
|
157
|
+
this.debugEnabled = debugEnabled;
|
|
158
|
+
this.rtcPeerConnection = rtcPeerConnection;
|
|
159
|
+
this.rtcIceCandidate = rtcIceCandidate;
|
|
160
|
+
this.debug('Instance created:', {
|
|
161
|
+
username: this.username,
|
|
162
|
+
publicKey: this.keypair.publicKey,
|
|
163
|
+
hasIceServers: iceServers.length > 0,
|
|
164
|
+
batchingEnabled: batchingOptions !== false
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Internal debug logging - only logs if debug mode is enabled
|
|
169
|
+
*/
|
|
170
|
+
debug(message, ...args) {
|
|
171
|
+
if (this.debugEnabled) {
|
|
172
|
+
console.log(`[Rondevu] ${message}`, ...args);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Create and initialize a Rondevu client
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```typescript
|
|
180
|
+
* const rondevu = await Rondevu.connect({
|
|
181
|
+
* apiUrl: 'https://api.ronde.vu',
|
|
182
|
+
* username: 'alice'
|
|
183
|
+
* })
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
static async connect(options) {
|
|
187
|
+
const username = options.username || Rondevu.generateAnonymousUsername();
|
|
188
|
+
// Apply WebRTC polyfills to global scope if provided (Node.js environments)
|
|
189
|
+
if (options.rtcPeerConnection) {
|
|
190
|
+
globalThis.RTCPeerConnection = options.rtcPeerConnection;
|
|
191
|
+
}
|
|
192
|
+
if (options.rtcIceCandidate) {
|
|
193
|
+
globalThis.RTCIceCandidate = options.rtcIceCandidate;
|
|
194
|
+
}
|
|
195
|
+
// Handle preset string or custom array
|
|
196
|
+
let iceServers;
|
|
197
|
+
if (typeof options.iceServers === 'string') {
|
|
198
|
+
iceServers = ICE_SERVER_PRESETS[options.iceServers];
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
iceServers = options.iceServers || [
|
|
202
|
+
{ urls: 'stun:stun.l.google.com:19302' }
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
if (options.debug) {
|
|
206
|
+
console.log('[Rondevu] Connecting:', {
|
|
207
|
+
username,
|
|
208
|
+
hasKeypair: !!options.keypair,
|
|
209
|
+
iceServers: iceServers.length,
|
|
210
|
+
batchingEnabled: options.batching !== false
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
// Generate keypair if not provided
|
|
214
|
+
let keypair = options.keypair;
|
|
215
|
+
if (!keypair) {
|
|
216
|
+
if (options.debug)
|
|
217
|
+
console.log('[Rondevu] Generating new keypair...');
|
|
218
|
+
keypair = await RondevuAPI.generateKeypair(options.cryptoAdapter);
|
|
219
|
+
if (options.debug)
|
|
220
|
+
console.log('[Rondevu] Generated keypair, publicKey:', keypair.publicKey);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
if (options.debug)
|
|
224
|
+
console.log('[Rondevu] Using existing keypair, publicKey:', keypair.publicKey);
|
|
225
|
+
}
|
|
226
|
+
// Create API instance
|
|
227
|
+
const api = new RondevuAPI(options.apiUrl, username, keypair, options.cryptoAdapter, options.batching);
|
|
228
|
+
if (options.debug)
|
|
229
|
+
console.log('[Rondevu] Created API instance');
|
|
230
|
+
return new Rondevu(options.apiUrl, username, keypair, api, iceServers, options.cryptoAdapter, options.batching, options.debug || false, options.rtcPeerConnection, options.rtcIceCandidate);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Generate an anonymous username with timestamp and random component
|
|
234
|
+
*/
|
|
235
|
+
static generateAnonymousUsername() {
|
|
236
|
+
const timestamp = Date.now().toString(36);
|
|
237
|
+
const random = Array.from(crypto.getRandomValues(new Uint8Array(3)))
|
|
238
|
+
.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
239
|
+
return `anon-${timestamp}-${random}`;
|
|
240
|
+
}
|
|
241
|
+
// ============================================
|
|
242
|
+
// Username Management
|
|
243
|
+
// ============================================
|
|
244
|
+
/**
|
|
245
|
+
* Check if username has been claimed (checks with server)
|
|
246
|
+
*/
|
|
247
|
+
async isUsernameClaimed() {
|
|
248
|
+
try {
|
|
249
|
+
const claimed = await this.api.isUsernameClaimed();
|
|
250
|
+
// Update internal flag to match server state
|
|
251
|
+
this.usernameClaimed = claimed;
|
|
252
|
+
return claimed;
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
console.error('Failed to check username claim status:', err);
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// ============================================
|
|
260
|
+
// Service Publishing
|
|
261
|
+
// ============================================
|
|
262
|
+
/**
|
|
263
|
+
* Default offer factory - creates a simple data channel connection
|
|
264
|
+
* The RTCPeerConnection is created by Rondevu and passed in
|
|
265
|
+
*/
|
|
266
|
+
async defaultOfferFactory(pc) {
|
|
267
|
+
const dc = pc.createDataChannel('default');
|
|
268
|
+
const offer = await pc.createOffer();
|
|
269
|
+
await pc.setLocalDescription(offer);
|
|
270
|
+
return { dc, offer };
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Publish a service with automatic offer management
|
|
274
|
+
* Call startFilling() to begin accepting connections
|
|
275
|
+
*
|
|
276
|
+
* @example
|
|
277
|
+
* ```typescript
|
|
278
|
+
* await rondevu.publishService({
|
|
279
|
+
* service: 'chat:2.0.0',
|
|
280
|
+
* maxOffers: 5,
|
|
281
|
+
* connectionConfig: {
|
|
282
|
+
* reconnectEnabled: true,
|
|
283
|
+
* bufferEnabled: true
|
|
284
|
+
* }
|
|
285
|
+
* })
|
|
286
|
+
* await rondevu.startFilling()
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
async publishService(options) {
|
|
290
|
+
const { service, maxOffers, offerFactory, ttl, connectionConfig } = options;
|
|
291
|
+
this.currentService = service;
|
|
292
|
+
this.connectionConfig = connectionConfig;
|
|
293
|
+
// Auto-append username to service
|
|
294
|
+
const serviceFqn = `${service}@${this.username}`;
|
|
295
|
+
this.debug(`Publishing service: ${service} with maxOffers: ${maxOffers}`);
|
|
296
|
+
// Create OfferPool (but don't start it yet - call startFilling() to begin)
|
|
297
|
+
this.offerPool = new OfferPool({
|
|
298
|
+
api: this.api,
|
|
299
|
+
serviceFqn,
|
|
300
|
+
maxOffers,
|
|
301
|
+
offerFactory: offerFactory || this.defaultOfferFactory.bind(this),
|
|
302
|
+
ttl: ttl || Rondevu.DEFAULT_TTL_MS,
|
|
303
|
+
iceServers: this.iceServers,
|
|
304
|
+
connectionConfig,
|
|
305
|
+
debugEnabled: this.debugEnabled,
|
|
306
|
+
});
|
|
307
|
+
// Forward events from OfferPool
|
|
308
|
+
this.offerPool.on('connection:opened', (offerId, connection) => {
|
|
309
|
+
this.emit('connection:opened', offerId, connection);
|
|
310
|
+
});
|
|
311
|
+
this.offerPool.on('offer:created', (offerId, serviceFqn) => {
|
|
312
|
+
this.emit('offer:created', offerId, serviceFqn);
|
|
313
|
+
});
|
|
314
|
+
this.offerPool.on('connection:rotated', (oldOfferId, newOfferId, connection) => {
|
|
315
|
+
this.emit('connection:rotated', oldOfferId, newOfferId, connection);
|
|
316
|
+
});
|
|
317
|
+
this.usernameClaimed = true;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Start filling offers and polling for answers/ICE
|
|
321
|
+
* Call this after publishService() to begin accepting connections
|
|
322
|
+
*/
|
|
323
|
+
async startFilling() {
|
|
324
|
+
if (!this.offerPool) {
|
|
325
|
+
throw new Error('No service published. Call publishService() first.');
|
|
326
|
+
}
|
|
327
|
+
this.debug('Starting offer filling and polling');
|
|
328
|
+
await this.offerPool.start();
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Stop filling offers and polling
|
|
332
|
+
* Closes all active peer connections
|
|
333
|
+
*/
|
|
334
|
+
stopFilling() {
|
|
335
|
+
this.debug('Stopping offer filling and polling');
|
|
336
|
+
this.offerPool?.stop();
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Get the count of active offers
|
|
340
|
+
* @returns Number of active offers
|
|
341
|
+
*/
|
|
342
|
+
getOfferCount() {
|
|
343
|
+
return this.offerPool?.getOfferCount() ?? 0;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Check if an offer is currently connected
|
|
347
|
+
* @param offerId - The offer ID to check
|
|
348
|
+
* @returns True if the offer exists and is connected
|
|
349
|
+
*/
|
|
350
|
+
isConnected(offerId) {
|
|
351
|
+
return this.offerPool?.isConnected(offerId) ?? false;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Disconnect all active offers
|
|
355
|
+
* Similar to stopFilling() but doesn't stop the polling/filling process
|
|
356
|
+
*/
|
|
357
|
+
disconnectAll() {
|
|
358
|
+
this.debug('Disconnecting all offers');
|
|
359
|
+
this.offerPool?.disconnectAll();
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Get the current service status
|
|
363
|
+
* @returns Object with service state information
|
|
364
|
+
*/
|
|
365
|
+
getServiceStatus() {
|
|
366
|
+
return {
|
|
367
|
+
active: this.currentService !== null,
|
|
368
|
+
offerCount: this.offerPool?.getOfferCount() ?? 0
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Resolve the full service FQN from various input options
|
|
373
|
+
* Supports direct FQN, service+username, or service discovery
|
|
374
|
+
*/
|
|
375
|
+
async resolveServiceFqn(options) {
|
|
376
|
+
const { serviceFqn, service, username } = options;
|
|
377
|
+
if (serviceFqn) {
|
|
378
|
+
return serviceFqn;
|
|
379
|
+
}
|
|
380
|
+
else if (service && username) {
|
|
381
|
+
return `${service}@${username}`;
|
|
382
|
+
}
|
|
383
|
+
else if (service) {
|
|
384
|
+
// Discovery mode - get random service
|
|
385
|
+
this.debug(`Discovering service: ${service}`);
|
|
386
|
+
const discovered = await this.findService(service);
|
|
387
|
+
return discovered.serviceFqn;
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
throw new Error('Either serviceFqn or service must be provided');
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Connect to a service (answerer side) - v1.0.0 API
|
|
395
|
+
* Returns an AnswererConnection with automatic reconnection and buffering
|
|
396
|
+
*
|
|
397
|
+
* BREAKING CHANGE: This now returns AnswererConnection instead of ConnectionContext
|
|
398
|
+
*
|
|
399
|
+
* @example
|
|
400
|
+
* ```typescript
|
|
401
|
+
* // Connect to specific user
|
|
402
|
+
* const connection = await rondevu.connectToService({
|
|
403
|
+
* serviceFqn: 'chat:2.0.0@alice',
|
|
404
|
+
* connectionConfig: {
|
|
405
|
+
* reconnectEnabled: true,
|
|
406
|
+
* bufferEnabled: true
|
|
407
|
+
* }
|
|
408
|
+
* })
|
|
409
|
+
*
|
|
410
|
+
* connection.on('connected', () => {
|
|
411
|
+
* console.log('Connected!')
|
|
412
|
+
* connection.send('Hello!')
|
|
413
|
+
* })
|
|
414
|
+
*
|
|
415
|
+
* connection.on('message', (data) => {
|
|
416
|
+
* console.log('Received:', data)
|
|
417
|
+
* })
|
|
418
|
+
*
|
|
419
|
+
* connection.on('reconnecting', (attempt) => {
|
|
420
|
+
* console.log(`Reconnecting, attempt ${attempt}`)
|
|
421
|
+
* })
|
|
422
|
+
*
|
|
423
|
+
* // Discover random service
|
|
424
|
+
* const connection = await rondevu.connectToService({
|
|
425
|
+
* service: 'chat:2.0.0'
|
|
426
|
+
* })
|
|
427
|
+
* ```
|
|
428
|
+
*/
|
|
429
|
+
async connectToService(options) {
|
|
430
|
+
const { rtcConfig, connectionConfig } = options;
|
|
431
|
+
// Validate inputs
|
|
432
|
+
if (options.serviceFqn !== undefined && typeof options.serviceFqn === 'string' && !options.serviceFqn.trim()) {
|
|
433
|
+
throw new Error('serviceFqn cannot be empty');
|
|
434
|
+
}
|
|
435
|
+
if (options.service !== undefined && typeof options.service === 'string' && !options.service.trim()) {
|
|
436
|
+
throw new Error('service cannot be empty');
|
|
437
|
+
}
|
|
438
|
+
if (options.username !== undefined && typeof options.username === 'string' && !options.username.trim()) {
|
|
439
|
+
throw new Error('username cannot be empty');
|
|
440
|
+
}
|
|
441
|
+
// Determine the full service FQN
|
|
442
|
+
const fqn = await this.resolveServiceFqn(options);
|
|
443
|
+
this.debug(`Connecting to service: ${fqn}`);
|
|
444
|
+
// Get service offer
|
|
445
|
+
const serviceData = await this.api.getService(fqn);
|
|
446
|
+
this.debug(`Found service from @${serviceData.username}`);
|
|
447
|
+
// Create RTCConfiguration
|
|
448
|
+
const rtcConfiguration = rtcConfig || {
|
|
449
|
+
iceServers: this.iceServers
|
|
450
|
+
};
|
|
451
|
+
// Create AnswererConnection
|
|
452
|
+
const connection = new AnswererConnection({
|
|
453
|
+
api: this.api,
|
|
454
|
+
serviceFqn: serviceData.serviceFqn,
|
|
455
|
+
offerId: serviceData.offerId,
|
|
456
|
+
offerSdp: serviceData.sdp,
|
|
457
|
+
rtcConfig: rtcConfiguration,
|
|
458
|
+
config: {
|
|
459
|
+
...connectionConfig,
|
|
460
|
+
debug: this.debugEnabled,
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
// Initialize the connection
|
|
464
|
+
await connection.initialize();
|
|
465
|
+
return connection;
|
|
466
|
+
}
|
|
467
|
+
// ============================================
|
|
468
|
+
// Service Discovery
|
|
469
|
+
// ============================================
|
|
470
|
+
/**
|
|
471
|
+
* Find a service - unified discovery method
|
|
472
|
+
*
|
|
473
|
+
* @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
|
|
474
|
+
* @param options - Discovery options
|
|
475
|
+
*
|
|
476
|
+
* @example
|
|
477
|
+
* ```typescript
|
|
478
|
+
* // Direct lookup (has username)
|
|
479
|
+
* const service = await rondevu.findService('chat:1.0.0@alice')
|
|
480
|
+
*
|
|
481
|
+
* // Random discovery (no username)
|
|
482
|
+
* const service = await rondevu.findService('chat:1.0.0')
|
|
483
|
+
*
|
|
484
|
+
* // Paginated discovery
|
|
485
|
+
* const result = await rondevu.findService('chat:1.0.0', {
|
|
486
|
+
* mode: 'paginated',
|
|
487
|
+
* limit: 20,
|
|
488
|
+
* offset: 0
|
|
489
|
+
* })
|
|
490
|
+
* ```
|
|
491
|
+
*/
|
|
492
|
+
async findService(serviceFqn, options) {
|
|
493
|
+
const { mode, limit = 10, offset = 0 } = options || {};
|
|
494
|
+
// Auto-detect mode if not specified
|
|
495
|
+
const hasUsername = serviceFqn.includes('@');
|
|
496
|
+
const effectiveMode = mode || (hasUsername ? 'direct' : 'random');
|
|
497
|
+
if (effectiveMode === 'paginated') {
|
|
498
|
+
return await this.api.getService(serviceFqn, { limit, offset });
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
// Both 'direct' and 'random' use the same API call
|
|
502
|
+
return await this.api.getService(serviceFqn);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// ============================================
|
|
506
|
+
// WebRTC Signaling
|
|
507
|
+
// ============================================
|
|
508
|
+
/**
|
|
509
|
+
* Post answer SDP to specific offer
|
|
510
|
+
*/
|
|
511
|
+
async postOfferAnswer(serviceFqn, offerId, sdp) {
|
|
512
|
+
await this.api.answerOffer(serviceFqn, offerId, sdp);
|
|
513
|
+
return { success: true, offerId };
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Get answer SDP (offerer polls this)
|
|
517
|
+
*/
|
|
518
|
+
async getOfferAnswer(serviceFqn, offerId) {
|
|
519
|
+
return await this.api.getOfferAnswer(serviceFqn, offerId);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Combined polling for answers and ICE candidates
|
|
523
|
+
* Returns all answered offers and ICE candidates for all peer's offers since timestamp
|
|
524
|
+
*/
|
|
525
|
+
async poll(since) {
|
|
526
|
+
return await this.api.poll(since);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Add ICE candidates to specific offer
|
|
530
|
+
*/
|
|
531
|
+
async addOfferIceCandidates(serviceFqn, offerId, candidates) {
|
|
532
|
+
return await this.api.addOfferIceCandidates(serviceFqn, offerId, candidates);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Get ICE candidates for specific offer (with polling support)
|
|
536
|
+
*/
|
|
537
|
+
async getOfferIceCandidates(serviceFqn, offerId, since = 0) {
|
|
538
|
+
return await this.api.getOfferIceCandidates(serviceFqn, offerId, since);
|
|
539
|
+
}
|
|
540
|
+
// ============================================
|
|
541
|
+
// Utility Methods
|
|
542
|
+
// ============================================
|
|
543
|
+
/**
|
|
544
|
+
* Get the current keypair (for backup/storage)
|
|
545
|
+
*/
|
|
546
|
+
getKeypair() {
|
|
547
|
+
return this.keypair;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Get the username
|
|
551
|
+
*/
|
|
552
|
+
getUsername() {
|
|
553
|
+
return this.username;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Get the public key
|
|
557
|
+
*/
|
|
558
|
+
getPublicKey() {
|
|
559
|
+
return this.keypair.publicKey;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Get active connections (for offerer side)
|
|
563
|
+
*/
|
|
564
|
+
getActiveConnections() {
|
|
565
|
+
return this.offerPool?.getActiveConnections() ?? new Map();
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Get all active offers (legacy compatibility)
|
|
569
|
+
* @deprecated Use getActiveConnections() instead
|
|
570
|
+
*/
|
|
571
|
+
getActiveOffers() {
|
|
572
|
+
const offers = [];
|
|
573
|
+
const connections = this.offerPool?.getActiveConnections() ?? new Map();
|
|
574
|
+
for (const [offerId, connection] of connections.entries()) {
|
|
575
|
+
const pc = connection.getPeerConnection();
|
|
576
|
+
const dc = connection.getDataChannel();
|
|
577
|
+
if (pc) {
|
|
578
|
+
offers.push({
|
|
579
|
+
offerId,
|
|
580
|
+
serviceFqn: this.currentService ? `${this.currentService}@${this.username}` : '',
|
|
581
|
+
pc,
|
|
582
|
+
dc: dc || undefined,
|
|
583
|
+
answered: connection.getState() === 'connected',
|
|
584
|
+
createdAt: Date.now(),
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return offers;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Access to underlying API for advanced operations
|
|
592
|
+
* @deprecated Use direct methods on Rondevu instance instead
|
|
593
|
+
*/
|
|
594
|
+
getAPIPublic() {
|
|
595
|
+
return this.api;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// Constants
|
|
599
|
+
Rondevu.DEFAULT_TTL_MS = 300000; // 5 minutes
|
|
600
|
+
Rondevu.POLLING_INTERVAL_MS = 1000; // 1 second
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Node.js Crypto adapter for Node.js environments
|
|
3
3
|
* Requires Node.js 19+ or Node.js 18 with --experimental-global-webcrypto flag
|
|
4
4
|
*/
|
|
5
|
-
import { CryptoAdapter, Keypair } from './
|
|
5
|
+
import { CryptoAdapter, Keypair } from './adapter.js';
|
|
6
6
|
/**
|
|
7
7
|
* Node.js Crypto implementation using Node.js built-in APIs
|
|
8
8
|
* Uses Buffer for base64 encoding and crypto.randomBytes for random generation
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Web Crypto adapter for browser environments
|
|
3
3
|
*/
|
|
4
|
-
import { CryptoAdapter, Keypair } from './
|
|
4
|
+
import { CryptoAdapter, Keypair } from './adapter.js';
|
|
5
5
|
/**
|
|
6
6
|
* Web Crypto implementation using browser APIs
|
|
7
7
|
* Uses btoa/atob for base64 encoding and crypto.getRandomValues for random bytes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AsyncLock provides a mutual exclusion primitive for asynchronous operations.
|
|
3
|
+
* Ensures only one async operation can proceed at a time while queuing others.
|
|
4
|
+
*/
|
|
5
|
+
export declare class AsyncLock {
|
|
6
|
+
private locked;
|
|
7
|
+
private queue;
|
|
8
|
+
/**
|
|
9
|
+
* Acquire the lock. If already locked, waits until released.
|
|
10
|
+
* @returns Promise that resolves when lock is acquired
|
|
11
|
+
*/
|
|
12
|
+
acquire(): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Release the lock. If others are waiting, grants lock to next in queue.
|
|
15
|
+
*/
|
|
16
|
+
release(): void;
|
|
17
|
+
/**
|
|
18
|
+
* Run a function with the lock acquired, automatically releasing after.
|
|
19
|
+
* This is the recommended way to use AsyncLock to prevent forgetting to release.
|
|
20
|
+
*
|
|
21
|
+
* @param fn - Async function to run with lock held
|
|
22
|
+
* @returns Promise resolving to the function's return value
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* const lock = new AsyncLock()
|
|
27
|
+
* const result = await lock.run(async () => {
|
|
28
|
+
* // Critical section - only one caller at a time
|
|
29
|
+
* return await doSomething()
|
|
30
|
+
* })
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
34
|
+
/**
|
|
35
|
+
* Check if lock is currently held
|
|
36
|
+
*/
|
|
37
|
+
isLocked(): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Get number of operations waiting for the lock
|
|
40
|
+
*/
|
|
41
|
+
getQueueLength(): number;
|
|
42
|
+
}
|