@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 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 and RESTful API changes:
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
- sdp: string;
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
- offerId: string;
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 an offer
102
+ * Answer a service
94
103
  */
95
- answerOffer(offerId: string, sdp: string, secret?: string): Promise<void>;
104
+ answerService(serviceUuid: string, sdp: string): Promise<{
105
+ offerId: string;
106
+ }>;
96
107
  /**
97
- * Get answer for an offer (offerer polls this)
108
+ * Get answer for a service (offerer polls this)
98
109
  */
99
- getAnswer(offerId: string): Promise<{
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 an offer
119
+ * Add ICE candidates to a service
108
120
  */
109
- addIceCandidates(offerId: string, candidates: RTCIceCandidateInit[]): Promise<void>;
121
+ addServiceIceCandidates(serviceUuid: string, candidates: RTCIceCandidateInit[], offerId?: string): Promise<{
122
+ offerId: string;
123
+ }>;
110
124
  /**
111
- * Get ICE candidates for an offer (with polling support)
125
+ * Get ICE candidates for a service (with polling support)
112
126
  */
113
- getIceCandidates(offerId: string, since?: number): Promise<IceCandidate[]>;
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 an offer
134
+ * Answer a service
135
135
  */
136
- async answerOffer(offerId, sdp, secret) {
137
- const response = await fetch(`${this.baseUrl}/offers/${offerId}/answer`, {
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, secret }),
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 offer: ${error.error || response.statusText}`);
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 an offer (offerer polls this)
152
+ * Get answer for a service (offerer polls this)
152
153
  */
153
- async getAnswer(offerId) {
154
- const response = await fetch(`${this.baseUrl}/offers/${offerId}/answer`, {
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 an offer
186
+ * Add ICE candidates to a service
186
187
  */
187
- async addIceCandidates(offerId, candidates) {
188
- const response = await fetch(`${this.baseUrl}/offers/${offerId}/ice-candidates`, {
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 an offer (with polling support)
204
+ * Get ICE candidates for a service (with polling support)
203
205
  */
204
- async getIceCandidates(offerId, since = 0) {
205
- const response = await fetch(`${this.baseUrl}/offers/${offerId}/ice-candidates?since=${since}`, { headers: this.getAuthHeader() });
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 data.candidates || [];
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
- sdp: string;
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
  * })
@@ -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, sdp, ttl, isPublic, metadata } = options;
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
- sdp,
97
+ offers,
98
98
  signature,
99
99
  message,
100
100
  ttl,
@@ -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
- this.offerId = publishedService.offerId;
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.offerId) {
85
- throw new Error('No offer ID available. Must receive offer first.');
88
+ if (!this.serviceUuid) {
89
+ throw new Error('No service UUID available. Must receive offer first.');
86
90
  }
87
- // Send answer to the offer
88
- await this.rondevu.getAPI().answerOffer(this.offerId, answer.sdp);
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.offerId) {
129
- console.warn('Cannot send ICE candidate: no offer ID');
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().addIceCandidates(this.offerId, [candidateData]);
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
- this.offerId = service.offerId;
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: service.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.offerId) {
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.offerId) {
223
+ if (!this.serviceUuid) {
208
224
  this.stopAnswerPolling();
209
225
  return;
210
226
  }
211
227
  try {
212
- const answer = await this.rondevu.getAPI().getAnswer(this.offerId);
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.offerId) {
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.offerId) {
297
+ if (!this.serviceUuid) {
278
298
  this.stopIcePolling();
279
299
  return;
280
300
  }
281
301
  try {
282
- const candidates = await this.rondevu
302
+ const result = await this.rondevu
283
303
  .getAPI()
284
- .getIceCandidates(this.offerId, this.lastIceTimestamp);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xtr-dev/rondevu-client",
3
- "version": "0.10.2",
3
+ "version": "0.12.0",
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",