@xtr-dev/rondevu-client 0.8.2 → 0.9.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 +402 -436
- package/dist/durable/channel.d.ts +115 -0
- package/dist/durable/channel.js +301 -0
- package/dist/durable/connection.d.ts +125 -0
- package/dist/durable/connection.js +370 -0
- package/dist/durable/reconnection.d.ts +90 -0
- package/dist/durable/reconnection.js +127 -0
- package/dist/durable/service.d.ts +103 -0
- package/dist/durable/service.js +264 -0
- package/dist/durable/types.d.ts +149 -0
- package/dist/durable/types.js +28 -0
- package/dist/index.d.ts +5 -10
- package/dist/index.js +5 -9
- package/dist/offer-pool.d.ts +15 -3
- package/dist/offer-pool.js +34 -8
- package/dist/peer/exchanging-ice-state.js +10 -2
- package/dist/peer/index.d.ts +1 -1
- package/dist/peer/index.js +25 -3
- package/dist/peer/state.js +9 -1
- package/dist/rondevu.d.ts +88 -13
- package/dist/rondevu.js +110 -27
- package/dist/service-pool.d.ts +11 -3
- package/dist/service-pool.js +120 -42
- package/dist/usernames.js +4 -3
- package/package.json +2 -2
- package/dist/bloom.d.ts +0 -30
- package/dist/bloom.js +0 -73
- package/dist/client.d.ts +0 -126
- package/dist/client.js +0 -171
- package/dist/connection.d.ts +0 -127
- package/dist/connection.js +0 -295
- package/dist/discovery.d.ts +0 -93
- package/dist/discovery.js +0 -164
- package/dist/peer.d.ts +0 -111
- package/dist/peer.js +0 -392
- package/dist/services.d.ts +0 -79
- package/dist/services.js +0 -206
- package/dist/types.d.ts +0 -157
- package/dist/types.js +0 -4
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@xtr-dev/rondevu-client)
|
|
4
4
|
|
|
5
|
-
🌐 **
|
|
5
|
+
🌐 **WebRTC with durable connections and automatic reconnection**
|
|
6
6
|
|
|
7
|
-
TypeScript/JavaScript client for Rondevu, providing
|
|
7
|
+
TypeScript/JavaScript client for Rondevu, providing durable WebRTC connections that survive network interruptions with automatic reconnection and message queuing.
|
|
8
8
|
|
|
9
9
|
**Related repositories:**
|
|
10
10
|
- [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client))
|
|
@@ -15,13 +15,14 @@ TypeScript/JavaScript client for Rondevu, providing cryptographic username claim
|
|
|
15
15
|
|
|
16
16
|
## Features
|
|
17
17
|
|
|
18
|
+
- **Durable Connections**: Automatic reconnection on network drops
|
|
19
|
+
- **Message Queuing**: Messages sent during disconnections are queued and flushed on reconnect
|
|
20
|
+
- **Durable Channels**: RTCDataChannel wrappers that survive connection drops
|
|
21
|
+
- **TTL Auto-Refresh**: Services automatically republish before expiration
|
|
18
22
|
- **Username Claiming**: Cryptographic ownership with Ed25519 signatures
|
|
19
23
|
- **Service Publishing**: Package-style naming (com.example.chat@1.0.0)
|
|
20
|
-
- **Privacy-Preserving Discovery**: UUID-based service index
|
|
21
|
-
- **Public/Private Services**: Control service visibility
|
|
22
|
-
- **Complete WebRTC Signaling**: Full offer/answer and ICE candidate exchange
|
|
23
|
-
- **Trickle ICE**: Send ICE candidates as they're discovered
|
|
24
24
|
- **TypeScript**: Full type safety and autocomplete
|
|
25
|
+
- **Configurable**: All timeouts, retry limits, and queue sizes are configurable
|
|
25
26
|
|
|
26
27
|
## Install
|
|
27
28
|
|
|
@@ -44,36 +45,36 @@ await client.register();
|
|
|
44
45
|
const claim = await client.usernames.claimUsername('alice');
|
|
45
46
|
client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);
|
|
46
47
|
|
|
47
|
-
console.log(`Username claimed: ${claim.username}`);
|
|
48
|
-
console.log(`Expires: ${new Date(claim.expiresAt)}`);
|
|
49
|
-
|
|
50
48
|
// Step 2: Expose service with handler
|
|
51
49
|
const keypair = client.usernames.loadKeypairFromStorage('alice');
|
|
52
50
|
|
|
53
|
-
const
|
|
51
|
+
const service = await client.exposeService({
|
|
54
52
|
username: 'alice',
|
|
55
53
|
privateKey: keypair.privateKey,
|
|
56
|
-
serviceFqn: '
|
|
54
|
+
serviceFqn: 'chat@1.0.0',
|
|
57
55
|
isPublic: true,
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
poolSize: 10, // Handle 10 concurrent connections
|
|
57
|
+
handler: (channel, connectionId) => {
|
|
58
|
+
console.log(`📡 New connection: ${connectionId}`);
|
|
60
59
|
|
|
61
|
-
channel.
|
|
62
|
-
console.log('📥 Received:',
|
|
63
|
-
channel.send(`Echo: ${
|
|
64
|
-
};
|
|
60
|
+
channel.on('message', (data) => {
|
|
61
|
+
console.log('📥 Received:', data);
|
|
62
|
+
channel.send(`Echo: ${data}`);
|
|
63
|
+
});
|
|
65
64
|
|
|
66
|
-
channel.
|
|
67
|
-
console.log(
|
|
68
|
-
};
|
|
65
|
+
channel.on('close', () => {
|
|
66
|
+
console.log(`👋 Connection ${connectionId} closed`);
|
|
67
|
+
});
|
|
69
68
|
}
|
|
70
69
|
});
|
|
71
70
|
|
|
72
|
-
|
|
71
|
+
// Start the service
|
|
72
|
+
const info = await service.start();
|
|
73
|
+
console.log(`Service published with UUID: ${info.uuid}`);
|
|
73
74
|
console.log('Waiting for connections...');
|
|
74
75
|
|
|
75
|
-
// Later:
|
|
76
|
-
await
|
|
76
|
+
// Later: stop the service
|
|
77
|
+
await service.stop();
|
|
77
78
|
```
|
|
78
79
|
|
|
79
80
|
### Connecting to a Service (Bob)
|
|
@@ -85,46 +86,75 @@ import { Rondevu } from '@xtr-dev/rondevu-client';
|
|
|
85
86
|
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
|
|
86
87
|
await client.register();
|
|
87
88
|
|
|
88
|
-
//
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
// Connect to Alice's service
|
|
90
|
+
const connection = await client.connect('alice', 'chat@1.0.0', {
|
|
91
|
+
maxReconnectAttempts: 5
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Create a durable channel
|
|
95
|
+
const channel = connection.createChannel('main');
|
|
93
96
|
|
|
94
|
-
channel.
|
|
95
|
-
console.log('📥 Received:',
|
|
96
|
-
};
|
|
97
|
+
channel.on('message', (data) => {
|
|
98
|
+
console.log('📥 Received:', data);
|
|
99
|
+
});
|
|
97
100
|
|
|
98
|
-
channel.
|
|
99
|
-
console.log('✅
|
|
101
|
+
channel.on('open', () => {
|
|
102
|
+
console.log('✅ Channel open');
|
|
100
103
|
channel.send('Hello Alice!');
|
|
101
|
-
};
|
|
104
|
+
});
|
|
102
105
|
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
// Listen for connection events
|
|
107
|
+
connection.on('connected', () => {
|
|
108
|
+
console.log('🎉 Connected to Alice');
|
|
105
109
|
});
|
|
106
110
|
|
|
107
|
-
|
|
108
|
-
console.
|
|
111
|
+
connection.on('reconnecting', (attempt, max, delay) => {
|
|
112
|
+
console.log(`🔄 Reconnecting... (${attempt}/${max}, retry in ${delay}ms)`);
|
|
109
113
|
});
|
|
110
114
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
connection.on('disconnected', () => {
|
|
116
|
+
console.log('🔌 Disconnected');
|
|
117
|
+
});
|
|
114
118
|
|
|
115
|
-
|
|
116
|
-
console.
|
|
117
|
-
|
|
118
|
-
console.log(` FQN: ${service.serviceFqn}`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
119
|
+
connection.on('failed', (error) => {
|
|
120
|
+
console.error('❌ Connection failed permanently:', error);
|
|
121
|
+
});
|
|
121
122
|
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
// Establish the connection
|
|
124
|
+
await connection.connect();
|
|
125
|
+
|
|
126
|
+
// Messages sent during disconnection are automatically queued
|
|
127
|
+
channel.send('This will be queued if disconnected');
|
|
128
|
+
|
|
129
|
+
// Later: close the connection
|
|
130
|
+
await connection.close();
|
|
126
131
|
```
|
|
127
132
|
|
|
133
|
+
## Core Concepts
|
|
134
|
+
|
|
135
|
+
### DurableConnection
|
|
136
|
+
|
|
137
|
+
Manages WebRTC peer lifecycle with automatic reconnection:
|
|
138
|
+
- Automatically reconnects when connection drops
|
|
139
|
+
- Exponential backoff with jitter (1s → 2s → 4s → 8s → ... max 30s)
|
|
140
|
+
- Configurable max retry attempts (default: 10)
|
|
141
|
+
- Manages multiple DurableChannel instances
|
|
142
|
+
|
|
143
|
+
### DurableChannel
|
|
144
|
+
|
|
145
|
+
Wraps RTCDataChannel with message queuing:
|
|
146
|
+
- Queues messages during disconnection
|
|
147
|
+
- Flushes queue on reconnection
|
|
148
|
+
- Configurable queue size and message age limits
|
|
149
|
+
- RTCDataChannel-compatible API with event emitters
|
|
150
|
+
|
|
151
|
+
### DurableService
|
|
152
|
+
|
|
153
|
+
Server-side service with TTL auto-refresh:
|
|
154
|
+
- Automatically republishes service before TTL expires
|
|
155
|
+
- Creates DurableConnection for each incoming peer
|
|
156
|
+
- Manages connection pool for multiple simultaneous connections
|
|
157
|
+
|
|
128
158
|
## API Reference
|
|
129
159
|
|
|
130
160
|
### Main Client
|
|
@@ -161,39 +191,12 @@ const check = await client.usernames.checkUsername('alice');
|
|
|
161
191
|
const claim = await client.usernames.claimUsername('alice');
|
|
162
192
|
// { username, publicKey, privateKey, claimedAt, expiresAt }
|
|
163
193
|
|
|
164
|
-
// Claim with existing keypair
|
|
165
|
-
const keypair = await client.usernames.generateKeypair();
|
|
166
|
-
const claim2 = await client.usernames.claimUsername('bob', keypair);
|
|
167
|
-
|
|
168
194
|
// Save keypair to localStorage
|
|
169
|
-
client.usernames.saveKeypairToStorage('alice', publicKey, privateKey);
|
|
195
|
+
client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);
|
|
170
196
|
|
|
171
197
|
// Load keypair from localStorage
|
|
172
|
-
const
|
|
198
|
+
const keypair = client.usernames.loadKeypairFromStorage('alice');
|
|
173
199
|
// { publicKey, privateKey } | null
|
|
174
|
-
|
|
175
|
-
// Export keypair for backup
|
|
176
|
-
const exported = client.usernames.exportKeypair('alice');
|
|
177
|
-
// { username, publicKey, privateKey }
|
|
178
|
-
|
|
179
|
-
// Import keypair from backup
|
|
180
|
-
client.usernames.importKeypair({ username: 'alice', publicKey, privateKey });
|
|
181
|
-
|
|
182
|
-
// Low-level: Generate keypair
|
|
183
|
-
const { publicKey, privateKey } = await client.usernames.generateKeypair();
|
|
184
|
-
|
|
185
|
-
// Low-level: Sign message
|
|
186
|
-
const signature = await client.usernames.signMessage(
|
|
187
|
-
'claim:alice:1234567890',
|
|
188
|
-
privateKey
|
|
189
|
-
);
|
|
190
|
-
|
|
191
|
-
// Low-level: Verify signature
|
|
192
|
-
const valid = await client.usernames.verifySignature(
|
|
193
|
-
'claim:alice:1234567890',
|
|
194
|
-
signature,
|
|
195
|
-
publicKey
|
|
196
|
-
);
|
|
197
200
|
```
|
|
198
201
|
|
|
199
202
|
**Username Rules:**
|
|
@@ -203,446 +206,413 @@ const valid = await client.usernames.verifySignature(
|
|
|
203
206
|
- Validity: 365 days from claim/last use
|
|
204
207
|
- Ownership: Secured by Ed25519 public key
|
|
205
208
|
|
|
206
|
-
###
|
|
209
|
+
### Durable Service API
|
|
207
210
|
|
|
208
211
|
```typescript
|
|
209
|
-
//
|
|
210
|
-
const service = await client.
|
|
212
|
+
// Expose a durable service
|
|
213
|
+
const service = await client.exposeService({
|
|
211
214
|
username: 'alice',
|
|
212
215
|
privateKey: keypair.privateKey,
|
|
213
|
-
serviceFqn: '
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
216
|
+
serviceFqn: 'chat@1.0.0',
|
|
217
|
+
|
|
218
|
+
// Service options
|
|
219
|
+
isPublic: true, // optional, default: false
|
|
220
|
+
metadata: { version: '1.0' }, // optional
|
|
221
|
+
ttl: 300000, // optional, default: 5 minutes
|
|
222
|
+
ttlRefreshMargin: 0.2, // optional, refresh at 80% of TTL
|
|
223
|
+
|
|
224
|
+
// Connection pooling
|
|
225
|
+
poolSize: 10, // optional, default: 1
|
|
226
|
+
pollingInterval: 2000, // optional, default: 2000ms
|
|
227
|
+
|
|
228
|
+
// Connection options (applied to incoming connections)
|
|
229
|
+
maxReconnectAttempts: 10, // optional, default: 10
|
|
230
|
+
reconnectBackoffBase: 1000, // optional, default: 1000ms
|
|
231
|
+
reconnectBackoffMax: 30000, // optional, default: 30000ms
|
|
232
|
+
reconnectJitter: 0.2, // optional, default: 0.2 (±20%)
|
|
233
|
+
connectionTimeout: 30000, // optional, default: 30000ms
|
|
234
|
+
|
|
235
|
+
// Message queuing
|
|
236
|
+
maxQueueSize: 1000, // optional, default: 1000
|
|
237
|
+
maxMessageAge: 60000, // optional, default: 60000ms (1 minute)
|
|
238
|
+
|
|
239
|
+
// WebRTC configuration
|
|
240
|
+
rtcConfig: {
|
|
241
|
+
iceServers: [
|
|
242
|
+
{ urls: 'stun:stun.l.google.com:19302' }
|
|
243
|
+
]
|
|
244
|
+
},
|
|
223
245
|
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
channel.onmessage = (e) => {
|
|
232
|
-
console.log('Received:', e.data);
|
|
233
|
-
channel.send(`Echo: ${e.data}`);
|
|
234
|
-
};
|
|
246
|
+
// Connection handler
|
|
247
|
+
handler: (channel, connectionId) => {
|
|
248
|
+
// Handle incoming connection
|
|
249
|
+
channel.on('message', (data) => {
|
|
250
|
+
console.log('Received:', data);
|
|
251
|
+
channel.send(`Echo: ${data}`);
|
|
252
|
+
});
|
|
235
253
|
}
|
|
236
254
|
});
|
|
237
255
|
|
|
238
|
-
//
|
|
239
|
-
await
|
|
256
|
+
// Start the service
|
|
257
|
+
const info = await service.start();
|
|
258
|
+
// { serviceId: '...', uuid: '...', expiresAt: 1234567890 }
|
|
240
259
|
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
260
|
+
// Get active connections
|
|
261
|
+
const connections = service.getActiveConnections();
|
|
262
|
+
// ['conn-123', 'conn-456']
|
|
244
263
|
|
|
245
|
-
|
|
264
|
+
// Get service info
|
|
265
|
+
const serviceInfo = service.getServiceInfo();
|
|
266
|
+
// { serviceId: '...', uuid: '...', expiresAt: 1234567890 } | null
|
|
246
267
|
|
|
247
|
-
|
|
268
|
+
// Stop the service
|
|
269
|
+
await service.stop();
|
|
270
|
+
```
|
|
248
271
|
|
|
272
|
+
**Service Events:**
|
|
249
273
|
```typescript
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
privateKey: keypair.privateKey,
|
|
254
|
-
serviceFqn: 'com.example.chat@1.0.0',
|
|
255
|
-
isPublic: true,
|
|
256
|
-
poolSize: 5, // Maintain 5 simultaneous open offers
|
|
257
|
-
pollingInterval: 2000, // Optional: polling interval in ms (default: 2000)
|
|
258
|
-
handler: (channel, peer, connectionId) => {
|
|
259
|
-
console.log(`📡 New connection: ${connectionId}`);
|
|
274
|
+
service.on('published', (serviceId, uuid) => {
|
|
275
|
+
console.log(`Service published: ${uuid}`);
|
|
276
|
+
});
|
|
260
277
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
};
|
|
278
|
+
service.on('connection', (connectionId) => {
|
|
279
|
+
console.log(`New connection: ${connectionId}`);
|
|
280
|
+
});
|
|
265
281
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
};
|
|
269
|
-
},
|
|
270
|
-
onPoolStatus: (status) => {
|
|
271
|
-
console.log('Pool status:', {
|
|
272
|
-
activeOffers: status.activeOffers,
|
|
273
|
-
activeConnections: status.activeConnections,
|
|
274
|
-
totalHandled: status.totalConnectionsHandled
|
|
275
|
-
});
|
|
276
|
-
},
|
|
277
|
-
onError: (error, context) => {
|
|
278
|
-
console.error(`Pool error (${context}):`, error);
|
|
279
|
-
}
|
|
282
|
+
service.on('disconnection', (connectionId) => {
|
|
283
|
+
console.log(`Connection closed: ${connectionId}`);
|
|
280
284
|
});
|
|
281
285
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
console.log(`Active connections: ${status.activeConnections}`);
|
|
286
|
+
service.on('ttl-refreshed', (expiresAt) => {
|
|
287
|
+
console.log(`TTL refreshed, expires at: ${new Date(expiresAt)}`);
|
|
288
|
+
});
|
|
286
289
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
+
service.on('error', (error, context) => {
|
|
291
|
+
console.error(`Service error (${context}):`, error);
|
|
292
|
+
});
|
|
290
293
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
3. Polling checks for answers every `pollingInterval` milliseconds (default: 2000ms)
|
|
295
|
-
4. Each connection gets a unique `connectionId` passed to the handler
|
|
296
|
-
5. No limit on total concurrent connections - only pool size (open offers) is controlled
|
|
297
|
-
|
|
298
|
-
**Use Cases:**
|
|
299
|
-
- Chat servers handling multiple clients
|
|
300
|
-
- File sharing services with concurrent downloads
|
|
301
|
-
- Multiplayer game lobbies
|
|
302
|
-
- Collaborative editing sessions
|
|
303
|
-
- Any service that needs to accept multiple simultaneous connections
|
|
304
|
-
|
|
305
|
-
**Pool Status Interface:**
|
|
306
|
-
```typescript
|
|
307
|
-
interface PoolStatus {
|
|
308
|
-
activeOffers: number; // Current number of open offers
|
|
309
|
-
activeConnections: number; // Current number of connected peers
|
|
310
|
-
totalConnectionsHandled: number; // Total connections since start
|
|
311
|
-
failedOfferCreations: number; // Failed offer creation attempts
|
|
312
|
-
}
|
|
294
|
+
service.on('closed', () => {
|
|
295
|
+
console.log('Service stopped');
|
|
296
|
+
});
|
|
313
297
|
```
|
|
314
298
|
|
|
315
|
-
|
|
299
|
+
### Durable Connection API
|
|
300
|
+
|
|
316
301
|
```typescript
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
302
|
+
// Connect by username and service FQN
|
|
303
|
+
const connection = await client.connect('alice', 'chat@1.0.0', {
|
|
304
|
+
// Connection options
|
|
305
|
+
maxReconnectAttempts: 10, // optional, default: 10
|
|
306
|
+
reconnectBackoffBase: 1000, // optional, default: 1000ms
|
|
307
|
+
reconnectBackoffMax: 30000, // optional, default: 30000ms
|
|
308
|
+
reconnectJitter: 0.2, // optional, default: 0.2 (±20%)
|
|
309
|
+
connectionTimeout: 30000, // optional, default: 30000ms
|
|
310
|
+
|
|
311
|
+
// Message queuing
|
|
312
|
+
maxQueueSize: 1000, // optional, default: 1000
|
|
313
|
+
maxMessageAge: 60000, // optional, default: 60000ms
|
|
314
|
+
|
|
315
|
+
// WebRTC configuration
|
|
316
|
+
rtcConfig: {
|
|
317
|
+
iceServers: [
|
|
318
|
+
{ urls: 'stun:stun.l.google.com:19302' }
|
|
319
|
+
]
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
322
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
- Examples: `com.example.chat@1.0.0`, `io.github.alice.notes@0.1.0-beta`
|
|
323
|
+
// Connect by UUID
|
|
324
|
+
const connection2 = await client.connectByUuid('service-uuid-here', {
|
|
325
|
+
maxReconnectAttempts: 5
|
|
326
|
+
});
|
|
328
327
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
328
|
+
// Create channels before connecting
|
|
329
|
+
const channel = connection.createChannel('main');
|
|
330
|
+
const fileChannel = connection.createChannel('files', {
|
|
331
|
+
ordered: false,
|
|
332
|
+
maxRetransmits: 3
|
|
333
|
+
});
|
|
334
334
|
|
|
335
|
-
|
|
335
|
+
// Get existing channel
|
|
336
|
+
const existingChannel = connection.getChannel('main');
|
|
336
337
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
// {
|
|
341
|
-
// username: 'alice',
|
|
342
|
-
// services: [
|
|
343
|
-
// { uuid: 'abc123', isPublic: false },
|
|
344
|
-
// { uuid: 'def456', isPublic: true, serviceFqn: '...', metadata: {...} }
|
|
345
|
-
// ]
|
|
346
|
-
// }
|
|
347
|
-
|
|
348
|
-
// Query service by FQN
|
|
349
|
-
const query = await client.discovery.queryService('alice', 'com.example.chat@1.0.0');
|
|
350
|
-
// { uuid: 'abc123', allowed: true }
|
|
351
|
-
|
|
352
|
-
// Get service details by UUID
|
|
353
|
-
const details = await client.discovery.getServiceDetails('abc123');
|
|
354
|
-
// { serviceId, username, serviceFqn, offerId, sdp, isPublic, metadata, ... }
|
|
355
|
-
|
|
356
|
-
// Connect to service by UUID
|
|
357
|
-
const peer = await client.discovery.connectToService('abc123', {
|
|
358
|
-
rtcConfig: { ... }, // optional
|
|
359
|
-
onConnected: () => { ... }, // optional
|
|
360
|
-
onData: (data) => { ... } // optional
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
// Connect by username + FQN (convenience method)
|
|
364
|
-
const { peer, channel } = await client.discovery.connect(
|
|
365
|
-
'alice',
|
|
366
|
-
'com.example.chat@1.0.0',
|
|
367
|
-
{ rtcConfig: { ... } } // optional
|
|
368
|
-
);
|
|
369
|
-
|
|
370
|
-
// Connect by UUID with channel
|
|
371
|
-
const { peer, channel } = await client.discovery.connectByUuid('abc123');
|
|
372
|
-
```
|
|
338
|
+
// Check connection state
|
|
339
|
+
const state = connection.getState();
|
|
340
|
+
// 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'failed' | 'closed'
|
|
373
341
|
|
|
374
|
-
|
|
342
|
+
const isConnected = connection.isConnected();
|
|
375
343
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const peer = client.createPeer({
|
|
379
|
-
iceServers: [
|
|
380
|
-
{ urls: 'stun:stun.l.google.com:19302' },
|
|
381
|
-
{
|
|
382
|
-
urls: 'turn:turn.example.com:3478',
|
|
383
|
-
username: 'user',
|
|
384
|
-
credential: 'pass'
|
|
385
|
-
}
|
|
386
|
-
],
|
|
387
|
-
iceTransportPolicy: 'relay' // optional: force TURN relay
|
|
388
|
-
});
|
|
344
|
+
// Connect
|
|
345
|
+
await connection.connect();
|
|
389
346
|
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
});
|
|
347
|
+
// Close connection
|
|
348
|
+
await connection.close();
|
|
349
|
+
```
|
|
394
350
|
|
|
395
|
-
|
|
396
|
-
|
|
351
|
+
**Connection Events:**
|
|
352
|
+
```typescript
|
|
353
|
+
connection.on('state', (newState, previousState) => {
|
|
354
|
+
console.log(`State: ${previousState} → ${newState}`);
|
|
397
355
|
});
|
|
398
356
|
|
|
399
|
-
|
|
400
|
-
console.log('
|
|
357
|
+
connection.on('connected', () => {
|
|
358
|
+
console.log('Connected');
|
|
401
359
|
});
|
|
402
360
|
|
|
403
|
-
|
|
404
|
-
console.
|
|
361
|
+
connection.on('reconnecting', (attempt, maxAttempts, delay) => {
|
|
362
|
+
console.log(`Reconnecting (${attempt}/${maxAttempts}) in ${delay}ms`);
|
|
405
363
|
});
|
|
406
364
|
|
|
407
|
-
|
|
408
|
-
console.log('
|
|
365
|
+
connection.on('disconnected', () => {
|
|
366
|
+
console.log('Disconnected');
|
|
409
367
|
});
|
|
410
368
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const stream = event.streams[0];
|
|
414
|
-
videoElement.srcObject = stream;
|
|
369
|
+
connection.on('failed', (error, permanent) => {
|
|
370
|
+
console.error('Connection failed:', error, 'Permanent:', permanent);
|
|
415
371
|
});
|
|
416
372
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
ttl: 300000, // optional
|
|
420
|
-
timeouts: { // optional
|
|
421
|
-
iceGathering: 10000,
|
|
422
|
-
waitingForAnswer: 30000,
|
|
423
|
-
creatingAnswer: 10000,
|
|
424
|
-
iceConnection: 30000
|
|
425
|
-
}
|
|
373
|
+
connection.on('closed', () => {
|
|
374
|
+
console.log('Connection closed');
|
|
426
375
|
});
|
|
376
|
+
```
|
|
427
377
|
|
|
428
|
-
|
|
429
|
-
await peer.answer(offerId, sdp);
|
|
378
|
+
### Durable Channel API
|
|
430
379
|
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
380
|
+
```typescript
|
|
381
|
+
const channel = connection.createChannel('chat', {
|
|
382
|
+
ordered: true, // optional, default: true
|
|
383
|
+
maxRetransmits: undefined // optional, for unordered channels
|
|
435
384
|
});
|
|
436
385
|
|
|
437
|
-
//
|
|
438
|
-
|
|
386
|
+
// Send data (queued if disconnected)
|
|
387
|
+
channel.send('Hello!');
|
|
388
|
+
channel.send(new ArrayBuffer(1024));
|
|
389
|
+
channel.send(new Blob(['data']));
|
|
439
390
|
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
peer.offerId; // string | undefined
|
|
444
|
-
peer.role; // 'offerer' | 'answerer' | undefined
|
|
445
|
-
```
|
|
391
|
+
// Check state
|
|
392
|
+
const state = channel.readyState;
|
|
393
|
+
// 'connecting' | 'open' | 'closing' | 'closed'
|
|
446
394
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
### Service Publisher (Offerer)
|
|
450
|
-
1. **idle** - Initial state
|
|
451
|
-
2. **creating-offer** - Creating WebRTC offer
|
|
452
|
-
3. **waiting-for-answer** - Polling for answer from peer
|
|
453
|
-
4. **exchanging-ice** - Exchanging ICE candidates
|
|
454
|
-
5. **connected** - Successfully connected
|
|
455
|
-
6. **failed** - Connection failed
|
|
456
|
-
7. **closed** - Connection closed
|
|
457
|
-
|
|
458
|
-
### Service Consumer (Answerer)
|
|
459
|
-
1. **idle** - Initial state
|
|
460
|
-
2. **answering** - Creating WebRTC answer
|
|
461
|
-
3. **exchanging-ice** - Exchanging ICE candidates
|
|
462
|
-
4. **connected** - Successfully connected
|
|
463
|
-
5. **failed** - Connection failed
|
|
464
|
-
6. **closed** - Connection closed
|
|
395
|
+
// Get buffered amount
|
|
396
|
+
const buffered = channel.bufferedAmount;
|
|
465
397
|
|
|
466
|
-
|
|
398
|
+
// Set buffered amount low threshold
|
|
399
|
+
channel.bufferedAmountLowThreshold = 16 * 1024; // 16KB
|
|
467
400
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
### Node.js 18+
|
|
472
|
-
Native fetch is available, but you need WebRTC polyfills:
|
|
401
|
+
// Get queue size (for debugging)
|
|
402
|
+
const queueSize = channel.getQueueSize();
|
|
473
403
|
|
|
474
|
-
|
|
475
|
-
|
|
404
|
+
// Close channel
|
|
405
|
+
channel.close();
|
|
476
406
|
```
|
|
477
407
|
|
|
408
|
+
**Channel Events:**
|
|
478
409
|
```typescript
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
const client = new Rondevu({
|
|
483
|
-
baseUrl: 'https://api.ronde.vu',
|
|
484
|
-
RTCPeerConnection,
|
|
485
|
-
RTCSessionDescription,
|
|
486
|
-
RTCIceCandidate
|
|
410
|
+
channel.on('open', () => {
|
|
411
|
+
console.log('Channel open');
|
|
487
412
|
});
|
|
488
|
-
```
|
|
489
|
-
|
|
490
|
-
### Node.js < 18
|
|
491
|
-
Install both fetch and WebRTC polyfills:
|
|
492
413
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
414
|
+
channel.on('message', (data) => {
|
|
415
|
+
console.log('Received:', data);
|
|
416
|
+
});
|
|
496
417
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc';
|
|
418
|
+
channel.on('error', (error) => {
|
|
419
|
+
console.error('Channel error:', error);
|
|
420
|
+
});
|
|
501
421
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
fetch: fetch as any,
|
|
505
|
-
RTCPeerConnection,
|
|
506
|
-
RTCSessionDescription,
|
|
507
|
-
RTCIceCandidate
|
|
422
|
+
channel.on('close', () => {
|
|
423
|
+
console.log('Channel closed');
|
|
508
424
|
});
|
|
509
|
-
```
|
|
510
425
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
426
|
+
channel.on('bufferedAmountLow', () => {
|
|
427
|
+
console.log('Buffer drained, safe to send more');
|
|
428
|
+
});
|
|
514
429
|
|
|
515
|
-
|
|
516
|
-
|
|
430
|
+
channel.on('queueOverflow', (droppedCount) => {
|
|
431
|
+
console.warn(`Queue overflow: ${droppedCount} messages dropped`);
|
|
517
432
|
});
|
|
518
433
|
```
|
|
519
434
|
|
|
520
|
-
|
|
521
|
-
|
|
435
|
+
## Configuration Options
|
|
436
|
+
|
|
437
|
+
### Connection Configuration
|
|
522
438
|
|
|
523
|
-
### Cloudflare Workers
|
|
524
439
|
```typescript
|
|
525
|
-
|
|
440
|
+
interface DurableConnectionConfig {
|
|
441
|
+
maxReconnectAttempts?: number; // default: 10
|
|
442
|
+
reconnectBackoffBase?: number; // default: 1000 (1 second)
|
|
443
|
+
reconnectBackoffMax?: number; // default: 30000 (30 seconds)
|
|
444
|
+
reconnectJitter?: number; // default: 0.2 (±20%)
|
|
445
|
+
connectionTimeout?: number; // default: 30000 (30 seconds)
|
|
446
|
+
maxQueueSize?: number; // default: 1000 messages
|
|
447
|
+
maxMessageAge?: number; // default: 60000 (1 minute)
|
|
448
|
+
rtcConfig?: RTCConfiguration;
|
|
449
|
+
}
|
|
450
|
+
```
|
|
526
451
|
|
|
527
|
-
|
|
528
|
-
async fetch(request: Request, env: Env) {
|
|
529
|
-
const client = new Rondevu({
|
|
530
|
-
baseUrl: 'https://api.ronde.vu'
|
|
531
|
-
});
|
|
452
|
+
### Service Configuration
|
|
532
453
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
454
|
+
```typescript
|
|
455
|
+
interface DurableServiceConfig extends DurableConnectionConfig {
|
|
456
|
+
username: string;
|
|
457
|
+
privateKey: string;
|
|
458
|
+
serviceFqn: string;
|
|
459
|
+
isPublic?: boolean; // default: false
|
|
460
|
+
metadata?: Record<string, any>;
|
|
461
|
+
ttl?: number; // default: 300000 (5 minutes)
|
|
462
|
+
ttlRefreshMargin?: number; // default: 0.2 (refresh at 80%)
|
|
463
|
+
poolSize?: number; // default: 1
|
|
464
|
+
pollingInterval?: number; // default: 2000 (2 seconds)
|
|
465
|
+
}
|
|
537
466
|
```
|
|
538
467
|
|
|
539
468
|
## Examples
|
|
540
469
|
|
|
541
|
-
###
|
|
470
|
+
### Chat Application
|
|
542
471
|
|
|
543
472
|
```typescript
|
|
544
|
-
//
|
|
545
|
-
const
|
|
546
|
-
await
|
|
547
|
-
|
|
548
|
-
const claim = await client1.usernames.claimUsername('alice');
|
|
549
|
-
client1.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);
|
|
473
|
+
// Server
|
|
474
|
+
const client = new Rondevu();
|
|
475
|
+
await client.register();
|
|
550
476
|
|
|
551
|
-
const
|
|
477
|
+
const claim = await client.usernames.claimUsername('alice');
|
|
478
|
+
client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);
|
|
479
|
+
const keypair = client.usernames.loadKeypairFromStorage('alice');
|
|
552
480
|
|
|
553
|
-
await
|
|
481
|
+
const service = await client.exposeService({
|
|
554
482
|
username: 'alice',
|
|
555
483
|
privateKey: keypair.privateKey,
|
|
556
|
-
serviceFqn: '
|
|
484
|
+
serviceFqn: 'chat@1.0.0',
|
|
557
485
|
isPublic: true,
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
486
|
+
poolSize: 50, // Handle 50 concurrent users
|
|
487
|
+
handler: (channel, connectionId) => {
|
|
488
|
+
console.log(`User ${connectionId} joined`);
|
|
489
|
+
|
|
490
|
+
channel.on('message', (data) => {
|
|
491
|
+
console.log(`[${connectionId}]: ${data}`);
|
|
492
|
+
// Broadcast to all users (implement your broadcast logic)
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
channel.on('close', () => {
|
|
496
|
+
console.log(`User ${connectionId} left`);
|
|
497
|
+
});
|
|
563
498
|
}
|
|
564
499
|
});
|
|
565
500
|
|
|
566
|
-
|
|
501
|
+
await service.start();
|
|
502
|
+
|
|
503
|
+
// Client
|
|
567
504
|
const client2 = new Rondevu();
|
|
568
505
|
await client2.register();
|
|
569
506
|
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
'com.example.echo@1.0.0'
|
|
573
|
-
);
|
|
507
|
+
const connection = await client2.connect('alice', 'chat@1.0.0');
|
|
508
|
+
const channel = connection.createChannel('chat');
|
|
574
509
|
|
|
575
|
-
channel.
|
|
576
|
-
|
|
510
|
+
channel.on('message', (data) => {
|
|
511
|
+
console.log('Message:', data);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
await connection.connect();
|
|
515
|
+
channel.send('Hello everyone!');
|
|
577
516
|
```
|
|
578
517
|
|
|
579
|
-
### File Transfer
|
|
518
|
+
### File Transfer with Progress
|
|
580
519
|
|
|
581
520
|
```typescript
|
|
582
|
-
//
|
|
583
|
-
await client.
|
|
521
|
+
// Server
|
|
522
|
+
const service = await client.exposeService({
|
|
584
523
|
username: 'alice',
|
|
585
524
|
privateKey: keypair.privateKey,
|
|
586
|
-
serviceFqn: '
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
525
|
+
serviceFqn: 'files@1.0.0',
|
|
526
|
+
handler: (channel, connectionId) => {
|
|
527
|
+
channel.on('message', async (data) => {
|
|
528
|
+
const request = JSON.parse(data);
|
|
529
|
+
|
|
530
|
+
if (request.action === 'download') {
|
|
531
|
+
const file = await fs.readFile(request.path);
|
|
532
|
+
const chunkSize = 16 * 1024; // 16KB chunks
|
|
533
|
+
|
|
534
|
+
for (let i = 0; i < file.byteLength; i += chunkSize) {
|
|
535
|
+
const chunk = file.slice(i, i + chunkSize);
|
|
536
|
+
channel.send(chunk);
|
|
537
|
+
|
|
538
|
+
// Wait for buffer to drain if needed
|
|
539
|
+
while (channel.bufferedAmount > 16 * 1024 * 1024) { // 16MB
|
|
540
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
channel.send(JSON.stringify({ done: true }));
|
|
596
545
|
}
|
|
597
|
-
};
|
|
546
|
+
});
|
|
598
547
|
}
|
|
599
548
|
});
|
|
600
549
|
|
|
601
|
-
|
|
602
|
-
const { peer, channel } = await client.discovery.connect(
|
|
603
|
-
'alice',
|
|
604
|
-
'com.example.files@1.0.0'
|
|
605
|
-
);
|
|
550
|
+
await service.start();
|
|
606
551
|
|
|
607
|
-
|
|
552
|
+
// Client
|
|
553
|
+
const connection = await client.connect('alice', 'files@1.0.0');
|
|
554
|
+
const channel = connection.createChannel('files');
|
|
608
555
|
|
|
609
|
-
|
|
610
|
-
channel.
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
556
|
+
const chunks = [];
|
|
557
|
+
channel.on('message', (data) => {
|
|
558
|
+
if (typeof data === 'string') {
|
|
559
|
+
const msg = JSON.parse(data);
|
|
560
|
+
if (msg.done) {
|
|
561
|
+
const blob = new Blob(chunks);
|
|
562
|
+
console.log('Download complete:', blob.size, 'bytes');
|
|
563
|
+
}
|
|
564
|
+
} else {
|
|
565
|
+
chunks.push(data);
|
|
566
|
+
console.log('Progress:', chunks.length * 16 * 1024, 'bytes');
|
|
615
567
|
}
|
|
616
|
-
};
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
await connection.connect();
|
|
571
|
+
channel.send(JSON.stringify({ action: 'download', path: '/file.zip' }));
|
|
617
572
|
```
|
|
618
573
|
|
|
619
|
-
|
|
574
|
+
## Platform-Specific Setup
|
|
620
575
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
|
576
|
+
### Modern Browsers
|
|
577
|
+
Works out of the box - no additional setup needed.
|
|
624
578
|
|
|
625
|
-
|
|
626
|
-
|
|
579
|
+
### Node.js 18+
|
|
580
|
+
Native fetch is available, but you need WebRTC polyfills:
|
|
627
581
|
|
|
628
|
-
|
|
582
|
+
```bash
|
|
583
|
+
npm install wrtc
|
|
584
|
+
```
|
|
629
585
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
586
|
+
```typescript
|
|
587
|
+
import { Rondevu } from '@xtr-dev/rondevu-client';
|
|
588
|
+
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc';
|
|
589
|
+
|
|
590
|
+
const client = new Rondevu({
|
|
591
|
+
baseUrl: 'https://api.ronde.vu',
|
|
592
|
+
RTCPeerConnection,
|
|
593
|
+
RTCSessionDescription,
|
|
594
|
+
RTCIceCandidate
|
|
635
595
|
});
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### Node.js < 18
|
|
599
|
+
Install both fetch and WebRTC polyfills:
|
|
636
600
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
'com.example.videochat@1.0.0'
|
|
641
|
-
);
|
|
601
|
+
```bash
|
|
602
|
+
npm install node-fetch wrtc
|
|
603
|
+
```
|
|
642
604
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
605
|
+
```typescript
|
|
606
|
+
import { Rondevu } from '@xtr-dev/rondevu-client';
|
|
607
|
+
import fetch from 'node-fetch';
|
|
608
|
+
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc';
|
|
609
|
+
|
|
610
|
+
const client = new Rondevu({
|
|
611
|
+
baseUrl: 'https://api.ronde.vu',
|
|
612
|
+
fetch: fetch as any,
|
|
613
|
+
RTCPeerConnection,
|
|
614
|
+
RTCSessionDescription,
|
|
615
|
+
RTCIceCandidate
|
|
646
616
|
});
|
|
647
617
|
```
|
|
648
618
|
|
|
@@ -652,41 +622,37 @@ All types are exported:
|
|
|
652
622
|
|
|
653
623
|
```typescript
|
|
654
624
|
import type {
|
|
625
|
+
// Client types
|
|
655
626
|
Credentials,
|
|
656
627
|
RondevuOptions,
|
|
657
628
|
|
|
658
629
|
// Username types
|
|
659
630
|
UsernameCheckResult,
|
|
660
631
|
UsernameClaimResult,
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
// Peer types
|
|
676
|
-
PeerOptions,
|
|
677
|
-
PeerEvents,
|
|
678
|
-
PeerTimeouts
|
|
632
|
+
|
|
633
|
+
// Durable connection types
|
|
634
|
+
DurableConnectionState,
|
|
635
|
+
DurableChannelState,
|
|
636
|
+
DurableConnectionConfig,
|
|
637
|
+
DurableChannelConfig,
|
|
638
|
+
DurableServiceConfig,
|
|
639
|
+
QueuedMessage,
|
|
640
|
+
DurableConnectionEvents,
|
|
641
|
+
DurableChannelEvents,
|
|
642
|
+
DurableServiceEvents,
|
|
643
|
+
ConnectionInfo,
|
|
644
|
+
ServiceInfo
|
|
679
645
|
} from '@xtr-dev/rondevu-client';
|
|
680
646
|
```
|
|
681
647
|
|
|
682
|
-
## Migration from
|
|
648
|
+
## Migration from v0.8.x
|
|
683
649
|
|
|
684
|
-
|
|
650
|
+
v0.9.0 is a **breaking change** that replaces the low-level APIs with high-level durable connections. See [MIGRATION.md](./MIGRATION.md) for detailed migration guide.
|
|
685
651
|
|
|
686
652
|
**Key Changes:**
|
|
687
|
-
- ❌ Removed: `
|
|
688
|
-
- ✅ Added: `
|
|
689
|
-
- ✅ Changed: Focus on
|
|
653
|
+
- ❌ Removed: `client.services.*`, `client.discovery.*`, `client.createPeer()` (low-level APIs)
|
|
654
|
+
- ✅ Added: `client.exposeService()`, `client.connect()`, `client.connectByUuid()` (durable APIs)
|
|
655
|
+
- ✅ Changed: Focus on durable connections with automatic reconnection and message queuing
|
|
690
656
|
|
|
691
657
|
## License
|
|
692
658
|
|