@xtr-dev/rondevu-client 0.13.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +519 -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
package/dist/rondevu.js
CHANGED
|
@@ -1,9 +1,53 @@
|
|
|
1
1
|
import { RondevuAPI } from './api.js';
|
|
2
|
+
// ICE server presets
|
|
3
|
+
export const ICE_SERVER_PRESETS = {
|
|
4
|
+
'ipv4-turn': [
|
|
5
|
+
{ urls: 'stun:57.129.61.67:3478' },
|
|
6
|
+
{
|
|
7
|
+
urls: [
|
|
8
|
+
'turn:57.129.61.67:3478?transport=tcp',
|
|
9
|
+
'turn:57.129.61.67:3478?transport=udp',
|
|
10
|
+
],
|
|
11
|
+
username: 'webrtcuser',
|
|
12
|
+
credential: 'supersecretpassword'
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
'hostname-turns': [
|
|
16
|
+
{ urls: 'stun:turn.share.fish:3478' },
|
|
17
|
+
{
|
|
18
|
+
urls: [
|
|
19
|
+
'turns:turn.share.fish:5349?transport=tcp',
|
|
20
|
+
'turns:turn.share.fish:5349?transport=udp',
|
|
21
|
+
'turn:turn.share.fish:3478?transport=tcp',
|
|
22
|
+
'turn:turn.share.fish:3478?transport=udp',
|
|
23
|
+
],
|
|
24
|
+
username: 'webrtcuser',
|
|
25
|
+
credential: 'supersecretpassword'
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
'google-stun': [
|
|
29
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
30
|
+
{ urls: 'stun:stun1.l.google.com:19302' }
|
|
31
|
+
],
|
|
32
|
+
'relay-only': [
|
|
33
|
+
{ urls: 'stun:57.129.61.67:3478' },
|
|
34
|
+
{
|
|
35
|
+
urls: [
|
|
36
|
+
'turn:57.129.61.67:3478?transport=tcp',
|
|
37
|
+
'turn:57.129.61.67:3478?transport=udp',
|
|
38
|
+
],
|
|
39
|
+
username: 'webrtcuser',
|
|
40
|
+
credential: 'supersecretpassword',
|
|
41
|
+
// @ts-expect-error - iceTransportPolicy is valid but not in RTCIceServer type
|
|
42
|
+
iceTransportPolicy: 'relay'
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
};
|
|
2
46
|
/**
|
|
3
47
|
* Rondevu - Complete WebRTC signaling client
|
|
4
48
|
*
|
|
5
49
|
* Provides a unified API for:
|
|
6
|
-
* -
|
|
50
|
+
* - Implicit username claiming (auto-claimed on first authenticated request)
|
|
7
51
|
* - Service publishing with automatic signature generation
|
|
8
52
|
* - Service discovery (direct, random, paginated)
|
|
9
53
|
* - WebRTC signaling (offer/answer exchange, ICE relay)
|
|
@@ -11,114 +55,152 @@ import { RondevuAPI } from './api.js';
|
|
|
11
55
|
*
|
|
12
56
|
* @example
|
|
13
57
|
* ```typescript
|
|
14
|
-
* //
|
|
15
|
-
* const rondevu =
|
|
58
|
+
* // Create and initialize Rondevu instance with preset ICE servers
|
|
59
|
+
* const rondevu = await Rondevu.connect({
|
|
16
60
|
* apiUrl: 'https://signal.example.com',
|
|
17
61
|
* username: 'alice',
|
|
62
|
+
* iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
|
|
18
63
|
* })
|
|
19
64
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
65
|
+
* // Or use custom ICE servers
|
|
66
|
+
* const rondevu2 = await Rondevu.connect({
|
|
67
|
+
* apiUrl: 'https://signal.example.com',
|
|
68
|
+
* username: 'bob',
|
|
69
|
+
* iceServers: [
|
|
70
|
+
* { urls: 'stun:stun.l.google.com:19302' },
|
|
71
|
+
* { urls: 'turn:turn.example.com:3478', username: 'user', credential: 'pass' }
|
|
72
|
+
* ]
|
|
73
|
+
* })
|
|
24
74
|
*
|
|
25
|
-
* // Publish a service
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
75
|
+
* // Publish a service with automatic offer management
|
|
76
|
+
* await rondevu.publishService({
|
|
77
|
+
* service: 'chat:2.0.0',
|
|
78
|
+
* maxOffers: 5, // Maintain up to 5 concurrent offers
|
|
79
|
+
* offerFactory: async (rtcConfig) => {
|
|
80
|
+
* const pc = new RTCPeerConnection(rtcConfig)
|
|
81
|
+
* const dc = pc.createDataChannel('chat')
|
|
82
|
+
* const offer = await pc.createOffer()
|
|
83
|
+
* await pc.setLocalDescription(offer)
|
|
84
|
+
* return { pc, dc, offer }
|
|
85
|
+
* }
|
|
30
86
|
* })
|
|
31
87
|
*
|
|
32
|
-
* //
|
|
33
|
-
*
|
|
88
|
+
* // Start accepting connections (auto-fills offers and polls)
|
|
89
|
+
* await rondevu.startFilling()
|
|
90
|
+
*
|
|
91
|
+
* // Access active connections
|
|
92
|
+
* for (const offer of rondevu.getActiveOffers()) {
|
|
93
|
+
* offer.dc?.addEventListener('message', (e) => console.log(e.data))
|
|
94
|
+
* }
|
|
34
95
|
*
|
|
35
|
-
* //
|
|
36
|
-
*
|
|
96
|
+
* // Stop when done
|
|
97
|
+
* rondevu.stopFilling()
|
|
37
98
|
* ```
|
|
38
99
|
*/
|
|
39
100
|
export class Rondevu {
|
|
40
|
-
constructor(
|
|
41
|
-
this.keypair = null;
|
|
101
|
+
constructor(apiUrl, username, keypair, api, iceServers, cryptoAdapter, batchingOptions, debugEnabled = false) {
|
|
42
102
|
this.usernameClaimed = false;
|
|
43
|
-
|
|
44
|
-
this.
|
|
45
|
-
this.
|
|
46
|
-
|
|
103
|
+
// Service management
|
|
104
|
+
this.currentService = null;
|
|
105
|
+
this.maxOffers = 0;
|
|
106
|
+
this.offerFactory = null;
|
|
107
|
+
this.ttl = Rondevu.DEFAULT_TTL_MS;
|
|
108
|
+
this.activeOffers = new Map();
|
|
109
|
+
// Polling
|
|
110
|
+
this.filling = false;
|
|
111
|
+
this.pollingInterval = null;
|
|
112
|
+
this.lastPollTimestamp = 0;
|
|
113
|
+
this.apiUrl = apiUrl;
|
|
114
|
+
this.username = username;
|
|
115
|
+
this.keypair = keypair;
|
|
116
|
+
this.api = api;
|
|
117
|
+
this.iceServers = iceServers;
|
|
118
|
+
this.cryptoAdapter = cryptoAdapter;
|
|
119
|
+
this.batchingOptions = batchingOptions;
|
|
120
|
+
this.debugEnabled = debugEnabled;
|
|
121
|
+
this.debug('Instance created:', {
|
|
47
122
|
username: this.username,
|
|
48
|
-
|
|
49
|
-
|
|
123
|
+
publicKey: this.keypair.publicKey,
|
|
124
|
+
hasIceServers: iceServers.length > 0,
|
|
125
|
+
batchingEnabled: batchingOptions !== false
|
|
50
126
|
});
|
|
51
127
|
}
|
|
52
|
-
// ============================================
|
|
53
|
-
// Initialization
|
|
54
|
-
// ============================================
|
|
55
128
|
/**
|
|
56
|
-
*
|
|
57
|
-
* Call this before using other methods
|
|
129
|
+
* Internal debug logging - only logs if debug mode is enabled
|
|
58
130
|
*/
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
131
|
+
debug(message, ...args) {
|
|
132
|
+
if (this.debugEnabled) {
|
|
133
|
+
console.log(`[Rondevu] ${message}`, ...args);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Create and initialize a Rondevu client
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```typescript
|
|
141
|
+
* const rondevu = await Rondevu.connect({
|
|
142
|
+
* apiUrl: 'https://api.ronde.vu',
|
|
143
|
+
* username: 'alice'
|
|
144
|
+
* })
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
static async connect(options) {
|
|
148
|
+
const username = options.username || Rondevu.generateAnonymousUsername();
|
|
149
|
+
// Handle preset string or custom array
|
|
150
|
+
let iceServers;
|
|
151
|
+
if (typeof options.iceServers === 'string') {
|
|
152
|
+
iceServers = ICE_SERVER_PRESETS[options.iceServers];
|
|
65
153
|
}
|
|
66
154
|
else {
|
|
67
|
-
|
|
155
|
+
iceServers = options.iceServers || [
|
|
156
|
+
{ urls: 'stun:stun.l.google.com:19302' }
|
|
157
|
+
];
|
|
68
158
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
159
|
+
if (options.debug) {
|
|
160
|
+
console.log('[Rondevu] Connecting:', {
|
|
161
|
+
username,
|
|
162
|
+
hasKeypair: !!options.keypair,
|
|
163
|
+
iceServers: iceServers.length,
|
|
164
|
+
batchingEnabled: options.batching !== false
|
|
165
|
+
});
|
|
73
166
|
}
|
|
167
|
+
// Generate keypair if not provided
|
|
168
|
+
let keypair = options.keypair;
|
|
169
|
+
if (!keypair) {
|
|
170
|
+
if (options.debug)
|
|
171
|
+
console.log('[Rondevu] Generating new keypair...');
|
|
172
|
+
keypair = await RondevuAPI.generateKeypair(options.cryptoAdapter);
|
|
173
|
+
if (options.debug)
|
|
174
|
+
console.log('[Rondevu] Generated keypair, publicKey:', keypair.publicKey);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
if (options.debug)
|
|
178
|
+
console.log('[Rondevu] Using existing keypair, publicKey:', keypair.publicKey);
|
|
179
|
+
}
|
|
180
|
+
// Create API instance
|
|
181
|
+
const api = new RondevuAPI(options.apiUrl, username, keypair, options.cryptoAdapter, options.batching);
|
|
182
|
+
if (options.debug)
|
|
183
|
+
console.log('[Rondevu] Created API instance');
|
|
184
|
+
return new Rondevu(options.apiUrl, username, keypair, api, iceServers, options.cryptoAdapter, options.batching, options.debug || false);
|
|
74
185
|
}
|
|
75
|
-
// ============================================
|
|
76
|
-
// Username Management
|
|
77
|
-
// ============================================
|
|
78
186
|
/**
|
|
79
|
-
*
|
|
80
|
-
* Should be called once before publishing services
|
|
187
|
+
* Generate an anonymous username with timestamp and random component
|
|
81
188
|
*/
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const check = await this.api.checkUsername(this.username);
|
|
88
|
-
if (!check.available) {
|
|
89
|
-
// Verify it's claimed by us
|
|
90
|
-
if (check.publicKey === this.keypair.publicKey) {
|
|
91
|
-
this.usernameClaimed = true;
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
throw new Error(`Username "${this.username}" is already claimed by another user`);
|
|
95
|
-
}
|
|
96
|
-
// Generate signature for username claim
|
|
97
|
-
const message = `claim:${this.username}:${Date.now()}`;
|
|
98
|
-
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey);
|
|
99
|
-
// Claim the username
|
|
100
|
-
await this.api.claimUsername(this.username, this.keypair.publicKey, signature, message);
|
|
101
|
-
this.usernameClaimed = true;
|
|
189
|
+
static generateAnonymousUsername() {
|
|
190
|
+
const timestamp = Date.now().toString(36);
|
|
191
|
+
const random = Array.from(crypto.getRandomValues(new Uint8Array(3)))
|
|
192
|
+
.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
193
|
+
return `anon-${timestamp}-${random}`;
|
|
102
194
|
}
|
|
195
|
+
// ============================================
|
|
196
|
+
// Username Management
|
|
197
|
+
// ============================================
|
|
103
198
|
/**
|
|
104
199
|
* Check if username has been claimed (checks with server)
|
|
105
200
|
*/
|
|
106
201
|
async isUsernameClaimed() {
|
|
107
|
-
if (!this.keypair) {
|
|
108
|
-
return false;
|
|
109
|
-
}
|
|
110
202
|
try {
|
|
111
|
-
const
|
|
112
|
-
// Debug logging
|
|
113
|
-
console.log('[Rondevu] Username check:', {
|
|
114
|
-
username: this.username,
|
|
115
|
-
available: check.available,
|
|
116
|
-
serverPublicKey: check.publicKey,
|
|
117
|
-
localPublicKey: this.keypair.publicKey,
|
|
118
|
-
match: check.publicKey === this.keypair.publicKey
|
|
119
|
-
});
|
|
120
|
-
// Username is claimed if it's not available and owned by our public key
|
|
121
|
-
const claimed = !check.available && check.publicKey === this.keypair.publicKey;
|
|
203
|
+
const claimed = await this.api.isUsernameClaimed();
|
|
122
204
|
// Update internal flag to match server state
|
|
123
205
|
this.usernameClaimed = claimed;
|
|
124
206
|
return claimed;
|
|
@@ -132,29 +214,352 @@ export class Rondevu {
|
|
|
132
214
|
// Service Publishing
|
|
133
215
|
// ============================================
|
|
134
216
|
/**
|
|
135
|
-
*
|
|
217
|
+
* Default offer factory - creates a simple data channel connection
|
|
218
|
+
*/
|
|
219
|
+
async defaultOfferFactory(rtcConfig) {
|
|
220
|
+
const pc = new RTCPeerConnection(rtcConfig);
|
|
221
|
+
const dc = pc.createDataChannel('default');
|
|
222
|
+
const offer = await pc.createOffer();
|
|
223
|
+
await pc.setLocalDescription(offer);
|
|
224
|
+
return { pc, dc, offer };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Publish a service with automatic offer management
|
|
228
|
+
* Call startFilling() to begin accepting connections
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* ```typescript
|
|
232
|
+
* await rondevu.publishService({
|
|
233
|
+
* service: 'chat:2.0.0',
|
|
234
|
+
* maxOffers: 5
|
|
235
|
+
* })
|
|
236
|
+
* await rondevu.startFilling()
|
|
237
|
+
* ```
|
|
136
238
|
*/
|
|
137
239
|
async publishService(options) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
240
|
+
const { service, maxOffers, offerFactory, ttl } = options;
|
|
241
|
+
this.currentService = service;
|
|
242
|
+
this.maxOffers = maxOffers;
|
|
243
|
+
this.offerFactory = offerFactory || this.defaultOfferFactory.bind(this);
|
|
244
|
+
this.ttl = ttl || Rondevu.DEFAULT_TTL_MS;
|
|
245
|
+
this.debug(`Publishing service: ${service} with maxOffers: ${maxOffers}`);
|
|
246
|
+
this.usernameClaimed = true;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Set up ICE candidate handler to send candidates to the server
|
|
250
|
+
*/
|
|
251
|
+
setupIceCandidateHandler(pc, serviceFqn, offerId) {
|
|
252
|
+
pc.onicecandidate = async (event) => {
|
|
253
|
+
if (event.candidate) {
|
|
254
|
+
try {
|
|
255
|
+
// Handle both browser and Node.js (wrtc) environments
|
|
256
|
+
// Browser: candidate.toJSON() exists
|
|
257
|
+
// Node.js wrtc: candidate is already a plain object
|
|
258
|
+
const candidateData = typeof event.candidate.toJSON === 'function'
|
|
259
|
+
? event.candidate.toJSON()
|
|
260
|
+
: event.candidate;
|
|
261
|
+
await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData]);
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
console.error('[Rondevu] Failed to send ICE candidate:', err);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Create a single offer and publish it to the server
|
|
271
|
+
*/
|
|
272
|
+
async createOffer() {
|
|
273
|
+
if (!this.currentService || !this.offerFactory) {
|
|
274
|
+
throw new Error('Service not published. Call publishService() first.');
|
|
275
|
+
}
|
|
276
|
+
const rtcConfig = {
|
|
277
|
+
iceServers: this.iceServers
|
|
155
278
|
};
|
|
279
|
+
this.debug('Creating new offer...');
|
|
280
|
+
// Create the offer using the factory
|
|
281
|
+
const { pc, dc, offer } = await this.offerFactory(rtcConfig);
|
|
282
|
+
// Auto-append username to service
|
|
283
|
+
const serviceFqn = `${this.currentService}@${this.username}`;
|
|
156
284
|
// Publish to server
|
|
157
|
-
|
|
285
|
+
const result = await this.api.publishService({
|
|
286
|
+
serviceFqn,
|
|
287
|
+
offers: [{ sdp: offer.sdp }],
|
|
288
|
+
ttl: this.ttl,
|
|
289
|
+
signature: '',
|
|
290
|
+
message: '',
|
|
291
|
+
});
|
|
292
|
+
const offerId = result.offers[0].offerId;
|
|
293
|
+
// Store active offer
|
|
294
|
+
this.activeOffers.set(offerId, {
|
|
295
|
+
offerId,
|
|
296
|
+
serviceFqn,
|
|
297
|
+
pc,
|
|
298
|
+
dc,
|
|
299
|
+
answered: false,
|
|
300
|
+
createdAt: Date.now()
|
|
301
|
+
});
|
|
302
|
+
this.debug(`Offer created: ${offerId}`);
|
|
303
|
+
// Set up ICE candidate handler
|
|
304
|
+
this.setupIceCandidateHandler(pc, serviceFqn, offerId);
|
|
305
|
+
// Monitor connection state
|
|
306
|
+
pc.onconnectionstatechange = () => {
|
|
307
|
+
this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`);
|
|
308
|
+
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
|
|
309
|
+
this.activeOffers.delete(offerId);
|
|
310
|
+
this.fillOffers(); // Try to replace failed offer
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Fill offers to reach maxOffers count
|
|
316
|
+
*/
|
|
317
|
+
async fillOffers() {
|
|
318
|
+
if (!this.filling || !this.currentService)
|
|
319
|
+
return;
|
|
320
|
+
const currentCount = this.activeOffers.size;
|
|
321
|
+
const needed = this.maxOffers - currentCount;
|
|
322
|
+
this.debug(`Filling offers: current=${currentCount}, needed=${needed}`);
|
|
323
|
+
for (let i = 0; i < needed; i++) {
|
|
324
|
+
try {
|
|
325
|
+
await this.createOffer();
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
console.error('[Rondevu] Failed to create offer:', err);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Poll for answers and ICE candidates (internal use for automatic offer management)
|
|
334
|
+
*/
|
|
335
|
+
async pollInternal() {
|
|
336
|
+
if (!this.filling)
|
|
337
|
+
return;
|
|
338
|
+
try {
|
|
339
|
+
const result = await this.api.poll(this.lastPollTimestamp);
|
|
340
|
+
// Process answers
|
|
341
|
+
for (const answer of result.answers) {
|
|
342
|
+
const activeOffer = this.activeOffers.get(answer.offerId);
|
|
343
|
+
if (activeOffer && !activeOffer.answered) {
|
|
344
|
+
this.debug(`Received answer for offer ${answer.offerId}`);
|
|
345
|
+
await activeOffer.pc.setRemoteDescription({
|
|
346
|
+
type: 'answer',
|
|
347
|
+
sdp: answer.sdp
|
|
348
|
+
});
|
|
349
|
+
activeOffer.answered = true;
|
|
350
|
+
this.lastPollTimestamp = answer.answeredAt;
|
|
351
|
+
// Create replacement offer
|
|
352
|
+
this.fillOffers();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// Process ICE candidates
|
|
356
|
+
for (const [offerId, candidates] of Object.entries(result.iceCandidates)) {
|
|
357
|
+
const activeOffer = this.activeOffers.get(offerId);
|
|
358
|
+
if (activeOffer) {
|
|
359
|
+
const answererCandidates = candidates.filter(c => c.role === 'answerer');
|
|
360
|
+
for (const item of answererCandidates) {
|
|
361
|
+
if (item.candidate) {
|
|
362
|
+
await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate));
|
|
363
|
+
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
console.error('[Rondevu] Polling error:', err);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Start filling offers and polling for answers/ICE
|
|
375
|
+
* Call this after publishService() to begin accepting connections
|
|
376
|
+
*/
|
|
377
|
+
async startFilling() {
|
|
378
|
+
if (this.filling) {
|
|
379
|
+
this.debug('Already filling');
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (!this.currentService) {
|
|
383
|
+
throw new Error('No service published. Call publishService() first.');
|
|
384
|
+
}
|
|
385
|
+
this.debug('Starting offer filling and polling');
|
|
386
|
+
this.filling = true;
|
|
387
|
+
// Fill initial offers
|
|
388
|
+
await this.fillOffers();
|
|
389
|
+
// Start polling
|
|
390
|
+
this.pollingInterval = setInterval(() => {
|
|
391
|
+
this.pollInternal();
|
|
392
|
+
}, Rondevu.POLLING_INTERVAL_MS);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Stop filling offers and polling
|
|
396
|
+
* Closes all active peer connections
|
|
397
|
+
*/
|
|
398
|
+
stopFilling() {
|
|
399
|
+
this.debug('Stopping offer filling and polling');
|
|
400
|
+
this.filling = false;
|
|
401
|
+
// Stop polling
|
|
402
|
+
if (this.pollingInterval) {
|
|
403
|
+
clearInterval(this.pollingInterval);
|
|
404
|
+
this.pollingInterval = null;
|
|
405
|
+
}
|
|
406
|
+
// Close all active connections
|
|
407
|
+
for (const [offerId, offer] of this.activeOffers.entries()) {
|
|
408
|
+
this.debug(`Closing offer ${offerId}`);
|
|
409
|
+
offer.dc?.close();
|
|
410
|
+
offer.pc.close();
|
|
411
|
+
}
|
|
412
|
+
this.activeOffers.clear();
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Resolve the full service FQN from various input options
|
|
416
|
+
* Supports direct FQN, service+username, or service discovery
|
|
417
|
+
*/
|
|
418
|
+
async resolveServiceFqn(options) {
|
|
419
|
+
const { serviceFqn, service, username } = options;
|
|
420
|
+
if (serviceFqn) {
|
|
421
|
+
return serviceFqn;
|
|
422
|
+
}
|
|
423
|
+
else if (service && username) {
|
|
424
|
+
return `${service}@${username}`;
|
|
425
|
+
}
|
|
426
|
+
else if (service) {
|
|
427
|
+
// Discovery mode - get random service
|
|
428
|
+
this.debug(`Discovering service: ${service}`);
|
|
429
|
+
const discovered = await this.discoverService(service);
|
|
430
|
+
return discovered.serviceFqn;
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
throw new Error('Either serviceFqn or service must be provided');
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Start polling for remote ICE candidates
|
|
438
|
+
* Returns the polling interval ID
|
|
439
|
+
*/
|
|
440
|
+
startIcePolling(pc, serviceFqn, offerId) {
|
|
441
|
+
let lastIceTimestamp = 0;
|
|
442
|
+
return setInterval(async () => {
|
|
443
|
+
try {
|
|
444
|
+
const result = await this.api.getOfferIceCandidates(serviceFqn, offerId, lastIceTimestamp);
|
|
445
|
+
for (const item of result.candidates) {
|
|
446
|
+
if (item.candidate) {
|
|
447
|
+
await pc.addIceCandidate(new RTCIceCandidate(item.candidate));
|
|
448
|
+
lastIceTimestamp = item.createdAt;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
console.error('[Rondevu] Failed to poll ICE candidates:', err);
|
|
454
|
+
}
|
|
455
|
+
}, Rondevu.POLLING_INTERVAL_MS);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Automatically connect to a service (answerer side)
|
|
459
|
+
* Handles the entire connection flow: discovery, WebRTC setup, answer exchange, ICE candidates
|
|
460
|
+
*
|
|
461
|
+
* @example
|
|
462
|
+
* ```typescript
|
|
463
|
+
* // Connect to specific user
|
|
464
|
+
* const connection = await rondevu.connectToService({
|
|
465
|
+
* serviceFqn: 'chat:2.0.0@alice',
|
|
466
|
+
* onConnection: ({ dc, peerUsername }) => {
|
|
467
|
+
* console.log('Connected to', peerUsername)
|
|
468
|
+
* dc.addEventListener('message', (e) => console.log(e.data))
|
|
469
|
+
* dc.addEventListener('open', () => dc.send('Hello!'))
|
|
470
|
+
* }
|
|
471
|
+
* })
|
|
472
|
+
*
|
|
473
|
+
* // Discover random service
|
|
474
|
+
* const connection = await rondevu.connectToService({
|
|
475
|
+
* service: 'chat:2.0.0',
|
|
476
|
+
* onConnection: ({ dc, peerUsername }) => {
|
|
477
|
+
* console.log('Connected to', peerUsername)
|
|
478
|
+
* }
|
|
479
|
+
* })
|
|
480
|
+
* ```
|
|
481
|
+
*/
|
|
482
|
+
async connectToService(options) {
|
|
483
|
+
const { onConnection, rtcConfig } = options;
|
|
484
|
+
// Validate inputs
|
|
485
|
+
if (options.serviceFqn !== undefined && typeof options.serviceFqn === 'string' && !options.serviceFqn.trim()) {
|
|
486
|
+
throw new Error('serviceFqn cannot be empty');
|
|
487
|
+
}
|
|
488
|
+
if (options.service !== undefined && typeof options.service === 'string' && !options.service.trim()) {
|
|
489
|
+
throw new Error('service cannot be empty');
|
|
490
|
+
}
|
|
491
|
+
if (options.username !== undefined && typeof options.username === 'string' && !options.username.trim()) {
|
|
492
|
+
throw new Error('username cannot be empty');
|
|
493
|
+
}
|
|
494
|
+
// Determine the full service FQN
|
|
495
|
+
const fqn = await this.resolveServiceFqn(options);
|
|
496
|
+
this.debug(`Connecting to service: ${fqn}`);
|
|
497
|
+
// 1. Get service offer
|
|
498
|
+
const serviceData = await this.api.getService(fqn);
|
|
499
|
+
this.debug(`Found service from @${serviceData.username}`);
|
|
500
|
+
// 2. Create RTCPeerConnection
|
|
501
|
+
const rtcConfiguration = rtcConfig || {
|
|
502
|
+
iceServers: this.iceServers
|
|
503
|
+
};
|
|
504
|
+
const pc = new RTCPeerConnection(rtcConfiguration);
|
|
505
|
+
// 3. Set up data channel handler (answerer receives it from offerer)
|
|
506
|
+
let dc = null;
|
|
507
|
+
const dataChannelPromise = new Promise((resolve) => {
|
|
508
|
+
pc.ondatachannel = (event) => {
|
|
509
|
+
this.debug('Data channel received from offerer');
|
|
510
|
+
dc = event.channel;
|
|
511
|
+
resolve(dc);
|
|
512
|
+
};
|
|
513
|
+
});
|
|
514
|
+
// 4. Set up ICE candidate exchange
|
|
515
|
+
this.setupIceCandidateHandler(pc, serviceData.serviceFqn, serviceData.offerId);
|
|
516
|
+
// 5. Poll for remote ICE candidates
|
|
517
|
+
const icePollInterval = this.startIcePolling(pc, serviceData.serviceFqn, serviceData.offerId);
|
|
518
|
+
// 6. Set remote description
|
|
519
|
+
await pc.setRemoteDescription({
|
|
520
|
+
type: 'offer',
|
|
521
|
+
sdp: serviceData.sdp
|
|
522
|
+
});
|
|
523
|
+
// 7. Create and send answer
|
|
524
|
+
const answer = await pc.createAnswer();
|
|
525
|
+
await pc.setLocalDescription(answer);
|
|
526
|
+
await this.api.answerOffer(serviceData.serviceFqn, serviceData.offerId, answer.sdp);
|
|
527
|
+
// 8. Wait for data channel to be established
|
|
528
|
+
dc = await dataChannelPromise;
|
|
529
|
+
// Create connection context
|
|
530
|
+
const context = {
|
|
531
|
+
pc,
|
|
532
|
+
dc,
|
|
533
|
+
serviceFqn: serviceData.serviceFqn,
|
|
534
|
+
offerId: serviceData.offerId,
|
|
535
|
+
peerUsername: serviceData.username
|
|
536
|
+
};
|
|
537
|
+
// 9. Set up connection state monitoring
|
|
538
|
+
pc.onconnectionstatechange = () => {
|
|
539
|
+
this.debug(`Connection state: ${pc.connectionState}`);
|
|
540
|
+
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
|
|
541
|
+
clearInterval(icePollInterval);
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
// 10. Wait for data channel to open and call onConnection
|
|
545
|
+
if (dc.readyState === 'open') {
|
|
546
|
+
this.debug('Data channel already open');
|
|
547
|
+
if (onConnection) {
|
|
548
|
+
await onConnection(context);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
await new Promise((resolve) => {
|
|
553
|
+
dc.addEventListener('open', async () => {
|
|
554
|
+
this.debug('Data channel opened');
|
|
555
|
+
if (onConnection) {
|
|
556
|
+
await onConnection(context);
|
|
557
|
+
}
|
|
558
|
+
resolve();
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
return context;
|
|
158
563
|
}
|
|
159
564
|
// ============================================
|
|
160
565
|
// Service Discovery
|
|
@@ -171,14 +576,14 @@ export class Rondevu {
|
|
|
171
576
|
* Example: chat:1.0.0 (without @username)
|
|
172
577
|
*/
|
|
173
578
|
async discoverService(serviceVersion) {
|
|
174
|
-
return await this.api.
|
|
579
|
+
return await this.api.getService(serviceVersion);
|
|
175
580
|
}
|
|
176
581
|
/**
|
|
177
582
|
* Discover multiple available services with pagination
|
|
178
583
|
* Example: chat:1.0.0 (without @username)
|
|
179
584
|
*/
|
|
180
585
|
async discoverServices(serviceVersion, limit = 10, offset = 0) {
|
|
181
|
-
return await this.api.
|
|
586
|
+
return await this.api.getService(serviceVersion, { limit, offset });
|
|
182
587
|
}
|
|
183
588
|
// ============================================
|
|
184
589
|
// WebRTC Signaling
|
|
@@ -187,7 +592,8 @@ export class Rondevu {
|
|
|
187
592
|
* Post answer SDP to specific offer
|
|
188
593
|
*/
|
|
189
594
|
async postOfferAnswer(serviceFqn, offerId, sdp) {
|
|
190
|
-
|
|
595
|
+
await this.api.answerOffer(serviceFqn, offerId, sdp);
|
|
596
|
+
return { success: true, offerId };
|
|
191
597
|
}
|
|
192
598
|
/**
|
|
193
599
|
* Get answer SDP (offerer polls this)
|
|
@@ -195,6 +601,13 @@ export class Rondevu {
|
|
|
195
601
|
async getOfferAnswer(serviceFqn, offerId) {
|
|
196
602
|
return await this.api.getOfferAnswer(serviceFqn, offerId);
|
|
197
603
|
}
|
|
604
|
+
/**
|
|
605
|
+
* Combined polling for answers and ICE candidates
|
|
606
|
+
* Returns all answered offers and ICE candidates for all peer's offers since timestamp
|
|
607
|
+
*/
|
|
608
|
+
async poll(since) {
|
|
609
|
+
return await this.api.poll(since);
|
|
610
|
+
}
|
|
198
611
|
/**
|
|
199
612
|
* Add ICE candidates to specific offer
|
|
200
613
|
*/
|
|
@@ -226,13 +639,16 @@ export class Rondevu {
|
|
|
226
639
|
* Get the public key
|
|
227
640
|
*/
|
|
228
641
|
getPublicKey() {
|
|
229
|
-
return this.keypair
|
|
642
|
+
return this.keypair.publicKey;
|
|
230
643
|
}
|
|
231
644
|
/**
|
|
232
645
|
* Access to underlying API for advanced operations
|
|
233
646
|
* @deprecated Use direct methods on Rondevu instance instead
|
|
234
647
|
*/
|
|
235
|
-
|
|
648
|
+
getAPIPublic() {
|
|
236
649
|
return this.api;
|
|
237
650
|
}
|
|
238
651
|
}
|
|
652
|
+
// Constants
|
|
653
|
+
Rondevu.DEFAULT_TTL_MS = 300000; // 5 minutes
|
|
654
|
+
Rondevu.POLLING_INTERVAL_MS = 1000; // 1 second
|