@xtr-dev/rondevu-client 0.18.1 → 0.18.3
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 +3 -3
- package/dist/api.d.ts +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/node-crypto-adapter.js +0 -2
- package/dist/rondevu.d.ts +122 -48
- package/dist/rondevu.js +186 -40
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -49,8 +49,8 @@ const rondevu = await Rondevu.connect({
|
|
|
49
49
|
await rondevu.publishService({
|
|
50
50
|
service: 'chat:1.0.0',
|
|
51
51
|
maxOffers: 5, // Maintain up to 5 concurrent offers
|
|
52
|
-
offerFactory: async (
|
|
53
|
-
|
|
52
|
+
offerFactory: async (pc) => {
|
|
53
|
+
// pc is created by Rondevu with ICE handlers already attached
|
|
54
54
|
const dc = pc.createDataChannel('chat')
|
|
55
55
|
|
|
56
56
|
dc.addEventListener('open', () => {
|
|
@@ -64,7 +64,7 @@ await rondevu.publishService({
|
|
|
64
64
|
|
|
65
65
|
const offer = await pc.createOffer()
|
|
66
66
|
await pc.setLocalDescription(offer)
|
|
67
|
-
return {
|
|
67
|
+
return { dc, offer }
|
|
68
68
|
}
|
|
69
69
|
})
|
|
70
70
|
|
package/dist/api.d.ts
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* @xtr-dev/rondevu-client
|
|
3
3
|
* WebRTC peer signaling client
|
|
4
4
|
*/
|
|
5
|
-
export { Rondevu } from './rondevu.js';
|
|
5
|
+
export { Rondevu, RondevuError, NetworkError, ValidationError, ConnectionError } from './rondevu.js';
|
|
6
6
|
export { RondevuAPI } from './api.js';
|
|
7
7
|
export { RpcBatcher } from './rpc-batcher.js';
|
|
8
8
|
export { WebCryptoAdapter } from './web-crypto-adapter.js';
|
|
9
9
|
export { NodeCryptoAdapter } from './node-crypto-adapter.js';
|
|
10
10
|
export type { Signaler, Binnable, } from './types.js';
|
|
11
11
|
export type { Keypair, OfferRequest, ServiceRequest, Service, ServiceOffer, IceCandidate, } from './api.js';
|
|
12
|
-
export type { RondevuOptions, PublishServiceOptions, ConnectToServiceOptions, ConnectionContext, OfferContext, OfferFactory } from './rondevu.js';
|
|
12
|
+
export type { RondevuOptions, PublishServiceOptions, ConnectToServiceOptions, ConnectionContext, OfferContext, OfferFactory, ActiveOffer, FindServiceOptions, ServiceResult, PaginatedServiceResult } from './rondevu.js';
|
|
13
13
|
export type { CryptoAdapter } from './crypto-adapter.js';
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @xtr-dev/rondevu-client
|
|
3
3
|
* WebRTC peer signaling client
|
|
4
4
|
*/
|
|
5
|
-
export { Rondevu } from './rondevu.js';
|
|
5
|
+
export { Rondevu, RondevuError, NetworkError, ValidationError, ConnectionError } from './rondevu.js';
|
|
6
6
|
export { RondevuAPI } from './api.js';
|
|
7
7
|
export { RpcBatcher } from './rpc-batcher.js';
|
|
8
8
|
// Export crypto adapters
|
|
@@ -65,12 +65,10 @@ export class NodeCryptoAdapter {
|
|
|
65
65
|
}
|
|
66
66
|
bytesToBase64(bytes) {
|
|
67
67
|
// Node.js Buffer provides native base64 encoding
|
|
68
|
-
// @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
|
|
69
68
|
return Buffer.from(bytes).toString('base64');
|
|
70
69
|
}
|
|
71
70
|
base64ToBytes(base64) {
|
|
72
71
|
// Node.js Buffer provides native base64 decoding
|
|
73
|
-
// @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
|
|
74
72
|
return new Uint8Array(Buffer.from(base64, 'base64'));
|
|
75
73
|
}
|
|
76
74
|
randomBytes(length) {
|
package/dist/rondevu.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { RondevuAPI, Keypair, IceCandidate, BatcherOptions } from './api.js';
|
|
2
2
|
import { CryptoAdapter } from './crypto-adapter.js';
|
|
3
|
+
import { EventEmitter } from 'events';
|
|
3
4
|
export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only';
|
|
4
5
|
export declare const ICE_SERVER_PRESETS: Record<IceServerPreset, RTCIceServer[]>;
|
|
5
6
|
export interface RondevuOptions {
|
|
@@ -14,11 +15,18 @@ export interface RondevuOptions {
|
|
|
14
15
|
rtcIceCandidate?: typeof RTCIceCandidate;
|
|
15
16
|
}
|
|
16
17
|
export interface OfferContext {
|
|
17
|
-
pc: RTCPeerConnection;
|
|
18
18
|
dc?: RTCDataChannel;
|
|
19
19
|
offer: RTCSessionDescriptionInit;
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Factory function for creating WebRTC offers.
|
|
23
|
+
* Rondevu creates the RTCPeerConnection and passes it to the factory,
|
|
24
|
+
* allowing ICE candidate handlers to be set up before setLocalDescription() is called.
|
|
25
|
+
*
|
|
26
|
+
* @param pc - The RTCPeerConnection created by Rondevu (already configured with ICE servers)
|
|
27
|
+
* @returns Promise containing the data channel (optional) and offer SDP
|
|
28
|
+
*/
|
|
29
|
+
export type OfferFactory = (pc: RTCPeerConnection) => Promise<OfferContext>;
|
|
22
30
|
export interface PublishServiceOptions {
|
|
23
31
|
service: string;
|
|
24
32
|
maxOffers: number;
|
|
@@ -39,6 +47,59 @@ export interface ConnectToServiceOptions {
|
|
|
39
47
|
onConnection?: (context: ConnectionContext) => void | Promise<void>;
|
|
40
48
|
rtcConfig?: RTCConfiguration;
|
|
41
49
|
}
|
|
50
|
+
export interface ActiveOffer {
|
|
51
|
+
offerId: string;
|
|
52
|
+
serviceFqn: string;
|
|
53
|
+
pc: RTCPeerConnection;
|
|
54
|
+
dc?: RTCDataChannel;
|
|
55
|
+
answered: boolean;
|
|
56
|
+
createdAt: number;
|
|
57
|
+
}
|
|
58
|
+
export interface FindServiceOptions {
|
|
59
|
+
mode?: 'direct' | 'random' | 'paginated';
|
|
60
|
+
limit?: number;
|
|
61
|
+
offset?: number;
|
|
62
|
+
}
|
|
63
|
+
export interface ServiceResult {
|
|
64
|
+
serviceId: string;
|
|
65
|
+
username: string;
|
|
66
|
+
serviceFqn: string;
|
|
67
|
+
offerId: string;
|
|
68
|
+
sdp: string;
|
|
69
|
+
createdAt: number;
|
|
70
|
+
expiresAt: number;
|
|
71
|
+
}
|
|
72
|
+
export interface PaginatedServiceResult {
|
|
73
|
+
services: ServiceResult[];
|
|
74
|
+
count: number;
|
|
75
|
+
limit: number;
|
|
76
|
+
offset: number;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Base error class for Rondevu errors
|
|
80
|
+
*/
|
|
81
|
+
export declare class RondevuError extends Error {
|
|
82
|
+
context?: Record<string, any> | undefined;
|
|
83
|
+
constructor(message: string, context?: Record<string, any> | undefined);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Network-related errors (API calls, connectivity)
|
|
87
|
+
*/
|
|
88
|
+
export declare class NetworkError extends RondevuError {
|
|
89
|
+
constructor(message: string, context?: Record<string, any>);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Validation errors (invalid input, malformed data)
|
|
93
|
+
*/
|
|
94
|
+
export declare class ValidationError extends RondevuError {
|
|
95
|
+
constructor(message: string, context?: Record<string, any>);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* WebRTC connection errors (peer connection failures, ICE issues)
|
|
99
|
+
*/
|
|
100
|
+
export declare class ConnectionError extends RondevuError {
|
|
101
|
+
constructor(message: string, context?: Record<string, any>);
|
|
102
|
+
}
|
|
42
103
|
/**
|
|
43
104
|
* Rondevu - Complete WebRTC signaling client
|
|
44
105
|
*
|
|
@@ -72,12 +133,12 @@ export interface ConnectToServiceOptions {
|
|
|
72
133
|
* await rondevu.publishService({
|
|
73
134
|
* service: 'chat:2.0.0',
|
|
74
135
|
* maxOffers: 5, // Maintain up to 5 concurrent offers
|
|
75
|
-
* offerFactory: async (
|
|
76
|
-
*
|
|
136
|
+
* offerFactory: async (pc) => {
|
|
137
|
+
* // pc is created by Rondevu with ICE handlers already attached
|
|
77
138
|
* const dc = pc.createDataChannel('chat')
|
|
78
139
|
* const offer = await pc.createOffer()
|
|
79
140
|
* await pc.setLocalDescription(offer)
|
|
80
|
-
* return {
|
|
141
|
+
* return { dc, offer }
|
|
81
142
|
* }
|
|
82
143
|
* })
|
|
83
144
|
*
|
|
@@ -93,7 +154,7 @@ export interface ConnectToServiceOptions {
|
|
|
93
154
|
* rondevu.stopFilling()
|
|
94
155
|
* ```
|
|
95
156
|
*/
|
|
96
|
-
export declare class Rondevu {
|
|
157
|
+
export declare class Rondevu extends EventEmitter {
|
|
97
158
|
private static readonly DEFAULT_TTL_MS;
|
|
98
159
|
private static readonly POLLING_INTERVAL_MS;
|
|
99
160
|
private api;
|
|
@@ -115,6 +176,7 @@ export declare class Rondevu {
|
|
|
115
176
|
private filling;
|
|
116
177
|
private pollingInterval;
|
|
117
178
|
private lastPollTimestamp;
|
|
179
|
+
private isPolling;
|
|
118
180
|
private constructor();
|
|
119
181
|
/**
|
|
120
182
|
* Internal debug logging - only logs if debug mode is enabled
|
|
@@ -142,6 +204,7 @@ export declare class Rondevu {
|
|
|
142
204
|
isUsernameClaimed(): Promise<boolean>;
|
|
143
205
|
/**
|
|
144
206
|
* Default offer factory - creates a simple data channel connection
|
|
207
|
+
* The RTCPeerConnection is created by Rondevu and passed in
|
|
145
208
|
*/
|
|
146
209
|
private defaultOfferFactory;
|
|
147
210
|
/**
|
|
@@ -160,6 +223,10 @@ export declare class Rondevu {
|
|
|
160
223
|
publishService(options: PublishServiceOptions): Promise<void>;
|
|
161
224
|
/**
|
|
162
225
|
* Set up ICE candidate handler to send candidates to the server
|
|
226
|
+
*
|
|
227
|
+
* Note: This is used by connectToService() where the offerId is already known.
|
|
228
|
+
* For createOffer(), we use inline ICE handling with early candidate queuing
|
|
229
|
+
* since the offerId isn't available until after the factory completes.
|
|
163
230
|
*/
|
|
164
231
|
private setupIceCandidateHandler;
|
|
165
232
|
/**
|
|
@@ -184,6 +251,32 @@ export declare class Rondevu {
|
|
|
184
251
|
* Closes all active peer connections
|
|
185
252
|
*/
|
|
186
253
|
stopFilling(): void;
|
|
254
|
+
/**
|
|
255
|
+
* Get the count of active offers
|
|
256
|
+
* @returns Number of active offers
|
|
257
|
+
*/
|
|
258
|
+
getOfferCount(): number;
|
|
259
|
+
/**
|
|
260
|
+
* Check if an offer is currently connected
|
|
261
|
+
* @param offerId - The offer ID to check
|
|
262
|
+
* @returns True if the offer exists and has been answered
|
|
263
|
+
*/
|
|
264
|
+
isConnected(offerId: string): boolean;
|
|
265
|
+
/**
|
|
266
|
+
* Disconnect all active offers
|
|
267
|
+
* Similar to stopFilling() but doesn't stop the polling/filling process
|
|
268
|
+
*/
|
|
269
|
+
disconnectAll(): Promise<void>;
|
|
270
|
+
/**
|
|
271
|
+
* Get the current service status
|
|
272
|
+
* @returns Object with service state information
|
|
273
|
+
*/
|
|
274
|
+
getServiceStatus(): {
|
|
275
|
+
active: boolean;
|
|
276
|
+
offerCount: number;
|
|
277
|
+
maxOffers: number;
|
|
278
|
+
filling: boolean;
|
|
279
|
+
};
|
|
187
280
|
/**
|
|
188
281
|
* Resolve the full service FQN from various input options
|
|
189
282
|
* Supports direct FQN, service+username, or service discovery
|
|
@@ -221,49 +314,30 @@ export declare class Rondevu {
|
|
|
221
314
|
*/
|
|
222
315
|
connectToService(options: ConnectToServiceOptions): Promise<ConnectionContext>;
|
|
223
316
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
createdAt: number;
|
|
247
|
-
expiresAt: number;
|
|
248
|
-
}>;
|
|
249
|
-
/**
|
|
250
|
-
* Discover multiple available services with pagination
|
|
251
|
-
* Example: chat:1.0.0 (without @username)
|
|
317
|
+
* Find a service - unified discovery method
|
|
318
|
+
*
|
|
319
|
+
* Replaces getService(), discoverService(), and discoverServices() with a single method.
|
|
320
|
+
*
|
|
321
|
+
* @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
|
|
322
|
+
* @param options - Discovery options
|
|
323
|
+
*
|
|
324
|
+
* @example
|
|
325
|
+
* ```typescript
|
|
326
|
+
* // Direct lookup (has username)
|
|
327
|
+
* const service = await rondevu.findService('chat:1.0.0@alice')
|
|
328
|
+
*
|
|
329
|
+
* // Random discovery (no username)
|
|
330
|
+
* const service = await rondevu.findService('chat:1.0.0')
|
|
331
|
+
*
|
|
332
|
+
* // Paginated discovery
|
|
333
|
+
* const result = await rondevu.findService('chat:1.0.0', {
|
|
334
|
+
* mode: 'paginated',
|
|
335
|
+
* limit: 20,
|
|
336
|
+
* offset: 0
|
|
337
|
+
* })
|
|
338
|
+
* ```
|
|
252
339
|
*/
|
|
253
|
-
|
|
254
|
-
services: Array<{
|
|
255
|
-
serviceId: string;
|
|
256
|
-
username: string;
|
|
257
|
-
serviceFqn: string;
|
|
258
|
-
offerId: string;
|
|
259
|
-
sdp: string;
|
|
260
|
-
createdAt: number;
|
|
261
|
-
expiresAt: number;
|
|
262
|
-
}>;
|
|
263
|
-
count: number;
|
|
264
|
-
limit: number;
|
|
265
|
-
offset: number;
|
|
266
|
-
}>;
|
|
340
|
+
findService(serviceFqn: string, options?: FindServiceOptions): Promise<ServiceResult | PaginatedServiceResult>;
|
|
267
341
|
/**
|
|
268
342
|
* Post answer SDP to specific offer
|
|
269
343
|
*/
|
package/dist/rondevu.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RondevuAPI } from './api.js';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
2
3
|
// ICE server presets
|
|
3
4
|
export const ICE_SERVER_PRESETS = {
|
|
4
5
|
'ipv4-turn': [
|
|
@@ -43,6 +44,47 @@ export const ICE_SERVER_PRESETS = {
|
|
|
43
44
|
}
|
|
44
45
|
]
|
|
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
|
+
}
|
|
46
88
|
/**
|
|
47
89
|
* Rondevu - Complete WebRTC signaling client
|
|
48
90
|
*
|
|
@@ -76,12 +118,12 @@ export const ICE_SERVER_PRESETS = {
|
|
|
76
118
|
* await rondevu.publishService({
|
|
77
119
|
* service: 'chat:2.0.0',
|
|
78
120
|
* maxOffers: 5, // Maintain up to 5 concurrent offers
|
|
79
|
-
* offerFactory: async (
|
|
80
|
-
*
|
|
121
|
+
* offerFactory: async (pc) => {
|
|
122
|
+
* // pc is created by Rondevu with ICE handlers already attached
|
|
81
123
|
* const dc = pc.createDataChannel('chat')
|
|
82
124
|
* const offer = await pc.createOffer()
|
|
83
125
|
* await pc.setLocalDescription(offer)
|
|
84
|
-
* return {
|
|
126
|
+
* return { dc, offer }
|
|
85
127
|
* }
|
|
86
128
|
* })
|
|
87
129
|
*
|
|
@@ -97,8 +139,9 @@ export const ICE_SERVER_PRESETS = {
|
|
|
97
139
|
* rondevu.stopFilling()
|
|
98
140
|
* ```
|
|
99
141
|
*/
|
|
100
|
-
export class Rondevu {
|
|
142
|
+
export class Rondevu extends EventEmitter {
|
|
101
143
|
constructor(apiUrl, username, keypair, api, iceServers, cryptoAdapter, batchingOptions, debugEnabled = false, rtcPeerConnection, rtcIceCandidate) {
|
|
144
|
+
super();
|
|
102
145
|
this.usernameClaimed = false;
|
|
103
146
|
// Service management
|
|
104
147
|
this.currentService = null;
|
|
@@ -110,6 +153,7 @@ export class Rondevu {
|
|
|
110
153
|
this.filling = false;
|
|
111
154
|
this.pollingInterval = null;
|
|
112
155
|
this.lastPollTimestamp = 0;
|
|
156
|
+
this.isPolling = false; // Guard against concurrent poll execution
|
|
113
157
|
this.apiUrl = apiUrl;
|
|
114
158
|
this.username = username;
|
|
115
159
|
this.keypair = keypair;
|
|
@@ -224,13 +268,13 @@ export class Rondevu {
|
|
|
224
268
|
// ============================================
|
|
225
269
|
/**
|
|
226
270
|
* Default offer factory - creates a simple data channel connection
|
|
271
|
+
* The RTCPeerConnection is created by Rondevu and passed in
|
|
227
272
|
*/
|
|
228
|
-
async defaultOfferFactory(
|
|
229
|
-
const pc = new RTCPeerConnection(rtcConfig);
|
|
273
|
+
async defaultOfferFactory(pc) {
|
|
230
274
|
const dc = pc.createDataChannel('default');
|
|
231
275
|
const offer = await pc.createOffer();
|
|
232
276
|
await pc.setLocalDescription(offer);
|
|
233
|
-
return {
|
|
277
|
+
return { dc, offer };
|
|
234
278
|
}
|
|
235
279
|
/**
|
|
236
280
|
* Publish a service with automatic offer management
|
|
@@ -256,6 +300,10 @@ export class Rondevu {
|
|
|
256
300
|
}
|
|
257
301
|
/**
|
|
258
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.
|
|
259
307
|
*/
|
|
260
308
|
setupIceCandidateHandler(pc, serviceFqn, offerId) {
|
|
261
309
|
pc.onicecandidate = async (event) => {
|
|
@@ -267,6 +315,8 @@ export class Rondevu {
|
|
|
267
315
|
const candidateData = typeof event.candidate.toJSON === 'function'
|
|
268
316
|
? event.candidate.toJSON()
|
|
269
317
|
: event.candidate;
|
|
318
|
+
// Emit local ICE candidate event
|
|
319
|
+
this.emit('ice:candidate:local', offerId, candidateData);
|
|
270
320
|
await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData]);
|
|
271
321
|
}
|
|
272
322
|
catch (err) {
|
|
@@ -285,25 +335,26 @@ export class Rondevu {
|
|
|
285
335
|
const rtcConfig = {
|
|
286
336
|
iceServers: this.iceServers
|
|
287
337
|
};
|
|
288
|
-
this.debug('Creating new offer...');
|
|
289
|
-
// Create the offer using the factory
|
|
290
|
-
// Note: The factory may call setLocalDescription() which triggers ICE gathering
|
|
291
|
-
const { pc, dc, offer } = await this.offerFactory(rtcConfig);
|
|
292
338
|
// Auto-append username to service
|
|
293
339
|
const serviceFqn = `${this.currentService}@${this.username}`;
|
|
294
|
-
|
|
295
|
-
//
|
|
296
|
-
|
|
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
|
|
297
346
|
const earlyIceCandidates = [];
|
|
298
|
-
let offerId
|
|
299
|
-
// Set up a queuing ICE candidate handler immediately after getting the pc
|
|
300
|
-
// This captures any candidates that fire before we have the offerId
|
|
347
|
+
let offerId;
|
|
301
348
|
pc.onicecandidate = async (event) => {
|
|
302
349
|
if (event.candidate) {
|
|
303
350
|
// Handle both browser and Node.js (wrtc) environments
|
|
304
351
|
const candidateData = typeof event.candidate.toJSON === 'function'
|
|
305
352
|
? event.candidate.toJSON()
|
|
306
353
|
: event.candidate;
|
|
354
|
+
// Emit local ICE candidate event
|
|
355
|
+
if (offerId) {
|
|
356
|
+
this.emit('ice:candidate:local', offerId, candidateData);
|
|
357
|
+
}
|
|
307
358
|
if (offerId) {
|
|
308
359
|
// We have the offerId, send directly
|
|
309
360
|
try {
|
|
@@ -320,7 +371,22 @@ export class Rondevu {
|
|
|
320
371
|
}
|
|
321
372
|
}
|
|
322
373
|
};
|
|
323
|
-
//
|
|
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
|
|
324
390
|
const result = await this.api.publishService({
|
|
325
391
|
serviceFqn,
|
|
326
392
|
offers: [{ sdp: offer.sdp }],
|
|
@@ -329,7 +395,7 @@ export class Rondevu {
|
|
|
329
395
|
message: '',
|
|
330
396
|
});
|
|
331
397
|
offerId = result.offers[0].offerId;
|
|
332
|
-
// Store active offer
|
|
398
|
+
// 5. Store active offer
|
|
333
399
|
this.activeOffers.set(offerId, {
|
|
334
400
|
offerId,
|
|
335
401
|
serviceFqn,
|
|
@@ -339,7 +405,15 @@ export class Rondevu {
|
|
|
339
405
|
createdAt: Date.now()
|
|
340
406
|
});
|
|
341
407
|
this.debug(`Offer created: ${offerId}`);
|
|
342
|
-
|
|
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
|
|
343
417
|
if (earlyIceCandidates.length > 0) {
|
|
344
418
|
this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`);
|
|
345
419
|
try {
|
|
@@ -349,10 +423,11 @@ export class Rondevu {
|
|
|
349
423
|
console.error('[Rondevu] Failed to send early ICE candidates:', err);
|
|
350
424
|
}
|
|
351
425
|
}
|
|
352
|
-
// Monitor connection state
|
|
426
|
+
// 7. Monitor connection state
|
|
353
427
|
pc.onconnectionstatechange = () => {
|
|
354
428
|
this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`);
|
|
355
429
|
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
|
|
430
|
+
this.emit('connection:closed', offerId);
|
|
356
431
|
this.activeOffers.delete(offerId);
|
|
357
432
|
this.fillOffers(); // Try to replace failed offer
|
|
358
433
|
}
|
|
@@ -382,6 +457,12 @@ export class Rondevu {
|
|
|
382
457
|
async pollInternal() {
|
|
383
458
|
if (!this.filling)
|
|
384
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;
|
|
385
466
|
try {
|
|
386
467
|
const result = await this.api.poll(this.lastPollTimestamp);
|
|
387
468
|
// Process answers
|
|
@@ -395,6 +476,7 @@ export class Rondevu {
|
|
|
395
476
|
});
|
|
396
477
|
activeOffer.answered = true;
|
|
397
478
|
this.lastPollTimestamp = answer.answeredAt;
|
|
479
|
+
this.emit('offer:answered', answer.offerId, answer.answererId);
|
|
398
480
|
// Create replacement offer
|
|
399
481
|
this.fillOffers();
|
|
400
482
|
}
|
|
@@ -406,6 +488,7 @@ export class Rondevu {
|
|
|
406
488
|
const answererCandidates = candidates.filter(c => c.role === 'answerer');
|
|
407
489
|
for (const item of answererCandidates) {
|
|
408
490
|
if (item.candidate) {
|
|
491
|
+
this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
|
|
409
492
|
await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate));
|
|
410
493
|
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt);
|
|
411
494
|
}
|
|
@@ -416,6 +499,9 @@ export class Rondevu {
|
|
|
416
499
|
catch (err) {
|
|
417
500
|
console.error('[Rondevu] Polling error:', err);
|
|
418
501
|
}
|
|
502
|
+
finally {
|
|
503
|
+
this.isPolling = false;
|
|
504
|
+
}
|
|
419
505
|
}
|
|
420
506
|
/**
|
|
421
507
|
* Start filling offers and polling for answers/ICE
|
|
@@ -445,6 +531,7 @@ export class Rondevu {
|
|
|
445
531
|
stopFilling() {
|
|
446
532
|
this.debug('Stopping offer filling and polling');
|
|
447
533
|
this.filling = false;
|
|
534
|
+
this.isPolling = false; // Reset polling guard
|
|
448
535
|
// Stop polling
|
|
449
536
|
if (this.pollingInterval) {
|
|
450
537
|
clearInterval(this.pollingInterval);
|
|
@@ -458,6 +545,47 @@ export class Rondevu {
|
|
|
458
545
|
}
|
|
459
546
|
this.activeOffers.clear();
|
|
460
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
|
+
}
|
|
461
589
|
/**
|
|
462
590
|
* Resolve the full service FQN from various input options
|
|
463
591
|
* Supports direct FQN, service+username, or service discovery
|
|
@@ -473,7 +601,7 @@ export class Rondevu {
|
|
|
473
601
|
else if (service) {
|
|
474
602
|
// Discovery mode - get random service
|
|
475
603
|
this.debug(`Discovering service: ${service}`);
|
|
476
|
-
const discovered = await this.
|
|
604
|
+
const discovered = await this.findService(service);
|
|
477
605
|
return discovered.serviceFqn;
|
|
478
606
|
}
|
|
479
607
|
else {
|
|
@@ -491,6 +619,7 @@ export class Rondevu {
|
|
|
491
619
|
const result = await this.api.getOfferIceCandidates(serviceFqn, offerId, lastIceTimestamp);
|
|
492
620
|
for (const item of result.candidates) {
|
|
493
621
|
if (item.candidate) {
|
|
622
|
+
this.emit('ice:candidate:remote', offerId, item.candidate, item.role);
|
|
494
623
|
await pc.addIceCandidate(new RTCIceCandidate(item.candidate));
|
|
495
624
|
lastIceTimestamp = item.createdAt;
|
|
496
625
|
}
|
|
@@ -555,6 +684,7 @@ export class Rondevu {
|
|
|
555
684
|
pc.ondatachannel = (event) => {
|
|
556
685
|
this.debug('Data channel received from offerer');
|
|
557
686
|
dc = event.channel;
|
|
687
|
+
this.emit('connection:opened', serviceData.offerId, dc);
|
|
558
688
|
resolve(dc);
|
|
559
689
|
};
|
|
560
690
|
});
|
|
@@ -612,25 +742,41 @@ export class Rondevu {
|
|
|
612
742
|
// Service Discovery
|
|
613
743
|
// ============================================
|
|
614
744
|
/**
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
*
|
|
630
|
-
*
|
|
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
|
+
* ```
|
|
631
767
|
*/
|
|
632
|
-
async
|
|
633
|
-
|
|
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
|
+
}
|
|
634
780
|
}
|
|
635
781
|
// ============================================
|
|
636
782
|
// WebRTC Signaling
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xtr-dev/rondevu-client",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.3",
|
|
4
4
|
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@eslint/js": "^9.39.1",
|
|
28
|
+
"@types/node": "^25.0.2",
|
|
28
29
|
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
|
29
30
|
"@typescript-eslint/parser": "^8.48.1",
|
|
30
31
|
"eslint": "^9.39.1",
|