@xtr-dev/rondevu-client 0.10.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/dist/api.d.ts +27 -10
- package/dist/api.js +27 -17
- package/dist/rondevu-service.d.ts +4 -2
- package/dist/rondevu-service.js +3 -3
- package/dist/rondevu-signaler.js +43 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,10 +17,12 @@ TypeScript/JavaScript client for Rondevu, providing easy-to-use WebRTC connectio
|
|
|
17
17
|
|
|
18
18
|
- **High-Level Wrappers**: ServiceHost and ServiceClient eliminate WebRTC boilerplate
|
|
19
19
|
- **Username-Based Discovery**: Connect to peers by username, not complex offer/answer exchange
|
|
20
|
+
- **Semver-Compatible Matching**: Requesting chat@1.0.0 matches any compatible 1.x.x version
|
|
21
|
+
- **Privacy-First Design**: Services are hidden by default - no enumeration possible
|
|
20
22
|
- **Automatic Reconnection**: Built-in retry logic with exponential backoff
|
|
21
23
|
- **Message Queuing**: Messages sent while disconnected are queued and flushed on reconnect
|
|
22
24
|
- **Cryptographic Username Claiming**: Secure ownership with Ed25519 signatures
|
|
23
|
-
- **Service Publishing**: Package-style naming (chat.app@1.0.0)
|
|
25
|
+
- **Service Publishing**: Package-style naming (chat.app@1.0.0) with multiple simultaneous offers
|
|
24
26
|
- **TypeScript**: Full type safety and autocomplete
|
|
25
27
|
- **Configurable Polling**: Exponential backoff with jitter to reduce server load
|
|
26
28
|
|
|
@@ -373,7 +375,7 @@ await conn.queueMessage('This will be sent when connected', {
|
|
|
373
375
|
|
|
374
376
|
## Migration from v0.9.x
|
|
375
377
|
|
|
376
|
-
v0.11.0 introduces high-level wrappers
|
|
378
|
+
v0.11.0+ introduces high-level wrappers, RESTful API changes, and semver-compatible discovery:
|
|
377
379
|
|
|
378
380
|
**API Changes:**
|
|
379
381
|
- Server endpoints restructured (`/usernames/*` → `/users/*`)
|
|
@@ -381,6 +383,10 @@ v0.11.0 introduces high-level wrappers and RESTful API changes:
|
|
|
381
383
|
- Message queue fully implemented
|
|
382
384
|
- Configurable polling with exponential backoff
|
|
383
385
|
- Removed deprecated `cleanup()` methods (use `dispose()`)
|
|
386
|
+
- **v0.11.0+**: Services use `offers` array instead of single `sdp`
|
|
387
|
+
- **v0.11.0+**: Semver-compatible service discovery (chat@1.0.0 matches 1.x.x)
|
|
388
|
+
- **v0.11.0+**: All services are hidden - no listing endpoint
|
|
389
|
+
- **v0.11.0+**: Services support multiple simultaneous offers for connection pooling
|
|
384
390
|
|
|
385
391
|
**Migration Guide:**
|
|
386
392
|
|
package/dist/api.d.ts
CHANGED
|
@@ -25,20 +25,29 @@ export interface Offer {
|
|
|
25
25
|
expiresAt: number;
|
|
26
26
|
answererPeerId?: string;
|
|
27
27
|
}
|
|
28
|
+
export interface OfferRequest {
|
|
29
|
+
sdp: string;
|
|
30
|
+
}
|
|
28
31
|
export interface ServiceRequest {
|
|
29
32
|
username: string;
|
|
30
33
|
serviceFqn: string;
|
|
31
|
-
|
|
34
|
+
offers: OfferRequest[];
|
|
32
35
|
ttl?: number;
|
|
33
36
|
isPublic?: boolean;
|
|
34
37
|
metadata?: Record<string, any>;
|
|
35
38
|
signature: string;
|
|
36
39
|
message: string;
|
|
37
40
|
}
|
|
41
|
+
export interface ServiceOffer {
|
|
42
|
+
offerId: string;
|
|
43
|
+
sdp: string;
|
|
44
|
+
createdAt: number;
|
|
45
|
+
expiresAt: number;
|
|
46
|
+
}
|
|
38
47
|
export interface Service {
|
|
39
48
|
serviceId: string;
|
|
40
49
|
uuid: string;
|
|
41
|
-
|
|
50
|
+
offers: ServiceOffer[];
|
|
42
51
|
username: string;
|
|
43
52
|
serviceFqn: string;
|
|
44
53
|
isPublic: boolean;
|
|
@@ -90,27 +99,35 @@ export declare class RondevuAPI {
|
|
|
90
99
|
*/
|
|
91
100
|
getOffer(offerId: string): Promise<Offer>;
|
|
92
101
|
/**
|
|
93
|
-
* Answer
|
|
102
|
+
* Answer a service
|
|
94
103
|
*/
|
|
95
|
-
|
|
104
|
+
answerService(serviceUuid: string, sdp: string): Promise<{
|
|
105
|
+
offerId: string;
|
|
106
|
+
}>;
|
|
96
107
|
/**
|
|
97
|
-
* Get answer for
|
|
108
|
+
* Get answer for a service (offerer polls this)
|
|
98
109
|
*/
|
|
99
|
-
|
|
110
|
+
getServiceAnswer(serviceUuid: string): Promise<{
|
|
100
111
|
sdp: string;
|
|
112
|
+
offerId: string;
|
|
101
113
|
} | null>;
|
|
102
114
|
/**
|
|
103
115
|
* Search offers by topic
|
|
104
116
|
*/
|
|
105
117
|
searchOffers(topic: string): Promise<Offer[]>;
|
|
106
118
|
/**
|
|
107
|
-
* Add ICE candidates to
|
|
119
|
+
* Add ICE candidates to a service
|
|
108
120
|
*/
|
|
109
|
-
|
|
121
|
+
addServiceIceCandidates(serviceUuid: string, candidates: RTCIceCandidateInit[], offerId?: string): Promise<{
|
|
122
|
+
offerId: string;
|
|
123
|
+
}>;
|
|
110
124
|
/**
|
|
111
|
-
* Get ICE candidates for
|
|
125
|
+
* Get ICE candidates for a service (with polling support)
|
|
112
126
|
*/
|
|
113
|
-
|
|
127
|
+
getServiceIceCandidates(serviceUuid: string, since?: number, offerId?: string): Promise<{
|
|
128
|
+
candidates: IceCandidate[];
|
|
129
|
+
offerId: string;
|
|
130
|
+
}>;
|
|
114
131
|
/**
|
|
115
132
|
* Publish a service
|
|
116
133
|
*/
|
package/dist/api.js
CHANGED
|
@@ -131,27 +131,28 @@ export class RondevuAPI {
|
|
|
131
131
|
return await response.json();
|
|
132
132
|
}
|
|
133
133
|
/**
|
|
134
|
-
* Answer
|
|
134
|
+
* Answer a service
|
|
135
135
|
*/
|
|
136
|
-
async
|
|
137
|
-
const response = await fetch(`${this.baseUrl}/
|
|
136
|
+
async answerService(serviceUuid, sdp) {
|
|
137
|
+
const response = await fetch(`${this.baseUrl}/services/${serviceUuid}/answer`, {
|
|
138
138
|
method: 'POST',
|
|
139
139
|
headers: {
|
|
140
140
|
'Content-Type': 'application/json',
|
|
141
141
|
...this.getAuthHeader(),
|
|
142
142
|
},
|
|
143
|
-
body: JSON.stringify({ sdp
|
|
143
|
+
body: JSON.stringify({ sdp }),
|
|
144
144
|
});
|
|
145
145
|
if (!response.ok) {
|
|
146
146
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
|
147
|
-
throw new Error(`Failed to answer
|
|
147
|
+
throw new Error(`Failed to answer service: ${error.error || response.statusText}`);
|
|
148
148
|
}
|
|
149
|
+
return await response.json();
|
|
149
150
|
}
|
|
150
151
|
/**
|
|
151
|
-
* Get answer for
|
|
152
|
+
* Get answer for a service (offerer polls this)
|
|
152
153
|
*/
|
|
153
|
-
async
|
|
154
|
-
const response = await fetch(`${this.baseUrl}/
|
|
154
|
+
async getServiceAnswer(serviceUuid) {
|
|
155
|
+
const response = await fetch(`${this.baseUrl}/services/${serviceUuid}/answer`, {
|
|
155
156
|
headers: this.getAuthHeader(),
|
|
156
157
|
});
|
|
157
158
|
if (!response.ok) {
|
|
@@ -163,7 +164,7 @@ export class RondevuAPI {
|
|
|
163
164
|
throw new Error(`Failed to get answer: ${error.error || response.statusText}`);
|
|
164
165
|
}
|
|
165
166
|
const data = await response.json();
|
|
166
|
-
return { sdp: data.sdp };
|
|
167
|
+
return { sdp: data.sdp, offerId: data.offerId };
|
|
167
168
|
}
|
|
168
169
|
/**
|
|
169
170
|
* Search offers by topic
|
|
@@ -182,33 +183,42 @@ export class RondevuAPI {
|
|
|
182
183
|
// ICE Candidates
|
|
183
184
|
// ============================================
|
|
184
185
|
/**
|
|
185
|
-
* Add ICE candidates to
|
|
186
|
+
* Add ICE candidates to a service
|
|
186
187
|
*/
|
|
187
|
-
async
|
|
188
|
-
const response = await fetch(`${this.baseUrl}/
|
|
188
|
+
async addServiceIceCandidates(serviceUuid, candidates, offerId) {
|
|
189
|
+
const response = await fetch(`${this.baseUrl}/services/${serviceUuid}/ice-candidates`, {
|
|
189
190
|
method: 'POST',
|
|
190
191
|
headers: {
|
|
191
192
|
'Content-Type': 'application/json',
|
|
192
193
|
...this.getAuthHeader(),
|
|
193
194
|
},
|
|
194
|
-
body: JSON.stringify({ candidates }),
|
|
195
|
+
body: JSON.stringify({ candidates, offerId }),
|
|
195
196
|
});
|
|
196
197
|
if (!response.ok) {
|
|
197
198
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
|
198
199
|
throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`);
|
|
199
200
|
}
|
|
201
|
+
return await response.json();
|
|
200
202
|
}
|
|
201
203
|
/**
|
|
202
|
-
* Get ICE candidates for
|
|
204
|
+
* Get ICE candidates for a service (with polling support)
|
|
203
205
|
*/
|
|
204
|
-
async
|
|
205
|
-
const
|
|
206
|
+
async getServiceIceCandidates(serviceUuid, since = 0, offerId) {
|
|
207
|
+
const url = new URL(`${this.baseUrl}/services/${serviceUuid}/ice-candidates`);
|
|
208
|
+
url.searchParams.set('since', since.toString());
|
|
209
|
+
if (offerId) {
|
|
210
|
+
url.searchParams.set('offerId', offerId);
|
|
211
|
+
}
|
|
212
|
+
const response = await fetch(url.toString(), { headers: this.getAuthHeader() });
|
|
206
213
|
if (!response.ok) {
|
|
207
214
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
|
208
215
|
throw new Error(`Failed to get ICE candidates: ${error.error || response.statusText}`);
|
|
209
216
|
}
|
|
210
217
|
const data = await response.json();
|
|
211
|
-
return
|
|
218
|
+
return {
|
|
219
|
+
candidates: data.candidates || [],
|
|
220
|
+
offerId: data.offerId
|
|
221
|
+
};
|
|
212
222
|
}
|
|
213
223
|
// ============================================
|
|
214
224
|
// Services
|
|
@@ -7,7 +7,9 @@ export interface RondevuServiceOptions {
|
|
|
7
7
|
}
|
|
8
8
|
export interface PublishServiceOptions {
|
|
9
9
|
serviceFqn: string;
|
|
10
|
-
|
|
10
|
+
offers: Array<{
|
|
11
|
+
sdp: string;
|
|
12
|
+
}>;
|
|
11
13
|
ttl?: number;
|
|
12
14
|
isPublic?: boolean;
|
|
13
15
|
metadata?: Record<string, any>;
|
|
@@ -36,7 +38,7 @@ export interface PublishServiceOptions {
|
|
|
36
38
|
* // Publish a service
|
|
37
39
|
* const publishedService = await service.publishService({
|
|
38
40
|
* serviceFqn: 'chat.app@1.0.0',
|
|
39
|
-
* sdp: offerSdp,
|
|
41
|
+
* offers: [{ sdp: offerSdp }],
|
|
40
42
|
* ttl: 300000,
|
|
41
43
|
* isPublic: true,
|
|
42
44
|
* })
|
package/dist/rondevu-service.js
CHANGED
|
@@ -23,7 +23,7 @@ import { RondevuAPI } from './api.js';
|
|
|
23
23
|
* // Publish a service
|
|
24
24
|
* const publishedService = await service.publishService({
|
|
25
25
|
* serviceFqn: 'chat.app@1.0.0',
|
|
26
|
-
* sdp: offerSdp,
|
|
26
|
+
* offers: [{ sdp: offerSdp }],
|
|
27
27
|
* ttl: 300000,
|
|
28
28
|
* isPublic: true,
|
|
29
29
|
* })
|
|
@@ -86,7 +86,7 @@ export class RondevuService {
|
|
|
86
86
|
if (!this.usernameClaimed) {
|
|
87
87
|
throw new Error('Username not claimed. Call claimUsername() first or the server will reject the service.');
|
|
88
88
|
}
|
|
89
|
-
const { serviceFqn,
|
|
89
|
+
const { serviceFqn, offers, ttl, isPublic, metadata } = options;
|
|
90
90
|
// Generate signature for service publication
|
|
91
91
|
const message = `publish:${this.username}:${serviceFqn}:${Date.now()}`;
|
|
92
92
|
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey);
|
|
@@ -94,7 +94,7 @@ export class RondevuService {
|
|
|
94
94
|
const serviceRequest = {
|
|
95
95
|
username: this.username,
|
|
96
96
|
serviceFqn,
|
|
97
|
-
|
|
97
|
+
offers,
|
|
98
98
|
signature,
|
|
99
99
|
message,
|
|
100
100
|
ttl,
|
package/dist/rondevu-signaler.js
CHANGED
|
@@ -62,11 +62,15 @@ export class RondevuSignaler {
|
|
|
62
62
|
// Publish service with the offer SDP
|
|
63
63
|
const publishedService = await this.rondevu.publishService({
|
|
64
64
|
serviceFqn: this.service,
|
|
65
|
-
sdp: offer.sdp,
|
|
65
|
+
offers: [{ sdp: offer.sdp }],
|
|
66
66
|
ttl: 300000, // 5 minutes
|
|
67
67
|
isPublic: true,
|
|
68
68
|
});
|
|
69
|
-
|
|
69
|
+
// Get the first offer from the published service
|
|
70
|
+
if (!publishedService.offers || publishedService.offers.length === 0) {
|
|
71
|
+
throw new Error('No offers returned from service publication');
|
|
72
|
+
}
|
|
73
|
+
this.offerId = publishedService.offers[0].offerId;
|
|
70
74
|
this.serviceUuid = publishedService.uuid;
|
|
71
75
|
// Start polling for answer
|
|
72
76
|
this.startAnswerPolling();
|
|
@@ -81,11 +85,12 @@ export class RondevuSignaler {
|
|
|
81
85
|
if (!answer.sdp) {
|
|
82
86
|
throw new Error('Answer SDP is required');
|
|
83
87
|
}
|
|
84
|
-
if (!this.
|
|
85
|
-
throw new Error('No
|
|
88
|
+
if (!this.serviceUuid) {
|
|
89
|
+
throw new Error('No service UUID available. Must receive offer first.');
|
|
86
90
|
}
|
|
87
|
-
// Send answer to the
|
|
88
|
-
await this.rondevu.getAPI().
|
|
91
|
+
// Send answer to the service
|
|
92
|
+
const result = await this.rondevu.getAPI().answerService(this.serviceUuid, answer.sdp);
|
|
93
|
+
this.offerId = result.offerId;
|
|
89
94
|
// Start polling for ICE candidates
|
|
90
95
|
this.startIcePolling();
|
|
91
96
|
}
|
|
@@ -125,8 +130,8 @@ export class RondevuSignaler {
|
|
|
125
130
|
* Send an ICE candidate to the remote peer
|
|
126
131
|
*/
|
|
127
132
|
async addIceCandidate(candidate) {
|
|
128
|
-
if (!this.
|
|
129
|
-
console.warn('Cannot send ICE candidate: no
|
|
133
|
+
if (!this.serviceUuid) {
|
|
134
|
+
console.warn('Cannot send ICE candidate: no service UUID');
|
|
130
135
|
return;
|
|
131
136
|
}
|
|
132
137
|
const candidateData = candidate.toJSON();
|
|
@@ -135,7 +140,11 @@ export class RondevuSignaler {
|
|
|
135
140
|
return;
|
|
136
141
|
}
|
|
137
142
|
try {
|
|
138
|
-
await this.rondevu.getAPI().
|
|
143
|
+
const result = await this.rondevu.getAPI().addServiceIceCandidates(this.serviceUuid, [candidateData], this.offerId || undefined);
|
|
144
|
+
// Store offerId if we didn't have it yet
|
|
145
|
+
if (!this.offerId) {
|
|
146
|
+
this.offerId = result.offerId;
|
|
147
|
+
}
|
|
139
148
|
}
|
|
140
149
|
catch (err) {
|
|
141
150
|
console.error('Failed to send ICE candidate:', err);
|
|
@@ -173,12 +182,19 @@ export class RondevuSignaler {
|
|
|
173
182
|
}
|
|
174
183
|
// Get the first available service (already has full details from searchServices)
|
|
175
184
|
const service = services[0];
|
|
176
|
-
|
|
185
|
+
// Get the first available offer from the service
|
|
186
|
+
if (!service.offers || service.offers.length === 0) {
|
|
187
|
+
console.warn(`No offers available for service ${this.host}/${this.service}`);
|
|
188
|
+
this.isPolling = false;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const firstOffer = service.offers[0];
|
|
192
|
+
this.offerId = firstOffer.offerId;
|
|
177
193
|
this.serviceUuid = service.uuid;
|
|
178
194
|
// Notify offer listeners
|
|
179
195
|
const offer = {
|
|
180
196
|
type: 'offer',
|
|
181
|
-
sdp:
|
|
197
|
+
sdp: firstOffer.sdp,
|
|
182
198
|
};
|
|
183
199
|
this.offerListeners.forEach(listener => {
|
|
184
200
|
try {
|
|
@@ -198,19 +214,23 @@ export class RondevuSignaler {
|
|
|
198
214
|
* Start polling for answer (offerer side) with exponential backoff
|
|
199
215
|
*/
|
|
200
216
|
startAnswerPolling() {
|
|
201
|
-
if (this.answerPollingTimeout || !this.
|
|
217
|
+
if (this.answerPollingTimeout || !this.serviceUuid) {
|
|
202
218
|
return;
|
|
203
219
|
}
|
|
204
220
|
let interval = this.pollingConfig.initialInterval;
|
|
205
221
|
let retries = 0;
|
|
206
222
|
const poll = async () => {
|
|
207
|
-
if (!this.
|
|
223
|
+
if (!this.serviceUuid) {
|
|
208
224
|
this.stopAnswerPolling();
|
|
209
225
|
return;
|
|
210
226
|
}
|
|
211
227
|
try {
|
|
212
|
-
const answer = await this.rondevu.getAPI().
|
|
228
|
+
const answer = await this.rondevu.getAPI().getServiceAnswer(this.serviceUuid);
|
|
213
229
|
if (answer && answer.sdp) {
|
|
230
|
+
// Store offerId if we didn't have it yet
|
|
231
|
+
if (!this.offerId) {
|
|
232
|
+
this.offerId = answer.offerId;
|
|
233
|
+
}
|
|
214
234
|
// Got answer - notify listeners and stop polling
|
|
215
235
|
const answerDesc = {
|
|
216
236
|
type: 'answer',
|
|
@@ -269,21 +289,25 @@ export class RondevuSignaler {
|
|
|
269
289
|
* Start polling for ICE candidates with adaptive backoff
|
|
270
290
|
*/
|
|
271
291
|
startIcePolling() {
|
|
272
|
-
if (this.icePollingTimeout || !this.
|
|
292
|
+
if (this.icePollingTimeout || !this.serviceUuid) {
|
|
273
293
|
return;
|
|
274
294
|
}
|
|
275
295
|
let interval = this.pollingConfig.initialInterval;
|
|
276
296
|
const poll = async () => {
|
|
277
|
-
if (!this.
|
|
297
|
+
if (!this.serviceUuid) {
|
|
278
298
|
this.stopIcePolling();
|
|
279
299
|
return;
|
|
280
300
|
}
|
|
281
301
|
try {
|
|
282
|
-
const
|
|
302
|
+
const result = await this.rondevu
|
|
283
303
|
.getAPI()
|
|
284
|
-
.
|
|
304
|
+
.getServiceIceCandidates(this.serviceUuid, this.lastIceTimestamp, this.offerId || undefined);
|
|
305
|
+
// Store offerId if we didn't have it yet
|
|
306
|
+
if (!this.offerId) {
|
|
307
|
+
this.offerId = result.offerId;
|
|
308
|
+
}
|
|
285
309
|
let foundCandidates = false;
|
|
286
|
-
for (const item of candidates) {
|
|
310
|
+
for (const item of result.candidates) {
|
|
287
311
|
if (item.candidate && item.candidate.candidate && item.candidate.candidate !== '') {
|
|
288
312
|
foundCandidates = true;
|
|
289
313
|
try {
|
package/package.json
CHANGED