@xtr-dev/rondevu-client 0.7.12 → 0.8.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 +459 -404
- package/dist/discovery.d.ts +93 -0
- package/dist/discovery.js +164 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +6 -2
- package/dist/offer-pool.d.ts +74 -0
- package/dist/offer-pool.js +119 -0
- package/dist/rondevu.d.ts +16 -1
- package/dist/rondevu.js +29 -2
- package/dist/service-pool.d.ts +115 -0
- package/dist/service-pool.js +339 -0
- package/dist/services.d.ts +79 -0
- package/dist/services.js +206 -0
- package/dist/usernames.d.ts +79 -0
- package/dist/usernames.js +147 -0
- package/package.json +3 -3
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { RondevuOffers } from './offers.js';
|
|
2
|
+
import { RondevuUsername } from './usernames.js';
|
|
3
|
+
import RondevuPeer from './peer/index.js';
|
|
4
|
+
import { OfferPool } from './offer-pool.js';
|
|
5
|
+
/**
|
|
6
|
+
* Manages a pooled service with multiple concurrent connections
|
|
7
|
+
*
|
|
8
|
+
* ServicePool coordinates offer creation, answer polling, and connection
|
|
9
|
+
* management for services that need to handle multiple simultaneous connections.
|
|
10
|
+
*/
|
|
11
|
+
export class ServicePool {
|
|
12
|
+
constructor(baseUrl, credentials, options) {
|
|
13
|
+
this.baseUrl = baseUrl;
|
|
14
|
+
this.credentials = credentials;
|
|
15
|
+
this.options = options;
|
|
16
|
+
this.connections = new Map();
|
|
17
|
+
this.status = {
|
|
18
|
+
activeOffers: 0,
|
|
19
|
+
activeConnections: 0,
|
|
20
|
+
totalConnectionsHandled: 0,
|
|
21
|
+
failedOfferCreations: 0
|
|
22
|
+
};
|
|
23
|
+
this.offersApi = new RondevuOffers(baseUrl, credentials);
|
|
24
|
+
this.usernameApi = new RondevuUsername(baseUrl);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Start the pooled service
|
|
28
|
+
*/
|
|
29
|
+
async start() {
|
|
30
|
+
const poolSize = this.options.poolSize || 1;
|
|
31
|
+
// 1. Create initial service (publishes first offer)
|
|
32
|
+
const service = await this.publishInitialService();
|
|
33
|
+
this.serviceId = service.serviceId;
|
|
34
|
+
this.uuid = service.uuid;
|
|
35
|
+
// 2. Create additional offers for pool (poolSize - 1)
|
|
36
|
+
const additionalOffers = [];
|
|
37
|
+
if (poolSize > 1) {
|
|
38
|
+
try {
|
|
39
|
+
const offers = await this.createOffers(poolSize - 1);
|
|
40
|
+
additionalOffers.push(...offers);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
this.handleError(error, 'initial-offer-creation');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// 3. Initialize OfferPool with all offers
|
|
47
|
+
this.offerPool = new OfferPool(this.offersApi, {
|
|
48
|
+
poolSize,
|
|
49
|
+
pollingInterval: this.options.pollingInterval || 2000,
|
|
50
|
+
onAnswered: (answer) => this.handleConnection(answer),
|
|
51
|
+
onRefill: (count) => this.createOffers(count),
|
|
52
|
+
onError: (err, ctx) => this.handleError(err, ctx)
|
|
53
|
+
});
|
|
54
|
+
// Add all offers to pool
|
|
55
|
+
const allOffers = [
|
|
56
|
+
{ id: service.offerId, peerId: this.credentials.peerId, sdp: '', topics: [], expiresAt: service.expiresAt, lastSeen: Date.now() },
|
|
57
|
+
...additionalOffers
|
|
58
|
+
];
|
|
59
|
+
await this.offerPool.addOffers(allOffers);
|
|
60
|
+
// 4. Start polling
|
|
61
|
+
await this.offerPool.start();
|
|
62
|
+
// Update status
|
|
63
|
+
this.updateStatus();
|
|
64
|
+
// 5. Return handle
|
|
65
|
+
return {
|
|
66
|
+
serviceId: this.serviceId,
|
|
67
|
+
uuid: this.uuid,
|
|
68
|
+
offerId: service.offerId,
|
|
69
|
+
unpublish: () => this.stop(),
|
|
70
|
+
getStatus: () => this.getStatus(),
|
|
71
|
+
addOffers: (count) => this.manualRefill(count)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Stop the pooled service and clean up
|
|
76
|
+
*/
|
|
77
|
+
async stop() {
|
|
78
|
+
// 1. Stop accepting new connections
|
|
79
|
+
if (this.offerPool) {
|
|
80
|
+
await this.offerPool.stop();
|
|
81
|
+
}
|
|
82
|
+
// 2. Delete remaining offers
|
|
83
|
+
if (this.offerPool) {
|
|
84
|
+
const offerIds = this.offerPool.getActiveOfferIds();
|
|
85
|
+
await Promise.allSettled(offerIds.map(id => this.offersApi.delete(id).catch(() => { })));
|
|
86
|
+
}
|
|
87
|
+
// 3. Close active connections
|
|
88
|
+
const closePromises = Array.from(this.connections.values()).map(async (conn) => {
|
|
89
|
+
try {
|
|
90
|
+
// Give a brief moment for graceful closure
|
|
91
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
92
|
+
conn.peer.pc.close();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Ignore errors during cleanup
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
await Promise.allSettled(closePromises);
|
|
99
|
+
// 4. Delete service if we have a serviceId
|
|
100
|
+
if (this.serviceId) {
|
|
101
|
+
try {
|
|
102
|
+
const response = await fetch(`${this.baseUrl}/services/${this.serviceId}`, {
|
|
103
|
+
method: 'DELETE',
|
|
104
|
+
headers: {
|
|
105
|
+
'Content-Type': 'application/json',
|
|
106
|
+
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
|
|
107
|
+
},
|
|
108
|
+
body: JSON.stringify({ username: this.options.username })
|
|
109
|
+
});
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
console.error('Failed to delete service:', await response.text());
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
console.error('Error deleting service:', error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Clear all state
|
|
119
|
+
this.connections.clear();
|
|
120
|
+
this.offerPool = undefined;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Handle an answered offer by setting up the connection
|
|
124
|
+
*/
|
|
125
|
+
async handleConnection(answer) {
|
|
126
|
+
const connectionId = this.generateConnectionId();
|
|
127
|
+
try {
|
|
128
|
+
// Create peer connection
|
|
129
|
+
const peer = new RondevuPeer(this.offersApi, this.options.rtcConfig || {
|
|
130
|
+
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
131
|
+
});
|
|
132
|
+
peer.role = 'offerer';
|
|
133
|
+
peer.offerId = answer.offerId;
|
|
134
|
+
// Set remote description (the answer)
|
|
135
|
+
await peer.pc.setRemoteDescription({
|
|
136
|
+
type: 'answer',
|
|
137
|
+
sdp: answer.sdp
|
|
138
|
+
});
|
|
139
|
+
// Wait for data channel (answerer creates it, we receive it)
|
|
140
|
+
const channel = await new Promise((resolve, reject) => {
|
|
141
|
+
const timeout = setTimeout(() => reject(new Error('Timeout waiting for data channel')), 30000);
|
|
142
|
+
peer.on('datachannel', (ch) => {
|
|
143
|
+
clearTimeout(timeout);
|
|
144
|
+
resolve(ch);
|
|
145
|
+
});
|
|
146
|
+
// Also check if channel already exists
|
|
147
|
+
if (peer.pc.ondatachannel) {
|
|
148
|
+
const existingHandler = peer.pc.ondatachannel;
|
|
149
|
+
peer.pc.ondatachannel = (event) => {
|
|
150
|
+
clearTimeout(timeout);
|
|
151
|
+
resolve(event.channel);
|
|
152
|
+
if (existingHandler)
|
|
153
|
+
existingHandler.call(peer.pc, event);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
peer.pc.ondatachannel = (event) => {
|
|
158
|
+
clearTimeout(timeout);
|
|
159
|
+
resolve(event.channel);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
// Register connection
|
|
164
|
+
this.connections.set(connectionId, {
|
|
165
|
+
peer,
|
|
166
|
+
channel,
|
|
167
|
+
connectedAt: Date.now(),
|
|
168
|
+
offerId: answer.offerId
|
|
169
|
+
});
|
|
170
|
+
this.status.activeConnections++;
|
|
171
|
+
this.status.totalConnectionsHandled++;
|
|
172
|
+
// Setup cleanup on disconnect
|
|
173
|
+
peer.on('disconnected', () => {
|
|
174
|
+
this.connections.delete(connectionId);
|
|
175
|
+
this.status.activeConnections--;
|
|
176
|
+
this.updateStatus();
|
|
177
|
+
});
|
|
178
|
+
peer.on('failed', () => {
|
|
179
|
+
this.connections.delete(connectionId);
|
|
180
|
+
this.status.activeConnections--;
|
|
181
|
+
this.updateStatus();
|
|
182
|
+
});
|
|
183
|
+
// Update status
|
|
184
|
+
this.updateStatus();
|
|
185
|
+
// Invoke user handler (wrapped in try-catch)
|
|
186
|
+
try {
|
|
187
|
+
this.options.handler(channel, peer, connectionId);
|
|
188
|
+
}
|
|
189
|
+
catch (handlerError) {
|
|
190
|
+
this.handleError(handlerError, 'handler');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
this.handleError(error, 'connection-setup');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Create multiple offers
|
|
199
|
+
*/
|
|
200
|
+
async createOffers(count) {
|
|
201
|
+
if (count <= 0) {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
// Server supports max 10 offers per request
|
|
205
|
+
const batchSize = Math.min(count, 10);
|
|
206
|
+
const offers = [];
|
|
207
|
+
try {
|
|
208
|
+
// Create peer connections and generate offers
|
|
209
|
+
const offerRequests = [];
|
|
210
|
+
for (let i = 0; i < batchSize; i++) {
|
|
211
|
+
const pc = new RTCPeerConnection(this.options.rtcConfig || {
|
|
212
|
+
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
213
|
+
});
|
|
214
|
+
// Create data channel (required for offers)
|
|
215
|
+
pc.createDataChannel('rondevu-service');
|
|
216
|
+
// Create offer
|
|
217
|
+
const offer = await pc.createOffer();
|
|
218
|
+
await pc.setLocalDescription(offer);
|
|
219
|
+
if (!offer.sdp) {
|
|
220
|
+
pc.close();
|
|
221
|
+
throw new Error('Failed to generate SDP');
|
|
222
|
+
}
|
|
223
|
+
offerRequests.push({
|
|
224
|
+
sdp: offer.sdp,
|
|
225
|
+
topics: [], // V2 doesn't use topics
|
|
226
|
+
ttl: this.options.ttl
|
|
227
|
+
});
|
|
228
|
+
// Close the PC immediately - we only needed the SDP
|
|
229
|
+
pc.close();
|
|
230
|
+
}
|
|
231
|
+
// Batch create offers
|
|
232
|
+
const createdOffers = await this.offersApi.create(offerRequests);
|
|
233
|
+
offers.push(...createdOffers);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
this.status.failedOfferCreations++;
|
|
237
|
+
this.handleError(error, 'offer-creation');
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
return offers;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Publish the initial service (creates first offer)
|
|
244
|
+
*/
|
|
245
|
+
async publishInitialService() {
|
|
246
|
+
const { username, privateKey, serviceFqn, rtcConfig, isPublic, metadata, ttl } = this.options;
|
|
247
|
+
// Create peer connection for initial offer
|
|
248
|
+
const pc = new RTCPeerConnection(rtcConfig || {
|
|
249
|
+
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
250
|
+
});
|
|
251
|
+
pc.createDataChannel('rondevu-service');
|
|
252
|
+
// Create offer
|
|
253
|
+
const offer = await pc.createOffer();
|
|
254
|
+
await pc.setLocalDescription(offer);
|
|
255
|
+
if (!offer.sdp) {
|
|
256
|
+
pc.close();
|
|
257
|
+
throw new Error('Failed to generate SDP');
|
|
258
|
+
}
|
|
259
|
+
// Create signature
|
|
260
|
+
const timestamp = Date.now();
|
|
261
|
+
const message = `publish:${username}:${serviceFqn}:${timestamp}`;
|
|
262
|
+
const signature = await this.usernameApi.signMessage(message, privateKey);
|
|
263
|
+
// Publish service
|
|
264
|
+
const response = await fetch(`${this.baseUrl}/services`, {
|
|
265
|
+
method: 'POST',
|
|
266
|
+
headers: {
|
|
267
|
+
'Content-Type': 'application/json',
|
|
268
|
+
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
|
|
269
|
+
},
|
|
270
|
+
body: JSON.stringify({
|
|
271
|
+
username,
|
|
272
|
+
serviceFqn,
|
|
273
|
+
sdp: offer.sdp,
|
|
274
|
+
ttl,
|
|
275
|
+
isPublic,
|
|
276
|
+
metadata,
|
|
277
|
+
signature,
|
|
278
|
+
message
|
|
279
|
+
})
|
|
280
|
+
});
|
|
281
|
+
pc.close();
|
|
282
|
+
if (!response.ok) {
|
|
283
|
+
const error = await response.json();
|
|
284
|
+
throw new Error(error.error || 'Failed to publish service');
|
|
285
|
+
}
|
|
286
|
+
const data = await response.json();
|
|
287
|
+
return {
|
|
288
|
+
serviceId: data.serviceId,
|
|
289
|
+
uuid: data.uuid,
|
|
290
|
+
offerId: data.offerId,
|
|
291
|
+
expiresAt: data.expiresAt
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Manually add offers to the pool
|
|
296
|
+
*/
|
|
297
|
+
async manualRefill(count) {
|
|
298
|
+
if (!this.offerPool) {
|
|
299
|
+
throw new Error('Pool not started');
|
|
300
|
+
}
|
|
301
|
+
const offers = await this.createOffers(count);
|
|
302
|
+
await this.offerPool.addOffers(offers);
|
|
303
|
+
this.updateStatus();
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Get current pool status
|
|
307
|
+
*/
|
|
308
|
+
getStatus() {
|
|
309
|
+
return { ...this.status };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Update status and notify listeners
|
|
313
|
+
*/
|
|
314
|
+
updateStatus() {
|
|
315
|
+
if (this.offerPool) {
|
|
316
|
+
this.status.activeOffers = this.offerPool.getActiveOfferCount();
|
|
317
|
+
}
|
|
318
|
+
if (this.options.onPoolStatus) {
|
|
319
|
+
this.options.onPoolStatus(this.getStatus());
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Handle errors
|
|
324
|
+
*/
|
|
325
|
+
handleError(error, context) {
|
|
326
|
+
if (this.options.onError) {
|
|
327
|
+
this.options.onError(error, context);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
console.error(`ServicePool error (${context}):`, error);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Generate a unique connection ID
|
|
335
|
+
*/
|
|
336
|
+
generateConnectionId() {
|
|
337
|
+
return `conn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import RondevuPeer from './peer/index.js';
|
|
2
|
+
import { PooledServiceHandle, PoolStatus } from './service-pool.js';
|
|
3
|
+
/**
|
|
4
|
+
* Service publish result
|
|
5
|
+
*/
|
|
6
|
+
export interface ServicePublishResult {
|
|
7
|
+
serviceId: string;
|
|
8
|
+
uuid: string;
|
|
9
|
+
offerId: string;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Service publish options
|
|
14
|
+
*/
|
|
15
|
+
export interface PublishServiceOptions {
|
|
16
|
+
username: string;
|
|
17
|
+
privateKey: string;
|
|
18
|
+
serviceFqn: string;
|
|
19
|
+
rtcConfig?: RTCConfiguration;
|
|
20
|
+
isPublic?: boolean;
|
|
21
|
+
metadata?: Record<string, any>;
|
|
22
|
+
ttl?: number;
|
|
23
|
+
onConnection?: (peer: RondevuPeer) => void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Service handle for managing an exposed service
|
|
27
|
+
*/
|
|
28
|
+
export interface ServiceHandle {
|
|
29
|
+
serviceId: string;
|
|
30
|
+
uuid: string;
|
|
31
|
+
offerId: string;
|
|
32
|
+
unpublish: () => Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Rondevu Services API
|
|
36
|
+
* Handles service publishing and management
|
|
37
|
+
*/
|
|
38
|
+
export declare class RondevuServices {
|
|
39
|
+
private baseUrl;
|
|
40
|
+
private credentials;
|
|
41
|
+
private usernameApi;
|
|
42
|
+
private offersApi;
|
|
43
|
+
constructor(baseUrl: string, credentials: {
|
|
44
|
+
peerId: string;
|
|
45
|
+
secret: string;
|
|
46
|
+
});
|
|
47
|
+
/**
|
|
48
|
+
* Publishes a service
|
|
49
|
+
*/
|
|
50
|
+
publishService(options: PublishServiceOptions): Promise<ServicePublishResult>;
|
|
51
|
+
/**
|
|
52
|
+
* Unpublishes a service
|
|
53
|
+
*/
|
|
54
|
+
unpublishService(serviceId: string, username: string): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Exposes a service with an automatic connection handler
|
|
57
|
+
* This is a convenience method that publishes the service and manages connections
|
|
58
|
+
*
|
|
59
|
+
* Set poolSize > 1 to enable offer pooling for handling multiple concurrent connections
|
|
60
|
+
*/
|
|
61
|
+
exposeService(options: Omit<PublishServiceOptions, 'onConnection'> & {
|
|
62
|
+
handler: (channel: RTCDataChannel, peer: RondevuPeer, connectionId?: string) => void;
|
|
63
|
+
poolSize?: number;
|
|
64
|
+
pollingInterval?: number;
|
|
65
|
+
onPoolStatus?: (status: PoolStatus) => void;
|
|
66
|
+
onError?: (error: Error, context: string) => void;
|
|
67
|
+
}): Promise<ServiceHandle | PooledServiceHandle>;
|
|
68
|
+
/**
|
|
69
|
+
* Validates service FQN format
|
|
70
|
+
*/
|
|
71
|
+
private validateServiceFqn;
|
|
72
|
+
/**
|
|
73
|
+
* Parses a service FQN into name and version
|
|
74
|
+
*/
|
|
75
|
+
parseServiceFqn(fqn: string): {
|
|
76
|
+
name: string;
|
|
77
|
+
version: string;
|
|
78
|
+
};
|
|
79
|
+
}
|
package/dist/services.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { RondevuUsername } from './usernames.js';
|
|
2
|
+
import RondevuPeer from './peer/index.js';
|
|
3
|
+
import { RondevuOffers } from './offers.js';
|
|
4
|
+
import { ServicePool } from './service-pool.js';
|
|
5
|
+
/**
|
|
6
|
+
* Rondevu Services API
|
|
7
|
+
* Handles service publishing and management
|
|
8
|
+
*/
|
|
9
|
+
export class RondevuServices {
|
|
10
|
+
constructor(baseUrl, credentials) {
|
|
11
|
+
this.baseUrl = baseUrl;
|
|
12
|
+
this.credentials = credentials;
|
|
13
|
+
this.usernameApi = new RondevuUsername(baseUrl);
|
|
14
|
+
this.offersApi = new RondevuOffers(baseUrl, credentials);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Publishes a service
|
|
18
|
+
*/
|
|
19
|
+
async publishService(options) {
|
|
20
|
+
const { username, privateKey, serviceFqn, rtcConfig, isPublic = false, metadata, ttl } = options;
|
|
21
|
+
// Validate FQN format
|
|
22
|
+
this.validateServiceFqn(serviceFqn);
|
|
23
|
+
// Create WebRTC peer connection to generate offer
|
|
24
|
+
const pc = new RTCPeerConnection(rtcConfig || {
|
|
25
|
+
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
26
|
+
});
|
|
27
|
+
// Add a data channel (required for datachannel-based services)
|
|
28
|
+
pc.createDataChannel('rondevu-service');
|
|
29
|
+
// Create offer
|
|
30
|
+
const offer = await pc.createOffer();
|
|
31
|
+
await pc.setLocalDescription(offer);
|
|
32
|
+
if (!offer.sdp) {
|
|
33
|
+
throw new Error('Failed to generate SDP');
|
|
34
|
+
}
|
|
35
|
+
// Create signature for username verification
|
|
36
|
+
const timestamp = Date.now();
|
|
37
|
+
const message = `publish:${username}:${serviceFqn}:${timestamp}`;
|
|
38
|
+
const signature = await this.usernameApi.signMessage(message, privateKey);
|
|
39
|
+
// Publish service
|
|
40
|
+
const response = await fetch(`${this.baseUrl}/services`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: {
|
|
43
|
+
'Content-Type': 'application/json',
|
|
44
|
+
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
|
|
45
|
+
},
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
username,
|
|
48
|
+
serviceFqn,
|
|
49
|
+
sdp: offer.sdp,
|
|
50
|
+
ttl,
|
|
51
|
+
isPublic,
|
|
52
|
+
metadata,
|
|
53
|
+
signature,
|
|
54
|
+
message
|
|
55
|
+
})
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const error = await response.json();
|
|
59
|
+
pc.close();
|
|
60
|
+
throw new Error(error.error || 'Failed to publish service');
|
|
61
|
+
}
|
|
62
|
+
const data = await response.json();
|
|
63
|
+
// Close the connection for now (would be kept open in a real implementation)
|
|
64
|
+
pc.close();
|
|
65
|
+
return {
|
|
66
|
+
serviceId: data.serviceId,
|
|
67
|
+
uuid: data.uuid,
|
|
68
|
+
offerId: data.offerId,
|
|
69
|
+
expiresAt: data.expiresAt
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Unpublishes a service
|
|
74
|
+
*/
|
|
75
|
+
async unpublishService(serviceId, username) {
|
|
76
|
+
const response = await fetch(`${this.baseUrl}/services/${serviceId}`, {
|
|
77
|
+
method: 'DELETE',
|
|
78
|
+
headers: {
|
|
79
|
+
'Content-Type': 'application/json',
|
|
80
|
+
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
|
|
81
|
+
},
|
|
82
|
+
body: JSON.stringify({ username })
|
|
83
|
+
});
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
const error = await response.json();
|
|
86
|
+
throw new Error(error.error || 'Failed to unpublish service');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Exposes a service with an automatic connection handler
|
|
91
|
+
* This is a convenience method that publishes the service and manages connections
|
|
92
|
+
*
|
|
93
|
+
* Set poolSize > 1 to enable offer pooling for handling multiple concurrent connections
|
|
94
|
+
*/
|
|
95
|
+
async exposeService(options) {
|
|
96
|
+
const { username, privateKey, serviceFqn, rtcConfig, isPublic, metadata, ttl, handler, poolSize, pollingInterval, onPoolStatus, onError } = options;
|
|
97
|
+
// If poolSize > 1, use pooled implementation
|
|
98
|
+
if (poolSize && poolSize > 1) {
|
|
99
|
+
const pool = new ServicePool(this.baseUrl, this.credentials, {
|
|
100
|
+
username,
|
|
101
|
+
privateKey,
|
|
102
|
+
serviceFqn,
|
|
103
|
+
rtcConfig,
|
|
104
|
+
isPublic,
|
|
105
|
+
metadata,
|
|
106
|
+
ttl,
|
|
107
|
+
handler: (channel, peer, connectionId) => handler(channel, peer, connectionId),
|
|
108
|
+
poolSize,
|
|
109
|
+
pollingInterval,
|
|
110
|
+
onPoolStatus,
|
|
111
|
+
onError
|
|
112
|
+
});
|
|
113
|
+
return await pool.start();
|
|
114
|
+
}
|
|
115
|
+
// Otherwise, use existing single-offer logic (UNCHANGED)
|
|
116
|
+
// Validate FQN
|
|
117
|
+
this.validateServiceFqn(serviceFqn);
|
|
118
|
+
// Create peer connection
|
|
119
|
+
const pc = new RTCPeerConnection(rtcConfig || {
|
|
120
|
+
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
121
|
+
});
|
|
122
|
+
// Create data channel
|
|
123
|
+
const channel = pc.createDataChannel('rondevu-service');
|
|
124
|
+
// Set up handler
|
|
125
|
+
channel.onopen = () => {
|
|
126
|
+
const peer = new RondevuPeer(this.offersApi, rtcConfig || {
|
|
127
|
+
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
128
|
+
});
|
|
129
|
+
handler(channel, peer);
|
|
130
|
+
};
|
|
131
|
+
// Create offer
|
|
132
|
+
const offer = await pc.createOffer();
|
|
133
|
+
await pc.setLocalDescription(offer);
|
|
134
|
+
if (!offer.sdp) {
|
|
135
|
+
pc.close();
|
|
136
|
+
throw new Error('Failed to generate SDP');
|
|
137
|
+
}
|
|
138
|
+
// Create signature
|
|
139
|
+
const timestamp = Date.now();
|
|
140
|
+
const message = `publish:${username}:${serviceFqn}:${timestamp}`;
|
|
141
|
+
const signature = await this.usernameApi.signMessage(message, privateKey);
|
|
142
|
+
// Publish service
|
|
143
|
+
const response = await fetch(`${this.baseUrl}/services`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: {
|
|
146
|
+
'Content-Type': 'application/json',
|
|
147
|
+
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
|
|
148
|
+
},
|
|
149
|
+
body: JSON.stringify({
|
|
150
|
+
username,
|
|
151
|
+
serviceFqn,
|
|
152
|
+
sdp: offer.sdp,
|
|
153
|
+
ttl,
|
|
154
|
+
isPublic,
|
|
155
|
+
metadata,
|
|
156
|
+
signature,
|
|
157
|
+
message
|
|
158
|
+
})
|
|
159
|
+
});
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
const error = await response.json();
|
|
162
|
+
pc.close();
|
|
163
|
+
throw new Error(error.error || 'Failed to expose service');
|
|
164
|
+
}
|
|
165
|
+
const data = await response.json();
|
|
166
|
+
return {
|
|
167
|
+
serviceId: data.serviceId,
|
|
168
|
+
uuid: data.uuid,
|
|
169
|
+
offerId: data.offerId,
|
|
170
|
+
unpublish: () => this.unpublishService(data.serviceId, username)
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Validates service FQN format
|
|
175
|
+
*/
|
|
176
|
+
validateServiceFqn(fqn) {
|
|
177
|
+
const parts = fqn.split('@');
|
|
178
|
+
if (parts.length !== 2) {
|
|
179
|
+
throw new Error('Service FQN must be in format: service-name@version');
|
|
180
|
+
}
|
|
181
|
+
const [serviceName, version] = parts;
|
|
182
|
+
// Validate service name (reverse domain notation)
|
|
183
|
+
const serviceNameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
184
|
+
if (!serviceNameRegex.test(serviceName)) {
|
|
185
|
+
throw new Error('Service name must be reverse domain notation (e.g., com.example.service)');
|
|
186
|
+
}
|
|
187
|
+
if (serviceName.length < 3 || serviceName.length > 128) {
|
|
188
|
+
throw new Error('Service name must be 3-128 characters');
|
|
189
|
+
}
|
|
190
|
+
// Validate version (semantic versioning)
|
|
191
|
+
const versionRegex = /^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$/;
|
|
192
|
+
if (!versionRegex.test(version)) {
|
|
193
|
+
throw new Error('Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Parses a service FQN into name and version
|
|
198
|
+
*/
|
|
199
|
+
parseServiceFqn(fqn) {
|
|
200
|
+
const parts = fqn.split('@');
|
|
201
|
+
if (parts.length !== 2) {
|
|
202
|
+
throw new Error('Invalid FQN format');
|
|
203
|
+
}
|
|
204
|
+
return { name: parts[0], version: parts[1] };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Username claim result
|
|
3
|
+
*/
|
|
4
|
+
export interface UsernameClaimResult {
|
|
5
|
+
username: string;
|
|
6
|
+
publicKey: string;
|
|
7
|
+
privateKey: string;
|
|
8
|
+
claimedAt: number;
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Username availability check result
|
|
13
|
+
*/
|
|
14
|
+
export interface UsernameCheckResult {
|
|
15
|
+
username: string;
|
|
16
|
+
available: boolean;
|
|
17
|
+
claimedAt?: number;
|
|
18
|
+
expiresAt?: number;
|
|
19
|
+
publicKey?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Rondevu Username API
|
|
23
|
+
* Handles username claiming with Ed25519 cryptographic proof
|
|
24
|
+
*/
|
|
25
|
+
export declare class RondevuUsername {
|
|
26
|
+
private baseUrl;
|
|
27
|
+
constructor(baseUrl: string);
|
|
28
|
+
/**
|
|
29
|
+
* Generates an Ed25519 keypair for username claiming
|
|
30
|
+
*/
|
|
31
|
+
generateKeypair(): Promise<{
|
|
32
|
+
publicKey: string;
|
|
33
|
+
privateKey: string;
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* Signs a message with an Ed25519 private key
|
|
37
|
+
*/
|
|
38
|
+
signMessage(message: string, privateKeyBase64: string): Promise<string>;
|
|
39
|
+
/**
|
|
40
|
+
* Claims a username
|
|
41
|
+
* Generates a new keypair if one is not provided
|
|
42
|
+
*/
|
|
43
|
+
claimUsername(username: string, existingKeypair?: {
|
|
44
|
+
publicKey: string;
|
|
45
|
+
privateKey: string;
|
|
46
|
+
}): Promise<UsernameClaimResult>;
|
|
47
|
+
/**
|
|
48
|
+
* Checks if a username is available
|
|
49
|
+
*/
|
|
50
|
+
checkUsername(username: string): Promise<UsernameCheckResult>;
|
|
51
|
+
/**
|
|
52
|
+
* Helper: Save keypair to localStorage
|
|
53
|
+
* WARNING: This stores the private key in localStorage which is not the most secure
|
|
54
|
+
* For production use, consider using IndexedDB with encryption or hardware security modules
|
|
55
|
+
*/
|
|
56
|
+
saveKeypairToStorage(username: string, publicKey: string, privateKey: string): void;
|
|
57
|
+
/**
|
|
58
|
+
* Helper: Load keypair from localStorage
|
|
59
|
+
*/
|
|
60
|
+
loadKeypairFromStorage(username: string): {
|
|
61
|
+
publicKey: string;
|
|
62
|
+
privateKey: string;
|
|
63
|
+
} | null;
|
|
64
|
+
/**
|
|
65
|
+
* Helper: Delete keypair from localStorage
|
|
66
|
+
*/
|
|
67
|
+
deleteKeypairFromStorage(username: string): void;
|
|
68
|
+
/**
|
|
69
|
+
* Export keypair as JSON string (for backup)
|
|
70
|
+
*/
|
|
71
|
+
exportKeypair(publicKey: string, privateKey: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* Import keypair from JSON string
|
|
74
|
+
*/
|
|
75
|
+
importKeypair(json: string): {
|
|
76
|
+
publicKey: string;
|
|
77
|
+
privateKey: string;
|
|
78
|
+
};
|
|
79
|
+
}
|