@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
package/dist/rondevu.js
DELETED
|
@@ -1,847 +0,0 @@
|
|
|
1
|
-
import { RondevuAPI } from './api.js';
|
|
2
|
-
import { EventEmitter } from 'eventemitter3';
|
|
3
|
-
// ICE server presets
|
|
4
|
-
export const ICE_SERVER_PRESETS = {
|
|
5
|
-
'ipv4-turn': [
|
|
6
|
-
{ urls: 'stun:57.129.61.67:3478' },
|
|
7
|
-
{
|
|
8
|
-
urls: [
|
|
9
|
-
'turn:57.129.61.67:3478?transport=tcp',
|
|
10
|
-
'turn:57.129.61.67:3478?transport=udp',
|
|
11
|
-
],
|
|
12
|
-
username: 'webrtcuser',
|
|
13
|
-
credential: 'supersecretpassword'
|
|
14
|
-
}
|
|
15
|
-
],
|
|
16
|
-
'hostname-turns': [
|
|
17
|
-
{ urls: 'stun:turn.share.fish:3478' },
|
|
18
|
-
{
|
|
19
|
-
urls: [
|
|
20
|
-
'turns:turn.share.fish:5349?transport=tcp',
|
|
21
|
-
'turns:turn.share.fish:5349?transport=udp',
|
|
22
|
-
'turn:turn.share.fish:3478?transport=tcp',
|
|
23
|
-
'turn:turn.share.fish:3478?transport=udp',
|
|
24
|
-
],
|
|
25
|
-
username: 'webrtcuser',
|
|
26
|
-
credential: 'supersecretpassword'
|
|
27
|
-
}
|
|
28
|
-
],
|
|
29
|
-
'google-stun': [
|
|
30
|
-
{ urls: 'stun:stun.l.google.com:19302' },
|
|
31
|
-
{ urls: 'stun:stun1.l.google.com:19302' }
|
|
32
|
-
],
|
|
33
|
-
'relay-only': [
|
|
34
|
-
{ urls: 'stun:57.129.61.67:3478' },
|
|
35
|
-
{
|
|
36
|
-
urls: [
|
|
37
|
-
'turn:57.129.61.67:3478?transport=tcp',
|
|
38
|
-
'turn:57.129.61.67:3478?transport=udp',
|
|
39
|
-
],
|
|
40
|
-
username: 'webrtcuser',
|
|
41
|
-
credential: 'supersecretpassword',
|
|
42
|
-
// @ts-expect-error - iceTransportPolicy is valid but not in RTCIceServer type
|
|
43
|
-
iceTransportPolicy: 'relay'
|
|
44
|
-
}
|
|
45
|
-
]
|
|
46
|
-
};
|
|
47
|
-
/**
|
|
48
|
-
* Base error class for Rondevu errors
|
|
49
|
-
*/
|
|
50
|
-
export class RondevuError extends Error {
|
|
51
|
-
constructor(message, context) {
|
|
52
|
-
super(message);
|
|
53
|
-
this.context = context;
|
|
54
|
-
this.name = 'RondevuError';
|
|
55
|
-
Object.setPrototypeOf(this, RondevuError.prototype);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Network-related errors (API calls, connectivity)
|
|
60
|
-
*/
|
|
61
|
-
export class NetworkError extends RondevuError {
|
|
62
|
-
constructor(message, context) {
|
|
63
|
-
super(message, context);
|
|
64
|
-
this.name = 'NetworkError';
|
|
65
|
-
Object.setPrototypeOf(this, NetworkError.prototype);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Validation errors (invalid input, malformed data)
|
|
70
|
-
*/
|
|
71
|
-
export class ValidationError extends RondevuError {
|
|
72
|
-
constructor(message, context) {
|
|
73
|
-
super(message, context);
|
|
74
|
-
this.name = 'ValidationError';
|
|
75
|
-
Object.setPrototypeOf(this, ValidationError.prototype);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* WebRTC connection errors (peer connection failures, ICE issues)
|
|
80
|
-
*/
|
|
81
|
-
export class ConnectionError extends RondevuError {
|
|
82
|
-
constructor(message, context) {
|
|
83
|
-
super(message, context);
|
|
84
|
-
this.name = 'ConnectionError';
|
|
85
|
-
Object.setPrototypeOf(this, ConnectionError.prototype);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Rondevu - Complete WebRTC signaling client
|
|
90
|
-
*
|
|
91
|
-
* Provides a unified API for:
|
|
92
|
-
* - Implicit username claiming (auto-claimed on first authenticated request)
|
|
93
|
-
* - Service publishing with automatic signature generation
|
|
94
|
-
* - Service discovery (direct, random, paginated)
|
|
95
|
-
* - WebRTC signaling (offer/answer exchange, ICE relay)
|
|
96
|
-
* - Keypair management
|
|
97
|
-
*
|
|
98
|
-
* @example
|
|
99
|
-
* ```typescript
|
|
100
|
-
* // Create and initialize Rondevu instance with preset ICE servers
|
|
101
|
-
* const rondevu = await Rondevu.connect({
|
|
102
|
-
* apiUrl: 'https://signal.example.com',
|
|
103
|
-
* username: 'alice',
|
|
104
|
-
* iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
|
|
105
|
-
* })
|
|
106
|
-
*
|
|
107
|
-
* // Or use custom ICE servers
|
|
108
|
-
* const rondevu2 = await Rondevu.connect({
|
|
109
|
-
* apiUrl: 'https://signal.example.com',
|
|
110
|
-
* username: 'bob',
|
|
111
|
-
* iceServers: [
|
|
112
|
-
* { urls: 'stun:stun.l.google.com:19302' },
|
|
113
|
-
* { urls: 'turn:turn.example.com:3478', username: 'user', credential: 'pass' }
|
|
114
|
-
* ]
|
|
115
|
-
* })
|
|
116
|
-
*
|
|
117
|
-
* // Publish a service with automatic offer management
|
|
118
|
-
* await rondevu.publishService({
|
|
119
|
-
* service: 'chat:2.0.0',
|
|
120
|
-
* maxOffers: 5, // Maintain up to 5 concurrent offers
|
|
121
|
-
* offerFactory: async (pc) => {
|
|
122
|
-
* // pc is created by Rondevu with ICE handlers already attached
|
|
123
|
-
* const dc = pc.createDataChannel('chat')
|
|
124
|
-
* const offer = await pc.createOffer()
|
|
125
|
-
* await pc.setLocalDescription(offer)
|
|
126
|
-
* return { dc, offer }
|
|
127
|
-
* }
|
|
128
|
-
* })
|
|
129
|
-
*
|
|
130
|
-
* // Start accepting connections (auto-fills offers and polls)
|
|
131
|
-
* await rondevu.startFilling()
|
|
132
|
-
*
|
|
133
|
-
* // Access active connections
|
|
134
|
-
* for (const offer of rondevu.getActiveOffers()) {
|
|
135
|
-
* offer.dc?.addEventListener('message', (e) => console.log(e.data))
|
|
136
|
-
* }
|
|
137
|
-
*
|
|
138
|
-
* // Stop when done
|
|
139
|
-
* rondevu.stopFilling()
|
|
140
|
-
* ```
|
|
141
|
-
*/
|
|
142
|
-
export class Rondevu extends EventEmitter {
|
|
143
|
-
constructor(apiUrl, username, keypair, api, iceServers, cryptoAdapter, batchingOptions, debugEnabled = false, rtcPeerConnection, rtcIceCandidate) {
|
|
144
|
-
super();
|
|
145
|
-
this.usernameClaimed = false;
|
|
146
|
-
// Service management
|
|
147
|
-
this.currentService = null;
|
|
148
|
-
this.maxOffers = 0;
|
|
149
|
-
this.offerFactory = null;
|
|
150
|
-
this.ttl = Rondevu.DEFAULT_TTL_MS;
|
|
151
|
-
this.activeOffers = new Map();
|
|
152
|
-
// Polling
|
|
153
|
-
this.filling = false;
|
|
154
|
-
this.pollingInterval = null;
|
|
155
|
-
this.lastPollTimestamp = 0;
|
|
156
|
-
this.isPolling = false; // Guard against concurrent poll execution
|
|
157
|
-
this.apiUrl = apiUrl;
|
|
158
|
-
this.username = username;
|
|
159
|
-
this.keypair = keypair;
|
|
160
|
-
this.api = api;
|
|
161
|
-
this.iceServers = iceServers;
|
|
162
|
-
this.cryptoAdapter = cryptoAdapter;
|
|
163
|
-
this.batchingOptions = batchingOptions;
|
|
164
|
-
this.debugEnabled = debugEnabled;
|
|
165
|
-
this.rtcPeerConnection = rtcPeerConnection;
|
|
166
|
-
this.rtcIceCandidate = rtcIceCandidate;
|
|
167
|
-
this.debug('Instance created:', {
|
|
168
|
-
username: this.username,
|
|
169
|
-
publicKey: this.keypair.publicKey,
|
|
170
|
-
hasIceServers: iceServers.length > 0,
|
|
171
|
-
batchingEnabled: batchingOptions !== false
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
/**
|
|
175
|
-
* Internal debug logging - only logs if debug mode is enabled
|
|
176
|
-
*/
|
|
177
|
-
debug(message, ...args) {
|
|
178
|
-
if (this.debugEnabled) {
|
|
179
|
-
console.log(`[Rondevu] ${message}`, ...args);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
/**
|
|
183
|
-
* Create and initialize a Rondevu client
|
|
184
|
-
*
|
|
185
|
-
* @example
|
|
186
|
-
* ```typescript
|
|
187
|
-
* const rondevu = await Rondevu.connect({
|
|
188
|
-
* apiUrl: 'https://api.ronde.vu',
|
|
189
|
-
* username: 'alice'
|
|
190
|
-
* })
|
|
191
|
-
* ```
|
|
192
|
-
*/
|
|
193
|
-
static async connect(options) {
|
|
194
|
-
const username = options.username || Rondevu.generateAnonymousUsername();
|
|
195
|
-
// Apply WebRTC polyfills to global scope if provided (Node.js environments)
|
|
196
|
-
if (options.rtcPeerConnection) {
|
|
197
|
-
globalThis.RTCPeerConnection = options.rtcPeerConnection;
|
|
198
|
-
}
|
|
199
|
-
if (options.rtcIceCandidate) {
|
|
200
|
-
globalThis.RTCIceCandidate = options.rtcIceCandidate;
|
|
201
|
-
}
|
|
202
|
-
// Handle preset string or custom array
|
|
203
|
-
let iceServers;
|
|
204
|
-
if (typeof options.iceServers === 'string') {
|
|
205
|
-
iceServers = ICE_SERVER_PRESETS[options.iceServers];
|
|
206
|
-
}
|
|
207
|
-
else {
|
|
208
|
-
iceServers = options.iceServers || [
|
|
209
|
-
{ urls: 'stun:stun.l.google.com:19302' }
|
|
210
|
-
];
|
|
211
|
-
}
|
|
212
|
-
if (options.debug) {
|
|
213
|
-
console.log('[Rondevu] Connecting:', {
|
|
214
|
-
username,
|
|
215
|
-
hasKeypair: !!options.keypair,
|
|
216
|
-
iceServers: iceServers.length,
|
|
217
|
-
batchingEnabled: options.batching !== false
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
// Generate keypair if not provided
|
|
221
|
-
let keypair = options.keypair;
|
|
222
|
-
if (!keypair) {
|
|
223
|
-
if (options.debug)
|
|
224
|
-
console.log('[Rondevu] Generating new keypair...');
|
|
225
|
-
keypair = await RondevuAPI.generateKeypair(options.cryptoAdapter);
|
|
226
|
-
if (options.debug)
|
|
227
|
-
console.log('[Rondevu] Generated keypair, publicKey:', keypair.publicKey);
|
|
228
|
-
}
|
|
229
|
-
else {
|
|
230
|
-
if (options.debug)
|
|
231
|
-
console.log('[Rondevu] Using existing keypair, publicKey:', keypair.publicKey);
|
|
232
|
-
}
|
|
233
|
-
// Create API instance
|
|
234
|
-
const api = new RondevuAPI(options.apiUrl, username, keypair, options.cryptoAdapter, options.batching);
|
|
235
|
-
if (options.debug)
|
|
236
|
-
console.log('[Rondevu] Created API instance');
|
|
237
|
-
return new Rondevu(options.apiUrl, username, keypair, api, iceServers, options.cryptoAdapter, options.batching, options.debug || false, options.rtcPeerConnection, options.rtcIceCandidate);
|
|
238
|
-
}
|
|
239
|
-
/**
|
|
240
|
-
* Generate an anonymous username with timestamp and random component
|
|
241
|
-
*/
|
|
242
|
-
static generateAnonymousUsername() {
|
|
243
|
-
const timestamp = Date.now().toString(36);
|
|
244
|
-
const random = Array.from(crypto.getRandomValues(new Uint8Array(3)))
|
|
245
|
-
.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
246
|
-
return `anon-${timestamp}-${random}`;
|
|
247
|
-
}
|
|
248
|
-
// ============================================
|
|
249
|
-
// Username Management
|
|
250
|
-
// ============================================
|
|
251
|
-
/**
|
|
252
|
-
* Check if username has been claimed (checks with server)
|
|
253
|
-
*/
|
|
254
|
-
async isUsernameClaimed() {
|
|
255
|
-
try {
|
|
256
|
-
const claimed = await this.api.isUsernameClaimed();
|
|
257
|
-
// Update internal flag to match server state
|
|
258
|
-
this.usernameClaimed = claimed;
|
|
259
|
-
return claimed;
|
|
260
|
-
}
|
|
261
|
-
catch (err) {
|
|
262
|
-
console.error('Failed to check username claim status:', err);
|
|
263
|
-
return false;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
// ============================================
|
|
267
|
-
// Service Publishing
|
|
268
|
-
// ============================================
|
|
269
|
-
/**
|
|
270
|
-
* Default offer factory - creates a simple data channel connection
|
|
271
|
-
* The RTCPeerConnection is created by Rondevu and passed in
|
|
272
|
-
*/
|
|
273
|
-
async defaultOfferFactory(pc) {
|
|
274
|
-
const dc = pc.createDataChannel('default');
|
|
275
|
-
const offer = await pc.createOffer();
|
|
276
|
-
await pc.setLocalDescription(offer);
|
|
277
|
-
return { dc, offer };
|
|
278
|
-
}
|
|
279
|
-
/**
|
|
280
|
-
* Publish a service with automatic offer management
|
|
281
|
-
* Call startFilling() to begin accepting connections
|
|
282
|
-
*
|
|
283
|
-
* @example
|
|
284
|
-
* ```typescript
|
|
285
|
-
* await rondevu.publishService({
|
|
286
|
-
* service: 'chat:2.0.0',
|
|
287
|
-
* maxOffers: 5
|
|
288
|
-
* })
|
|
289
|
-
* await rondevu.startFilling()
|
|
290
|
-
* ```
|
|
291
|
-
*/
|
|
292
|
-
async publishService(options) {
|
|
293
|
-
const { service, maxOffers, offerFactory, ttl } = options;
|
|
294
|
-
this.currentService = service;
|
|
295
|
-
this.maxOffers = maxOffers;
|
|
296
|
-
this.offerFactory = offerFactory || this.defaultOfferFactory.bind(this);
|
|
297
|
-
this.ttl = ttl || Rondevu.DEFAULT_TTL_MS;
|
|
298
|
-
this.debug(`Publishing service: ${service} with maxOffers: ${maxOffers}`);
|
|
299
|
-
this.usernameClaimed = true;
|
|
300
|
-
}
|
|
301
|
-
/**
|
|
302
|
-
* Set up ICE candidate handler to send candidates to the server
|
|
303
|
-
*
|
|
304
|
-
* Note: This is used by connectToService() where the offerId is already known.
|
|
305
|
-
* For createOffer(), we use inline ICE handling with early candidate queuing
|
|
306
|
-
* since the offerId isn't available until after the factory completes.
|
|
307
|
-
*/
|
|
308
|
-
setupIceCandidateHandler(pc, serviceFqn, offerId) {
|
|
309
|
-
pc.onicecandidate = async (event) => {
|
|
310
|
-
if (event.candidate) {
|
|
311
|
-
try {
|
|
312
|
-
// Handle both browser and Node.js (wrtc) environments
|
|
313
|
-
// Browser: candidate.toJSON() exists
|
|
314
|
-
// Node.js wrtc: candidate is already a plain object
|
|
315
|
-
const candidateData = typeof event.candidate.toJSON === 'function'
|
|
316
|
-
? event.candidate.toJSON()
|
|
317
|
-
: event.candidate;
|
|
318
|
-
// Emit local ICE candidate event
|
|
319
|
-
this.emit('ice:candidate:local', offerId, candidateData);
|
|
320
|
-
await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData]);
|
|
321
|
-
}
|
|
322
|
-
catch (err) {
|
|
323
|
-
console.error('[Rondevu] Failed to send ICE candidate:', err);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
/**
|
|
329
|
-
* Create a single offer and publish it to the server
|
|
330
|
-
*/
|
|
331
|
-
async createOffer() {
|
|
332
|
-
if (!this.currentService || !this.offerFactory) {
|
|
333
|
-
throw new Error('Service not published. Call publishService() first.');
|
|
334
|
-
}
|
|
335
|
-
const rtcConfig = {
|
|
336
|
-
iceServers: this.iceServers
|
|
337
|
-
};
|
|
338
|
-
// Auto-append username to service
|
|
339
|
-
const serviceFqn = `${this.currentService}@${this.username}`;
|
|
340
|
-
this.debug('Creating new offer...');
|
|
341
|
-
// 1. Create the RTCPeerConnection - Rondevu controls this to set up handlers early
|
|
342
|
-
const pc = new RTCPeerConnection(rtcConfig);
|
|
343
|
-
// 2. Set up ICE candidate handler with queuing BEFORE the factory runs
|
|
344
|
-
// This ensures we capture all candidates, even those generated immediately
|
|
345
|
-
// when setLocalDescription() is called in the factory
|
|
346
|
-
const earlyIceCandidates = [];
|
|
347
|
-
let offerId;
|
|
348
|
-
pc.onicecandidate = async (event) => {
|
|
349
|
-
if (event.candidate) {
|
|
350
|
-
// Handle both browser and Node.js (wrtc) environments
|
|
351
|
-
const candidateData = typeof event.candidate.toJSON === 'function'
|
|
352
|
-
? event.candidate.toJSON()
|
|
353
|
-
: event.candidate;
|
|
354
|
-
// Emit local ICE candidate event
|
|
355
|
-
if (offerId) {
|
|
356
|
-
this.emit('ice:candidate:local', offerId, candidateData);
|
|
357
|
-
}
|
|
358
|
-
if (offerId) {
|
|
359
|
-
// We have the offerId, send directly
|
|
360
|
-
try {
|
|
361
|
-
await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData]);
|
|
362
|
-
}
|
|
363
|
-
catch (err) {
|
|
364
|
-
console.error('[Rondevu] Failed to send ICE candidate:', err);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
else {
|
|
368
|
-
// Queue for later - we don't have the offerId yet
|
|
369
|
-
this.debug('Queuing early ICE candidate');
|
|
370
|
-
earlyIceCandidates.push(candidateData);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
};
|
|
374
|
-
// 3. Call the factory with the pc - factory creates data channel and offer
|
|
375
|
-
// When factory calls setLocalDescription(), ICE gathering starts and
|
|
376
|
-
// candidates are captured by the handler we set up above
|
|
377
|
-
let dc;
|
|
378
|
-
let offer;
|
|
379
|
-
try {
|
|
380
|
-
const factoryResult = await this.offerFactory(pc);
|
|
381
|
-
dc = factoryResult.dc;
|
|
382
|
-
offer = factoryResult.offer;
|
|
383
|
-
}
|
|
384
|
-
catch (err) {
|
|
385
|
-
// Clean up the connection if factory fails
|
|
386
|
-
pc.close();
|
|
387
|
-
throw err;
|
|
388
|
-
}
|
|
389
|
-
// 4. Publish to server to get offerId
|
|
390
|
-
const result = await this.api.publishService({
|
|
391
|
-
serviceFqn,
|
|
392
|
-
offers: [{ sdp: offer.sdp }],
|
|
393
|
-
ttl: this.ttl,
|
|
394
|
-
signature: '',
|
|
395
|
-
message: '',
|
|
396
|
-
});
|
|
397
|
-
offerId = result.offers[0].offerId;
|
|
398
|
-
// 5. Store active offer
|
|
399
|
-
this.activeOffers.set(offerId, {
|
|
400
|
-
offerId,
|
|
401
|
-
serviceFqn,
|
|
402
|
-
pc,
|
|
403
|
-
dc,
|
|
404
|
-
answered: false,
|
|
405
|
-
createdAt: Date.now()
|
|
406
|
-
});
|
|
407
|
-
this.debug(`Offer created: ${offerId}`);
|
|
408
|
-
this.emit('offer:created', offerId, serviceFqn);
|
|
409
|
-
// Set up data channel open handler (offerer side)
|
|
410
|
-
if (dc) {
|
|
411
|
-
dc.onopen = () => {
|
|
412
|
-
this.debug(`Data channel opened for offer ${offerId}`);
|
|
413
|
-
this.emit('connection:opened', offerId, dc);
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
// 6. Send any queued early ICE candidates
|
|
417
|
-
if (earlyIceCandidates.length > 0) {
|
|
418
|
-
this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`);
|
|
419
|
-
try {
|
|
420
|
-
await this.api.addOfferIceCandidates(serviceFqn, offerId, earlyIceCandidates);
|
|
421
|
-
}
|
|
422
|
-
catch (err) {
|
|
423
|
-
console.error('[Rondevu] Failed to send early ICE candidates:', err);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
// 7. Monitor connection state
|
|
427
|
-
pc.onconnectionstatechange = () => {
|
|
428
|
-
this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`);
|
|
429
|
-
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
|
|
430
|
-
this.emit('connection:closed', offerId);
|
|
431
|
-
this.activeOffers.delete(offerId);
|
|
432
|
-
this.fillOffers(); // Try to replace failed offer
|
|
433
|
-
}
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
/**
|
|
437
|
-
* Fill offers to reach maxOffers count
|
|
438
|
-
*/
|
|
439
|
-
async fillOffers() {
|
|
440
|
-
if (!this.filling || !this.currentService)
|
|
441
|
-
return;
|
|
442
|
-
const currentCount = this.activeOffers.size;
|
|
443
|
-
const needed = this.maxOffers - currentCount;
|
|
444
|
-
this.debug(`Filling offers: current=${currentCount}, needed=${needed}`);
|
|
445
|
-
for (let i = 0; i < needed; i++) {
|
|
446
|
-
try {
|
|
447
|
-
await this.createOffer();
|
|
448
|
-
}
|
|
449
|
-
catch (err) {
|
|
450
|
-
console.error('[Rondevu] Failed to create offer:', err);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
/**
|
|
455
|
-
* Poll for answers and ICE candidates (internal use for automatic offer management)
|
|
456
|
-
*/
|
|
457
|
-
async pollInternal() {
|
|
458
|
-
if (!this.filling)
|
|
459
|
-
return;
|
|
460
|
-
// Prevent concurrent poll execution to avoid duplicate answer processing
|
|
461
|
-
if (this.isPolling) {
|
|
462
|
-
this.debug('Poll already in progress, skipping');
|
|
463
|
-
return;
|
|
464
|
-
}
|
|
465
|
-
this.isPolling = true;
|
|
466
|
-
try {
|
|
467
|
-
const result = await this.api.poll(this.lastPollTimestamp);
|
|
468
|
-
// Process answers
|
|
469
|
-
for (const answer of result.answers) {
|
|
470
|
-
const activeOffer = this.activeOffers.get(answer.offerId);
|
|
471
|
-
if (activeOffer && !activeOffer.answered) {
|
|
472
|
-
this.debug(`Received answer for offer ${answer.offerId}`);
|
|
473
|
-
await activeOffer.pc.setRemoteDescription({
|
|
474
|
-
type: 'answer',
|
|
475
|
-
sdp: answer.sdp
|
|
476
|
-
});
|
|
477
|
-
activeOffer.answered = true;
|
|
478
|
-
this.lastPollTimestamp = answer.answeredAt;
|
|
479
|
-
this.emit('offer:answered', answer.offerId, answer.answererId);
|
|
480
|
-
// Create replacement offer
|
|
481
|
-
this.fillOffers();
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
// Process ICE candidates
|
|
485
|
-
for (const [offerId, candidates] of Object.entries(result.iceCandidates)) {
|
|
486
|
-
const activeOffer = this.activeOffers.get(offerId);
|
|
487
|
-
if (activeOffer) {
|
|
488
|
-
const answererCandidates = candidates.filter(c => c.role === 'answerer');
|
|
489
|
-
for (const item of answererCandidates) {
|
|
490
|
-
if (item.candidate) {
|
|
491
|
-
this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
|
|
492
|
-
await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate));
|
|
493
|
-
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
catch (err) {
|
|
500
|
-
console.error('[Rondevu] Polling error:', err);
|
|
501
|
-
}
|
|
502
|
-
finally {
|
|
503
|
-
this.isPolling = false;
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
/**
|
|
507
|
-
* Start filling offers and polling for answers/ICE
|
|
508
|
-
* Call this after publishService() to begin accepting connections
|
|
509
|
-
*/
|
|
510
|
-
async startFilling() {
|
|
511
|
-
if (this.filling) {
|
|
512
|
-
this.debug('Already filling');
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
if (!this.currentService) {
|
|
516
|
-
throw new Error('No service published. Call publishService() first.');
|
|
517
|
-
}
|
|
518
|
-
this.debug('Starting offer filling and polling');
|
|
519
|
-
this.filling = true;
|
|
520
|
-
// Fill initial offers
|
|
521
|
-
await this.fillOffers();
|
|
522
|
-
// Start polling
|
|
523
|
-
this.pollingInterval = setInterval(() => {
|
|
524
|
-
this.pollInternal();
|
|
525
|
-
}, Rondevu.POLLING_INTERVAL_MS);
|
|
526
|
-
}
|
|
527
|
-
/**
|
|
528
|
-
* Stop filling offers and polling
|
|
529
|
-
* Closes all active peer connections
|
|
530
|
-
*/
|
|
531
|
-
stopFilling() {
|
|
532
|
-
this.debug('Stopping offer filling and polling');
|
|
533
|
-
this.filling = false;
|
|
534
|
-
this.isPolling = false; // Reset polling guard
|
|
535
|
-
// Stop polling
|
|
536
|
-
if (this.pollingInterval) {
|
|
537
|
-
clearInterval(this.pollingInterval);
|
|
538
|
-
this.pollingInterval = null;
|
|
539
|
-
}
|
|
540
|
-
// Close all active connections
|
|
541
|
-
for (const [offerId, offer] of this.activeOffers.entries()) {
|
|
542
|
-
this.debug(`Closing offer ${offerId}`);
|
|
543
|
-
offer.dc?.close();
|
|
544
|
-
offer.pc.close();
|
|
545
|
-
}
|
|
546
|
-
this.activeOffers.clear();
|
|
547
|
-
}
|
|
548
|
-
/**
|
|
549
|
-
* Get the count of active offers
|
|
550
|
-
* @returns Number of active offers
|
|
551
|
-
*/
|
|
552
|
-
getOfferCount() {
|
|
553
|
-
return this.activeOffers.size;
|
|
554
|
-
}
|
|
555
|
-
/**
|
|
556
|
-
* Check if an offer is currently connected
|
|
557
|
-
* @param offerId - The offer ID to check
|
|
558
|
-
* @returns True if the offer exists and has been answered
|
|
559
|
-
*/
|
|
560
|
-
isConnected(offerId) {
|
|
561
|
-
const offer = this.activeOffers.get(offerId);
|
|
562
|
-
return offer ? offer.answered : false;
|
|
563
|
-
}
|
|
564
|
-
/**
|
|
565
|
-
* Disconnect all active offers
|
|
566
|
-
* Similar to stopFilling() but doesn't stop the polling/filling process
|
|
567
|
-
*/
|
|
568
|
-
async disconnectAll() {
|
|
569
|
-
this.debug('Disconnecting all offers');
|
|
570
|
-
for (const [offerId, offer] of this.activeOffers.entries()) {
|
|
571
|
-
this.debug(`Closing offer ${offerId}`);
|
|
572
|
-
offer.dc?.close();
|
|
573
|
-
offer.pc.close();
|
|
574
|
-
}
|
|
575
|
-
this.activeOffers.clear();
|
|
576
|
-
}
|
|
577
|
-
/**
|
|
578
|
-
* Get the current service status
|
|
579
|
-
* @returns Object with service state information
|
|
580
|
-
*/
|
|
581
|
-
getServiceStatus() {
|
|
582
|
-
return {
|
|
583
|
-
active: this.currentService !== null,
|
|
584
|
-
offerCount: this.activeOffers.size,
|
|
585
|
-
maxOffers: this.maxOffers,
|
|
586
|
-
filling: this.filling
|
|
587
|
-
};
|
|
588
|
-
}
|
|
589
|
-
/**
|
|
590
|
-
* Resolve the full service FQN from various input options
|
|
591
|
-
* Supports direct FQN, service+username, or service discovery
|
|
592
|
-
*/
|
|
593
|
-
async resolveServiceFqn(options) {
|
|
594
|
-
const { serviceFqn, service, username } = options;
|
|
595
|
-
if (serviceFqn) {
|
|
596
|
-
return serviceFqn;
|
|
597
|
-
}
|
|
598
|
-
else if (service && username) {
|
|
599
|
-
return `${service}@${username}`;
|
|
600
|
-
}
|
|
601
|
-
else if (service) {
|
|
602
|
-
// Discovery mode - get random service
|
|
603
|
-
this.debug(`Discovering service: ${service}`);
|
|
604
|
-
const discovered = await this.findService(service);
|
|
605
|
-
return discovered.serviceFqn;
|
|
606
|
-
}
|
|
607
|
-
else {
|
|
608
|
-
throw new Error('Either serviceFqn or service must be provided');
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
/**
|
|
612
|
-
* Start polling for remote ICE candidates
|
|
613
|
-
* Returns the polling interval ID
|
|
614
|
-
*/
|
|
615
|
-
startIcePolling(pc, serviceFqn, offerId) {
|
|
616
|
-
let lastIceTimestamp = 0;
|
|
617
|
-
return setInterval(async () => {
|
|
618
|
-
try {
|
|
619
|
-
const result = await this.api.getOfferIceCandidates(serviceFqn, offerId, lastIceTimestamp);
|
|
620
|
-
for (const item of result.candidates) {
|
|
621
|
-
if (item.candidate) {
|
|
622
|
-
this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
|
|
623
|
-
await pc.addIceCandidate(new RTCIceCandidate(item.candidate));
|
|
624
|
-
lastIceTimestamp = item.createdAt;
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
catch (err) {
|
|
629
|
-
console.error('[Rondevu] Failed to poll ICE candidates:', err);
|
|
630
|
-
}
|
|
631
|
-
}, Rondevu.POLLING_INTERVAL_MS);
|
|
632
|
-
}
|
|
633
|
-
/**
|
|
634
|
-
* Automatically connect to a service (answerer side)
|
|
635
|
-
* Handles the entire connection flow: discovery, WebRTC setup, answer exchange, ICE candidates
|
|
636
|
-
*
|
|
637
|
-
* @example
|
|
638
|
-
* ```typescript
|
|
639
|
-
* // Connect to specific user
|
|
640
|
-
* const connection = await rondevu.connectToService({
|
|
641
|
-
* serviceFqn: 'chat:2.0.0@alice',
|
|
642
|
-
* onConnection: ({ dc, peerUsername }) => {
|
|
643
|
-
* console.log('Connected to', peerUsername)
|
|
644
|
-
* dc.addEventListener('message', (e) => console.log(e.data))
|
|
645
|
-
* dc.addEventListener('open', () => dc.send('Hello!'))
|
|
646
|
-
* }
|
|
647
|
-
* })
|
|
648
|
-
*
|
|
649
|
-
* // Discover random service
|
|
650
|
-
* const connection = await rondevu.connectToService({
|
|
651
|
-
* service: 'chat:2.0.0',
|
|
652
|
-
* onConnection: ({ dc, peerUsername }) => {
|
|
653
|
-
* console.log('Connected to', peerUsername)
|
|
654
|
-
* }
|
|
655
|
-
* })
|
|
656
|
-
* ```
|
|
657
|
-
*/
|
|
658
|
-
async connectToService(options) {
|
|
659
|
-
const { onConnection, rtcConfig } = options;
|
|
660
|
-
// Validate inputs
|
|
661
|
-
if (options.serviceFqn !== undefined && typeof options.serviceFqn === 'string' && !options.serviceFqn.trim()) {
|
|
662
|
-
throw new Error('serviceFqn cannot be empty');
|
|
663
|
-
}
|
|
664
|
-
if (options.service !== undefined && typeof options.service === 'string' && !options.service.trim()) {
|
|
665
|
-
throw new Error('service cannot be empty');
|
|
666
|
-
}
|
|
667
|
-
if (options.username !== undefined && typeof options.username === 'string' && !options.username.trim()) {
|
|
668
|
-
throw new Error('username cannot be empty');
|
|
669
|
-
}
|
|
670
|
-
// Determine the full service FQN
|
|
671
|
-
const fqn = await this.resolveServiceFqn(options);
|
|
672
|
-
this.debug(`Connecting to service: ${fqn}`);
|
|
673
|
-
// 1. Get service offer
|
|
674
|
-
const serviceData = await this.api.getService(fqn);
|
|
675
|
-
this.debug(`Found service from @${serviceData.username}`);
|
|
676
|
-
// 2. Create RTCPeerConnection
|
|
677
|
-
const rtcConfiguration = rtcConfig || {
|
|
678
|
-
iceServers: this.iceServers
|
|
679
|
-
};
|
|
680
|
-
const pc = new RTCPeerConnection(rtcConfiguration);
|
|
681
|
-
// 3. Set up data channel handler (answerer receives it from offerer)
|
|
682
|
-
let dc = null;
|
|
683
|
-
const dataChannelPromise = new Promise((resolve) => {
|
|
684
|
-
pc.ondatachannel = (event) => {
|
|
685
|
-
this.debug('Data channel received from offerer');
|
|
686
|
-
dc = event.channel;
|
|
687
|
-
this.emit('connection:opened', serviceData.offerId, dc);
|
|
688
|
-
resolve(dc);
|
|
689
|
-
};
|
|
690
|
-
});
|
|
691
|
-
// 4. Set up ICE candidate exchange
|
|
692
|
-
this.setupIceCandidateHandler(pc, serviceData.serviceFqn, serviceData.offerId);
|
|
693
|
-
// 5. Poll for remote ICE candidates
|
|
694
|
-
const icePollInterval = this.startIcePolling(pc, serviceData.serviceFqn, serviceData.offerId);
|
|
695
|
-
// 6. Set remote description
|
|
696
|
-
await pc.setRemoteDescription({
|
|
697
|
-
type: 'offer',
|
|
698
|
-
sdp: serviceData.sdp
|
|
699
|
-
});
|
|
700
|
-
// 7. Create and send answer
|
|
701
|
-
const answer = await pc.createAnswer();
|
|
702
|
-
await pc.setLocalDescription(answer);
|
|
703
|
-
await this.api.answerOffer(serviceData.serviceFqn, serviceData.offerId, answer.sdp);
|
|
704
|
-
// 8. Wait for data channel to be established
|
|
705
|
-
dc = await dataChannelPromise;
|
|
706
|
-
// Create connection context
|
|
707
|
-
const context = {
|
|
708
|
-
pc,
|
|
709
|
-
dc,
|
|
710
|
-
serviceFqn: serviceData.serviceFqn,
|
|
711
|
-
offerId: serviceData.offerId,
|
|
712
|
-
peerUsername: serviceData.username
|
|
713
|
-
};
|
|
714
|
-
// 9. Set up connection state monitoring
|
|
715
|
-
pc.onconnectionstatechange = () => {
|
|
716
|
-
this.debug(`Connection state: ${pc.connectionState}`);
|
|
717
|
-
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
|
|
718
|
-
clearInterval(icePollInterval);
|
|
719
|
-
}
|
|
720
|
-
};
|
|
721
|
-
// 10. Wait for data channel to open and call onConnection
|
|
722
|
-
if (dc.readyState === 'open') {
|
|
723
|
-
this.debug('Data channel already open');
|
|
724
|
-
if (onConnection) {
|
|
725
|
-
await onConnection(context);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
else {
|
|
729
|
-
await new Promise((resolve) => {
|
|
730
|
-
dc.addEventListener('open', async () => {
|
|
731
|
-
this.debug('Data channel opened');
|
|
732
|
-
if (onConnection) {
|
|
733
|
-
await onConnection(context);
|
|
734
|
-
}
|
|
735
|
-
resolve();
|
|
736
|
-
});
|
|
737
|
-
});
|
|
738
|
-
}
|
|
739
|
-
return context;
|
|
740
|
-
}
|
|
741
|
-
// ============================================
|
|
742
|
-
// Service Discovery
|
|
743
|
-
// ============================================
|
|
744
|
-
/**
|
|
745
|
-
* Find a service - unified discovery method
|
|
746
|
-
*
|
|
747
|
-
* Replaces getService(), discoverService(), and discoverServices() with a single method.
|
|
748
|
-
*
|
|
749
|
-
* @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
|
|
750
|
-
* @param options - Discovery options
|
|
751
|
-
*
|
|
752
|
-
* @example
|
|
753
|
-
* ```typescript
|
|
754
|
-
* // Direct lookup (has username)
|
|
755
|
-
* const service = await rondevu.findService('chat:1.0.0@alice')
|
|
756
|
-
*
|
|
757
|
-
* // Random discovery (no username)
|
|
758
|
-
* const service = await rondevu.findService('chat:1.0.0')
|
|
759
|
-
*
|
|
760
|
-
* // Paginated discovery
|
|
761
|
-
* const result = await rondevu.findService('chat:1.0.0', {
|
|
762
|
-
* mode: 'paginated',
|
|
763
|
-
* limit: 20,
|
|
764
|
-
* offset: 0
|
|
765
|
-
* })
|
|
766
|
-
* ```
|
|
767
|
-
*/
|
|
768
|
-
async findService(serviceFqn, options) {
|
|
769
|
-
const { mode, limit = 10, offset = 0 } = options || {};
|
|
770
|
-
// Auto-detect mode if not specified
|
|
771
|
-
const hasUsername = serviceFqn.includes('@');
|
|
772
|
-
const effectiveMode = mode || (hasUsername ? 'direct' : 'random');
|
|
773
|
-
if (effectiveMode === 'paginated') {
|
|
774
|
-
return await this.api.getService(serviceFqn, { limit, offset });
|
|
775
|
-
}
|
|
776
|
-
else {
|
|
777
|
-
// Both 'direct' and 'random' use the same API call
|
|
778
|
-
return await this.api.getService(serviceFqn);
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
// ============================================
|
|
782
|
-
// WebRTC Signaling
|
|
783
|
-
// ============================================
|
|
784
|
-
/**
|
|
785
|
-
* Post answer SDP to specific offer
|
|
786
|
-
*/
|
|
787
|
-
async postOfferAnswer(serviceFqn, offerId, sdp) {
|
|
788
|
-
await this.api.answerOffer(serviceFqn, offerId, sdp);
|
|
789
|
-
return { success: true, offerId };
|
|
790
|
-
}
|
|
791
|
-
/**
|
|
792
|
-
* Get answer SDP (offerer polls this)
|
|
793
|
-
*/
|
|
794
|
-
async getOfferAnswer(serviceFqn, offerId) {
|
|
795
|
-
return await this.api.getOfferAnswer(serviceFqn, offerId);
|
|
796
|
-
}
|
|
797
|
-
/**
|
|
798
|
-
* Combined polling for answers and ICE candidates
|
|
799
|
-
* Returns all answered offers and ICE candidates for all peer's offers since timestamp
|
|
800
|
-
*/
|
|
801
|
-
async poll(since) {
|
|
802
|
-
return await this.api.poll(since);
|
|
803
|
-
}
|
|
804
|
-
/**
|
|
805
|
-
* Add ICE candidates to specific offer
|
|
806
|
-
*/
|
|
807
|
-
async addOfferIceCandidates(serviceFqn, offerId, candidates) {
|
|
808
|
-
return await this.api.addOfferIceCandidates(serviceFqn, offerId, candidates);
|
|
809
|
-
}
|
|
810
|
-
/**
|
|
811
|
-
* Get ICE candidates for specific offer (with polling support)
|
|
812
|
-
*/
|
|
813
|
-
async getOfferIceCandidates(serviceFqn, offerId, since = 0) {
|
|
814
|
-
return await this.api.getOfferIceCandidates(serviceFqn, offerId, since);
|
|
815
|
-
}
|
|
816
|
-
// ============================================
|
|
817
|
-
// Utility Methods
|
|
818
|
-
// ============================================
|
|
819
|
-
/**
|
|
820
|
-
* Get the current keypair (for backup/storage)
|
|
821
|
-
*/
|
|
822
|
-
getKeypair() {
|
|
823
|
-
return this.keypair;
|
|
824
|
-
}
|
|
825
|
-
/**
|
|
826
|
-
* Get the username
|
|
827
|
-
*/
|
|
828
|
-
getUsername() {
|
|
829
|
-
return this.username;
|
|
830
|
-
}
|
|
831
|
-
/**
|
|
832
|
-
* Get the public key
|
|
833
|
-
*/
|
|
834
|
-
getPublicKey() {
|
|
835
|
-
return this.keypair.publicKey;
|
|
836
|
-
}
|
|
837
|
-
/**
|
|
838
|
-
* Access to underlying API for advanced operations
|
|
839
|
-
* @deprecated Use direct methods on Rondevu instance instead
|
|
840
|
-
*/
|
|
841
|
-
getAPIPublic() {
|
|
842
|
-
return this.api;
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
// Constants
|
|
846
|
-
Rondevu.DEFAULT_TTL_MS = 300000; // 5 minutes
|
|
847
|
-
Rondevu.POLLING_INTERVAL_MS = 1000; // 1 second
|