@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
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DurableConnection - WebRTC connection with automatic reconnection
|
|
3
|
+
*
|
|
4
|
+
* Manages the WebRTC peer lifecycle and automatically reconnects on
|
|
5
|
+
* connection drops with exponential backoff.
|
|
6
|
+
*/
|
|
7
|
+
import { EventEmitter } from '../event-emitter.js';
|
|
8
|
+
import RondevuPeer from '../peer/index.js';
|
|
9
|
+
import { DurableChannel } from './channel.js';
|
|
10
|
+
import { createReconnectionScheduler } from './reconnection.js';
|
|
11
|
+
import { DurableConnectionState } from './types.js';
|
|
12
|
+
/**
|
|
13
|
+
* Default configuration for durable connections
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_CONFIG = {
|
|
16
|
+
maxReconnectAttempts: 10,
|
|
17
|
+
reconnectBackoffBase: 1000,
|
|
18
|
+
reconnectBackoffMax: 30000,
|
|
19
|
+
reconnectJitter: 0.2,
|
|
20
|
+
connectionTimeout: 30000,
|
|
21
|
+
maxQueueSize: 1000,
|
|
22
|
+
maxMessageAge: 60000,
|
|
23
|
+
rtcConfig: {
|
|
24
|
+
iceServers: [
|
|
25
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
26
|
+
{ urls: 'stun:stun1.l.google.com:19302' }
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Durable WebRTC connection that automatically reconnects
|
|
32
|
+
*
|
|
33
|
+
* The DurableConnection manages the lifecycle of a WebRTC peer connection
|
|
34
|
+
* and provides:
|
|
35
|
+
* - Automatic reconnection with exponential backoff
|
|
36
|
+
* - Multiple durable channels that survive reconnections
|
|
37
|
+
* - Configurable retry limits and timeouts
|
|
38
|
+
* - High-level connection state events
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* const connection = new DurableConnection(
|
|
43
|
+
* offersApi,
|
|
44
|
+
* { username: 'alice', serviceFqn: 'chat@1.0.0' },
|
|
45
|
+
* { maxReconnectAttempts: 5 }
|
|
46
|
+
* );
|
|
47
|
+
*
|
|
48
|
+
* connection.on('connected', () => {
|
|
49
|
+
* console.log('Connected!');
|
|
50
|
+
* });
|
|
51
|
+
*
|
|
52
|
+
* connection.on('reconnecting', (attempt, max, delay) => {
|
|
53
|
+
* console.log(`Reconnecting... (${attempt}/${max}, retry in ${delay}ms)`);
|
|
54
|
+
* });
|
|
55
|
+
*
|
|
56
|
+
* const channel = connection.createChannel('chat');
|
|
57
|
+
* channel.on('message', (data) => {
|
|
58
|
+
* console.log('Received:', data);
|
|
59
|
+
* });
|
|
60
|
+
*
|
|
61
|
+
* await connection.connect();
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export class DurableConnection extends EventEmitter {
|
|
65
|
+
constructor(offersApi, connectionInfo, config) {
|
|
66
|
+
super();
|
|
67
|
+
this.offersApi = offersApi;
|
|
68
|
+
this.channels = new Map();
|
|
69
|
+
this.connectionId = `conn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
70
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
71
|
+
this.connectionInfo = connectionInfo;
|
|
72
|
+
this._state = DurableConnectionState.CONNECTING;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Current connection state
|
|
76
|
+
*/
|
|
77
|
+
getState() {
|
|
78
|
+
return this._state;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Check if connection is currently connected
|
|
82
|
+
*/
|
|
83
|
+
isConnected() {
|
|
84
|
+
return this._state === DurableConnectionState.CONNECTED;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Create a durable channel on this connection
|
|
88
|
+
*
|
|
89
|
+
* The channel will be created on the current peer connection if available,
|
|
90
|
+
* otherwise it will be created when the connection is established.
|
|
91
|
+
*
|
|
92
|
+
* @param label - Channel label
|
|
93
|
+
* @param options - RTCDataChannel init options
|
|
94
|
+
* @returns DurableChannel instance
|
|
95
|
+
*/
|
|
96
|
+
createChannel(label, options) {
|
|
97
|
+
// Check if channel already exists
|
|
98
|
+
if (this.channels.has(label)) {
|
|
99
|
+
throw new Error(`Channel with label '${label}' already exists`);
|
|
100
|
+
}
|
|
101
|
+
// Create durable channel
|
|
102
|
+
const durableChannel = new DurableChannel(label, {
|
|
103
|
+
maxQueueSize: this.config.maxQueueSize,
|
|
104
|
+
maxMessageAge: this.config.maxMessageAge,
|
|
105
|
+
ordered: options?.ordered ?? true,
|
|
106
|
+
maxRetransmits: options?.maxRetransmits
|
|
107
|
+
});
|
|
108
|
+
this.channels.set(label, durableChannel);
|
|
109
|
+
// If we have a current peer, attach the channel
|
|
110
|
+
if (this.currentPeer && this._state === DurableConnectionState.CONNECTED) {
|
|
111
|
+
this.createAndAttachChannel(durableChannel, options);
|
|
112
|
+
}
|
|
113
|
+
return durableChannel;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Get an existing channel by label
|
|
117
|
+
*/
|
|
118
|
+
getChannel(label) {
|
|
119
|
+
return this.channels.get(label);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Establish the initial connection
|
|
123
|
+
*
|
|
124
|
+
* @returns Promise that resolves when connected
|
|
125
|
+
*/
|
|
126
|
+
async connect() {
|
|
127
|
+
if (this._state !== DurableConnectionState.CONNECTING) {
|
|
128
|
+
throw new Error(`Cannot connect from state: ${this._state}`);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await this.establishConnection();
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
this._state = DurableConnectionState.DISCONNECTED;
|
|
135
|
+
await this.handleDisconnection();
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Close the connection gracefully
|
|
141
|
+
*/
|
|
142
|
+
async close() {
|
|
143
|
+
if (this._state === DurableConnectionState.CLOSED) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const previousState = this._state;
|
|
147
|
+
this._state = DurableConnectionState.CLOSED;
|
|
148
|
+
// Cancel any ongoing reconnection
|
|
149
|
+
if (this.reconnectionScheduler) {
|
|
150
|
+
this.reconnectionScheduler.cancel();
|
|
151
|
+
}
|
|
152
|
+
// Close all channels
|
|
153
|
+
for (const channel of this.channels.values()) {
|
|
154
|
+
channel.close();
|
|
155
|
+
}
|
|
156
|
+
// Close peer connection
|
|
157
|
+
if (this.currentPeer) {
|
|
158
|
+
await this.currentPeer.close();
|
|
159
|
+
this.currentPeer = undefined;
|
|
160
|
+
}
|
|
161
|
+
this.emit('state', this._state, previousState);
|
|
162
|
+
this.emit('closed');
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Establish a WebRTC connection
|
|
166
|
+
*/
|
|
167
|
+
async establishConnection() {
|
|
168
|
+
// Create new peer
|
|
169
|
+
const peer = new RondevuPeer(this.offersApi, this.config.rtcConfig);
|
|
170
|
+
this.currentPeer = peer;
|
|
171
|
+
// Setup peer event handlers
|
|
172
|
+
this.setupPeerHandlers(peer);
|
|
173
|
+
// Determine connection method based on connection info
|
|
174
|
+
if (this.connectionInfo.uuid) {
|
|
175
|
+
// Connect by UUID
|
|
176
|
+
await this.connectByUuid(peer, this.connectionInfo.uuid);
|
|
177
|
+
}
|
|
178
|
+
else if (this.connectionInfo.username && this.connectionInfo.serviceFqn) {
|
|
179
|
+
// Connect by username and service FQN
|
|
180
|
+
await this.connectByService(peer, this.connectionInfo.username, this.connectionInfo.serviceFqn);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
throw new Error('Invalid connection info: must provide either uuid or (username + serviceFqn)');
|
|
184
|
+
}
|
|
185
|
+
// Wait for connection with timeout
|
|
186
|
+
await this.waitForConnection(peer);
|
|
187
|
+
// Connection established
|
|
188
|
+
this.transitionToConnected();
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Connect to a service by UUID
|
|
192
|
+
*/
|
|
193
|
+
async connectByUuid(peer, uuid) {
|
|
194
|
+
// Get service details
|
|
195
|
+
const response = await fetch(`${this.offersApi['baseUrl']}/services/${uuid}`);
|
|
196
|
+
if (!response.ok) {
|
|
197
|
+
throw new Error(`Service not found: ${uuid}`);
|
|
198
|
+
}
|
|
199
|
+
const service = await response.json();
|
|
200
|
+
// Answer the offer
|
|
201
|
+
await peer.answer(service.offerId, service.sdp, {
|
|
202
|
+
secret: this.offersApi['credentials'].secret,
|
|
203
|
+
topics: []
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Connect to a service by username and service FQN
|
|
208
|
+
*/
|
|
209
|
+
async connectByService(peer, username, serviceFqn) {
|
|
210
|
+
// Query service to get UUID
|
|
211
|
+
const response = await fetch(`${this.offersApi['baseUrl']}/index/${username}/query`, {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: { 'Content-Type': 'application/json' },
|
|
214
|
+
body: JSON.stringify({ serviceFqn })
|
|
215
|
+
});
|
|
216
|
+
if (!response.ok) {
|
|
217
|
+
throw new Error(`Service not found: ${username}/${serviceFqn}`);
|
|
218
|
+
}
|
|
219
|
+
const { uuid } = await response.json();
|
|
220
|
+
// Connect by UUID
|
|
221
|
+
await this.connectByUuid(peer, uuid);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Wait for peer connection to establish
|
|
225
|
+
*/
|
|
226
|
+
async waitForConnection(peer) {
|
|
227
|
+
return new Promise((resolve, reject) => {
|
|
228
|
+
const timeout = setTimeout(() => {
|
|
229
|
+
reject(new Error('Connection timeout'));
|
|
230
|
+
}, this.config.connectionTimeout);
|
|
231
|
+
const onConnected = () => {
|
|
232
|
+
clearTimeout(timeout);
|
|
233
|
+
peer.off('connected', onConnected);
|
|
234
|
+
peer.off('failed', onFailed);
|
|
235
|
+
resolve();
|
|
236
|
+
};
|
|
237
|
+
const onFailed = (error) => {
|
|
238
|
+
clearTimeout(timeout);
|
|
239
|
+
peer.off('connected', onConnected);
|
|
240
|
+
peer.off('failed', onFailed);
|
|
241
|
+
reject(error);
|
|
242
|
+
};
|
|
243
|
+
peer.on('connected', onConnected);
|
|
244
|
+
peer.on('failed', onFailed);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Setup event handlers for peer
|
|
249
|
+
*/
|
|
250
|
+
setupPeerHandlers(peer) {
|
|
251
|
+
this.peerConnectedHandler = () => {
|
|
252
|
+
// Connection established - will be handled by waitForConnection
|
|
253
|
+
};
|
|
254
|
+
this.peerDisconnectedHandler = () => {
|
|
255
|
+
if (this._state !== DurableConnectionState.CLOSED) {
|
|
256
|
+
this.handleDisconnection();
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
this.peerFailedHandler = (error) => {
|
|
260
|
+
if (this._state !== DurableConnectionState.CLOSED) {
|
|
261
|
+
console.error('Peer connection failed:', error);
|
|
262
|
+
this.handleDisconnection();
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
this.peerDataChannelHandler = (channel) => {
|
|
266
|
+
// Find or create durable channel
|
|
267
|
+
let durableChannel = this.channels.get(channel.label);
|
|
268
|
+
if (!durableChannel) {
|
|
269
|
+
// Auto-create channel for incoming data channels
|
|
270
|
+
durableChannel = new DurableChannel(channel.label, {
|
|
271
|
+
maxQueueSize: this.config.maxQueueSize,
|
|
272
|
+
maxMessageAge: this.config.maxMessageAge
|
|
273
|
+
});
|
|
274
|
+
this.channels.set(channel.label, durableChannel);
|
|
275
|
+
}
|
|
276
|
+
// Attach the received channel
|
|
277
|
+
durableChannel.attachToChannel(channel);
|
|
278
|
+
};
|
|
279
|
+
peer.on('connected', this.peerConnectedHandler);
|
|
280
|
+
peer.on('disconnected', this.peerDisconnectedHandler);
|
|
281
|
+
peer.on('failed', this.peerFailedHandler);
|
|
282
|
+
peer.on('datachannel', this.peerDataChannelHandler);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Transition to connected state
|
|
286
|
+
*/
|
|
287
|
+
transitionToConnected() {
|
|
288
|
+
const previousState = this._state;
|
|
289
|
+
this._state = DurableConnectionState.CONNECTED;
|
|
290
|
+
// Reset reconnection scheduler if it exists
|
|
291
|
+
if (this.reconnectionScheduler) {
|
|
292
|
+
this.reconnectionScheduler.reset();
|
|
293
|
+
}
|
|
294
|
+
// Attach all channels to the new peer connection
|
|
295
|
+
for (const [label, channel] of this.channels) {
|
|
296
|
+
if (this.currentPeer) {
|
|
297
|
+
this.createAndAttachChannel(channel);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
this.emit('state', this._state, previousState);
|
|
301
|
+
this.emit('connected');
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Create underlying RTCDataChannel and attach to durable channel
|
|
305
|
+
*/
|
|
306
|
+
createAndAttachChannel(durableChannel, options) {
|
|
307
|
+
if (!this.currentPeer) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// Check if peer already has this channel (received via datachannel event)
|
|
311
|
+
// If not, create it
|
|
312
|
+
const senders = this.currentPeer.pc.getSenders?.() || [];
|
|
313
|
+
const existingChannel = Array.from(senders)
|
|
314
|
+
.map((sender) => sender.channel)
|
|
315
|
+
.find(ch => ch && ch.label === durableChannel.label);
|
|
316
|
+
if (existingChannel) {
|
|
317
|
+
durableChannel.attachToChannel(existingChannel);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
// Create new channel on peer
|
|
321
|
+
const rtcChannel = this.currentPeer.createDataChannel(durableChannel.label, options);
|
|
322
|
+
durableChannel.attachToChannel(rtcChannel);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Handle connection disconnection
|
|
327
|
+
*/
|
|
328
|
+
async handleDisconnection() {
|
|
329
|
+
if (this._state === DurableConnectionState.CLOSED ||
|
|
330
|
+
this._state === DurableConnectionState.FAILED) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const previousState = this._state;
|
|
334
|
+
this._state = DurableConnectionState.RECONNECTING;
|
|
335
|
+
this.emit('state', this._state, previousState);
|
|
336
|
+
this.emit('disconnected');
|
|
337
|
+
// Detach all channels (but keep them alive)
|
|
338
|
+
for (const channel of this.channels.values()) {
|
|
339
|
+
channel.detachFromChannel();
|
|
340
|
+
}
|
|
341
|
+
// Close old peer
|
|
342
|
+
if (this.currentPeer) {
|
|
343
|
+
await this.currentPeer.close();
|
|
344
|
+
this.currentPeer = undefined;
|
|
345
|
+
}
|
|
346
|
+
// Create or use existing reconnection scheduler
|
|
347
|
+
if (!this.reconnectionScheduler) {
|
|
348
|
+
this.reconnectionScheduler = createReconnectionScheduler({
|
|
349
|
+
maxAttempts: this.config.maxReconnectAttempts,
|
|
350
|
+
backoffBase: this.config.reconnectBackoffBase,
|
|
351
|
+
backoffMax: this.config.reconnectBackoffMax,
|
|
352
|
+
jitter: this.config.reconnectJitter,
|
|
353
|
+
onReconnect: async () => {
|
|
354
|
+
await this.establishConnection();
|
|
355
|
+
},
|
|
356
|
+
onMaxAttemptsExceeded: (error) => {
|
|
357
|
+
const prevState = this._state;
|
|
358
|
+
this._state = DurableConnectionState.FAILED;
|
|
359
|
+
this.emit('state', this._state, prevState);
|
|
360
|
+
this.emit('failed', error, true);
|
|
361
|
+
},
|
|
362
|
+
onBeforeAttempt: (attempt, max, delay) => {
|
|
363
|
+
this.emit('reconnecting', attempt, max, delay);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
// Schedule reconnection
|
|
368
|
+
this.reconnectionScheduler.schedule();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconnection utilities for durable connections
|
|
3
|
+
*
|
|
4
|
+
* This module provides utilities for managing reconnection logic with
|
|
5
|
+
* exponential backoff and jitter.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Calculate exponential backoff delay with jitter
|
|
9
|
+
*
|
|
10
|
+
* @param attempt - Current attempt number (0-indexed)
|
|
11
|
+
* @param base - Base delay in milliseconds
|
|
12
|
+
* @param max - Maximum delay in milliseconds
|
|
13
|
+
* @param jitter - Jitter factor (0-1), e.g., 0.2 for ±20%
|
|
14
|
+
* @returns Delay in milliseconds with jitter applied
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* calculateBackoff(0, 1000, 30000, 0.2) // ~1000ms ± 20%
|
|
19
|
+
* calculateBackoff(1, 1000, 30000, 0.2) // ~2000ms ± 20%
|
|
20
|
+
* calculateBackoff(2, 1000, 30000, 0.2) // ~4000ms ± 20%
|
|
21
|
+
* calculateBackoff(5, 1000, 30000, 0.2) // ~30000ms ± 20% (capped at max)
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function calculateBackoff(attempt: number, base: number, max: number, jitter: number): number;
|
|
25
|
+
/**
|
|
26
|
+
* Configuration for reconnection scheduler
|
|
27
|
+
*/
|
|
28
|
+
export interface ReconnectionSchedulerConfig {
|
|
29
|
+
/** Maximum number of reconnection attempts */
|
|
30
|
+
maxAttempts: number;
|
|
31
|
+
/** Base delay for exponential backoff */
|
|
32
|
+
backoffBase: number;
|
|
33
|
+
/** Maximum delay between attempts */
|
|
34
|
+
backoffMax: number;
|
|
35
|
+
/** Jitter factor for randomizing delays */
|
|
36
|
+
jitter: number;
|
|
37
|
+
/** Callback invoked for each reconnection attempt */
|
|
38
|
+
onReconnect: () => Promise<void>;
|
|
39
|
+
/** Callback invoked when max attempts exceeded */
|
|
40
|
+
onMaxAttemptsExceeded: (error: Error) => void;
|
|
41
|
+
/** Optional callback invoked before each attempt */
|
|
42
|
+
onBeforeAttempt?: (attempt: number, maxAttempts: number, delay: number) => void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Reconnection scheduler state
|
|
46
|
+
*/
|
|
47
|
+
export interface ReconnectionScheduler {
|
|
48
|
+
/** Current attempt number */
|
|
49
|
+
attempt: number;
|
|
50
|
+
/** Whether scheduler is active */
|
|
51
|
+
active: boolean;
|
|
52
|
+
/** Schedule next reconnection attempt */
|
|
53
|
+
schedule: () => void;
|
|
54
|
+
/** Cancel scheduled reconnection */
|
|
55
|
+
cancel: () => void;
|
|
56
|
+
/** Reset attempt counter */
|
|
57
|
+
reset: () => void;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Create a reconnection scheduler
|
|
61
|
+
*
|
|
62
|
+
* @param config - Scheduler configuration
|
|
63
|
+
* @returns Reconnection scheduler instance
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* const scheduler = createReconnectionScheduler({
|
|
68
|
+
* maxAttempts: 10,
|
|
69
|
+
* backoffBase: 1000,
|
|
70
|
+
* backoffMax: 30000,
|
|
71
|
+
* jitter: 0.2,
|
|
72
|
+
* onReconnect: async () => {
|
|
73
|
+
* await connect();
|
|
74
|
+
* },
|
|
75
|
+
* onMaxAttemptsExceeded: (error) => {
|
|
76
|
+
* console.error('Failed to reconnect:', error);
|
|
77
|
+
* },
|
|
78
|
+
* onBeforeAttempt: (attempt, max, delay) => {
|
|
79
|
+
* console.log(`Reconnecting in ${delay}ms (${attempt}/${max})...`);
|
|
80
|
+
* }
|
|
81
|
+
* });
|
|
82
|
+
*
|
|
83
|
+
* // Start reconnection
|
|
84
|
+
* scheduler.schedule();
|
|
85
|
+
*
|
|
86
|
+
* // Cancel reconnection
|
|
87
|
+
* scheduler.cancel();
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export declare function createReconnectionScheduler(config: ReconnectionSchedulerConfig): ReconnectionScheduler;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconnection utilities for durable connections
|
|
3
|
+
*
|
|
4
|
+
* This module provides utilities for managing reconnection logic with
|
|
5
|
+
* exponential backoff and jitter.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Calculate exponential backoff delay with jitter
|
|
9
|
+
*
|
|
10
|
+
* @param attempt - Current attempt number (0-indexed)
|
|
11
|
+
* @param base - Base delay in milliseconds
|
|
12
|
+
* @param max - Maximum delay in milliseconds
|
|
13
|
+
* @param jitter - Jitter factor (0-1), e.g., 0.2 for ±20%
|
|
14
|
+
* @returns Delay in milliseconds with jitter applied
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* calculateBackoff(0, 1000, 30000, 0.2) // ~1000ms ± 20%
|
|
19
|
+
* calculateBackoff(1, 1000, 30000, 0.2) // ~2000ms ± 20%
|
|
20
|
+
* calculateBackoff(2, 1000, 30000, 0.2) // ~4000ms ± 20%
|
|
21
|
+
* calculateBackoff(5, 1000, 30000, 0.2) // ~30000ms ± 20% (capped at max)
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function calculateBackoff(attempt, base, max, jitter) {
|
|
25
|
+
// Calculate exponential delay: base * 2^attempt
|
|
26
|
+
const exponential = base * Math.pow(2, attempt);
|
|
27
|
+
// Cap at maximum
|
|
28
|
+
const capped = Math.min(exponential, max);
|
|
29
|
+
// Apply jitter: ± (jitter * capped)
|
|
30
|
+
const jitterAmount = capped * jitter;
|
|
31
|
+
const randomJitter = (Math.random() * 2 - 1) * jitterAmount;
|
|
32
|
+
// Return delay with jitter, ensuring it's not negative
|
|
33
|
+
return Math.max(0, capped + randomJitter);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Create a reconnection scheduler
|
|
37
|
+
*
|
|
38
|
+
* @param config - Scheduler configuration
|
|
39
|
+
* @returns Reconnection scheduler instance
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```typescript
|
|
43
|
+
* const scheduler = createReconnectionScheduler({
|
|
44
|
+
* maxAttempts: 10,
|
|
45
|
+
* backoffBase: 1000,
|
|
46
|
+
* backoffMax: 30000,
|
|
47
|
+
* jitter: 0.2,
|
|
48
|
+
* onReconnect: async () => {
|
|
49
|
+
* await connect();
|
|
50
|
+
* },
|
|
51
|
+
* onMaxAttemptsExceeded: (error) => {
|
|
52
|
+
* console.error('Failed to reconnect:', error);
|
|
53
|
+
* },
|
|
54
|
+
* onBeforeAttempt: (attempt, max, delay) => {
|
|
55
|
+
* console.log(`Reconnecting in ${delay}ms (${attempt}/${max})...`);
|
|
56
|
+
* }
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* // Start reconnection
|
|
60
|
+
* scheduler.schedule();
|
|
61
|
+
*
|
|
62
|
+
* // Cancel reconnection
|
|
63
|
+
* scheduler.cancel();
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function createReconnectionScheduler(config) {
|
|
67
|
+
let attempt = 0;
|
|
68
|
+
let active = false;
|
|
69
|
+
let timer;
|
|
70
|
+
const schedule = () => {
|
|
71
|
+
// Cancel any existing timer
|
|
72
|
+
if (timer) {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
timer = undefined;
|
|
75
|
+
}
|
|
76
|
+
// Check if max attempts exceeded
|
|
77
|
+
if (attempt >= config.maxAttempts) {
|
|
78
|
+
active = false;
|
|
79
|
+
config.onMaxAttemptsExceeded(new Error(`Max reconnection attempts exceeded (${config.maxAttempts})`));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// Calculate delay
|
|
83
|
+
const delay = calculateBackoff(attempt, config.backoffBase, config.backoffMax, config.jitter);
|
|
84
|
+
// Notify before attempt
|
|
85
|
+
if (config.onBeforeAttempt) {
|
|
86
|
+
config.onBeforeAttempt(attempt + 1, config.maxAttempts, delay);
|
|
87
|
+
}
|
|
88
|
+
// Mark as active
|
|
89
|
+
active = true;
|
|
90
|
+
// Schedule reconnection
|
|
91
|
+
timer = setTimeout(async () => {
|
|
92
|
+
attempt++;
|
|
93
|
+
try {
|
|
94
|
+
await config.onReconnect();
|
|
95
|
+
// Success - reset scheduler
|
|
96
|
+
attempt = 0;
|
|
97
|
+
active = false;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
// Failure - schedule next attempt
|
|
101
|
+
schedule();
|
|
102
|
+
}
|
|
103
|
+
}, delay);
|
|
104
|
+
};
|
|
105
|
+
const cancel = () => {
|
|
106
|
+
if (timer) {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
timer = undefined;
|
|
109
|
+
}
|
|
110
|
+
active = false;
|
|
111
|
+
};
|
|
112
|
+
const reset = () => {
|
|
113
|
+
cancel();
|
|
114
|
+
attempt = 0;
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
get attempt() {
|
|
118
|
+
return attempt;
|
|
119
|
+
},
|
|
120
|
+
get active() {
|
|
121
|
+
return active;
|
|
122
|
+
},
|
|
123
|
+
schedule,
|
|
124
|
+
cancel,
|
|
125
|
+
reset
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DurableService - Service with automatic TTL refresh
|
|
3
|
+
*
|
|
4
|
+
* Manages service publishing with automatic reconnection for incoming
|
|
5
|
+
* connections and TTL auto-refresh to prevent expiration.
|
|
6
|
+
*/
|
|
7
|
+
import { EventEmitter } from '../event-emitter.js';
|
|
8
|
+
import type { RondevuOffers } from '../offers.js';
|
|
9
|
+
import { DurableChannel } from './channel.js';
|
|
10
|
+
import type { DurableServiceConfig, DurableServiceEvents, ServiceInfo } from './types.js';
|
|
11
|
+
/**
|
|
12
|
+
* Connection handler callback
|
|
13
|
+
*/
|
|
14
|
+
export type ConnectionHandler = (channel: DurableChannel, connectionId: string) => void | Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Durable service that automatically refreshes TTL and handles reconnections
|
|
17
|
+
*
|
|
18
|
+
* The DurableService manages service publishing and provides:
|
|
19
|
+
* - Automatic TTL refresh before expiration
|
|
20
|
+
* - Durable connections for incoming peers
|
|
21
|
+
* - Connection pooling for multiple simultaneous connections
|
|
22
|
+
* - High-level connection lifecycle events
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* const service = new DurableService(
|
|
27
|
+
* offersApi,
|
|
28
|
+
* (channel, connectionId) => {
|
|
29
|
+
* channel.on('message', (data) => {
|
|
30
|
+
* console.log(`Message from ${connectionId}:`, data);
|
|
31
|
+
* channel.send(`Echo: ${data}`);
|
|
32
|
+
* });
|
|
33
|
+
* },
|
|
34
|
+
* {
|
|
35
|
+
* username: 'alice',
|
|
36
|
+
* privateKey: keypair.privateKey,
|
|
37
|
+
* serviceFqn: 'chat@1.0.0',
|
|
38
|
+
* poolSize: 10
|
|
39
|
+
* }
|
|
40
|
+
* );
|
|
41
|
+
*
|
|
42
|
+
* service.on('published', (serviceId, uuid) => {
|
|
43
|
+
* console.log(`Service published: ${uuid}`);
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* service.on('connection', (connectionId) => {
|
|
47
|
+
* console.log(`New connection: ${connectionId}`);
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* await service.start();
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export declare class DurableService extends EventEmitter<DurableServiceEvents> {
|
|
54
|
+
private offersApi;
|
|
55
|
+
private baseUrl;
|
|
56
|
+
private credentials;
|
|
57
|
+
private handler;
|
|
58
|
+
readonly config: Required<DurableServiceConfig>;
|
|
59
|
+
private serviceId?;
|
|
60
|
+
private uuid?;
|
|
61
|
+
private expiresAt?;
|
|
62
|
+
private ttlRefreshTimer?;
|
|
63
|
+
private servicePool?;
|
|
64
|
+
private activeChannels;
|
|
65
|
+
constructor(offersApi: RondevuOffers, baseUrl: string, credentials: {
|
|
66
|
+
peerId: string;
|
|
67
|
+
secret: string;
|
|
68
|
+
}, handler: ConnectionHandler, config: DurableServiceConfig);
|
|
69
|
+
/**
|
|
70
|
+
* Start the service
|
|
71
|
+
*
|
|
72
|
+
* Publishes the service and begins accepting connections.
|
|
73
|
+
*
|
|
74
|
+
* @returns Service information
|
|
75
|
+
*/
|
|
76
|
+
start(): Promise<ServiceInfo>;
|
|
77
|
+
/**
|
|
78
|
+
* Stop the service
|
|
79
|
+
*
|
|
80
|
+
* Unpublishes the service and closes all active connections.
|
|
81
|
+
*/
|
|
82
|
+
stop(): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Get list of active connection IDs
|
|
85
|
+
*/
|
|
86
|
+
getActiveConnections(): string[];
|
|
87
|
+
/**
|
|
88
|
+
* Get service information
|
|
89
|
+
*/
|
|
90
|
+
getServiceInfo(): ServiceInfo | null;
|
|
91
|
+
/**
|
|
92
|
+
* Schedule TTL refresh
|
|
93
|
+
*/
|
|
94
|
+
private scheduleRefresh;
|
|
95
|
+
/**
|
|
96
|
+
* Refresh service TTL
|
|
97
|
+
*/
|
|
98
|
+
private refreshServiceTTL;
|
|
99
|
+
/**
|
|
100
|
+
* Handle new incoming connection
|
|
101
|
+
*/
|
|
102
|
+
private handleNewConnection;
|
|
103
|
+
}
|