rollback-netcode 0.0.4

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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/dist/debug.d.ts +29 -0
  4. package/dist/debug.d.ts.map +1 -0
  5. package/dist/debug.js +56 -0
  6. package/dist/debug.js.map +1 -0
  7. package/dist/index.d.ts +62 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +57 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/protocol/encoding.d.ts +80 -0
  12. package/dist/protocol/encoding.d.ts.map +1 -0
  13. package/dist/protocol/encoding.js +992 -0
  14. package/dist/protocol/encoding.js.map +1 -0
  15. package/dist/protocol/messages.d.ts +271 -0
  16. package/dist/protocol/messages.d.ts.map +1 -0
  17. package/dist/protocol/messages.js +114 -0
  18. package/dist/protocol/messages.js.map +1 -0
  19. package/dist/rollback/engine.d.ts +261 -0
  20. package/dist/rollback/engine.d.ts.map +1 -0
  21. package/dist/rollback/engine.js +543 -0
  22. package/dist/rollback/engine.js.map +1 -0
  23. package/dist/rollback/input-buffer.d.ts +225 -0
  24. package/dist/rollback/input-buffer.d.ts.map +1 -0
  25. package/dist/rollback/input-buffer.js +483 -0
  26. package/dist/rollback/input-buffer.js.map +1 -0
  27. package/dist/rollback/snapshot-buffer.d.ts +119 -0
  28. package/dist/rollback/snapshot-buffer.d.ts.map +1 -0
  29. package/dist/rollback/snapshot-buffer.js +256 -0
  30. package/dist/rollback/snapshot-buffer.js.map +1 -0
  31. package/dist/session/desync-manager.d.ts +106 -0
  32. package/dist/session/desync-manager.d.ts.map +1 -0
  33. package/dist/session/desync-manager.js +136 -0
  34. package/dist/session/desync-manager.js.map +1 -0
  35. package/dist/session/lag-monitor.d.ts +69 -0
  36. package/dist/session/lag-monitor.d.ts.map +1 -0
  37. package/dist/session/lag-monitor.js +74 -0
  38. package/dist/session/lag-monitor.js.map +1 -0
  39. package/dist/session/message-builders.d.ts +86 -0
  40. package/dist/session/message-builders.d.ts.map +1 -0
  41. package/dist/session/message-builders.js +199 -0
  42. package/dist/session/message-builders.js.map +1 -0
  43. package/dist/session/message-router.d.ts +61 -0
  44. package/dist/session/message-router.d.ts.map +1 -0
  45. package/dist/session/message-router.js +105 -0
  46. package/dist/session/message-router.js.map +1 -0
  47. package/dist/session/player-manager.d.ts +100 -0
  48. package/dist/session/player-manager.d.ts.map +1 -0
  49. package/dist/session/player-manager.js +160 -0
  50. package/dist/session/player-manager.js.map +1 -0
  51. package/dist/session/session.d.ts +379 -0
  52. package/dist/session/session.d.ts.map +1 -0
  53. package/dist/session/session.js +1294 -0
  54. package/dist/session/session.js.map +1 -0
  55. package/dist/session/topology.d.ts +66 -0
  56. package/dist/session/topology.d.ts.map +1 -0
  57. package/dist/session/topology.js +72 -0
  58. package/dist/session/topology.js.map +1 -0
  59. package/dist/transport/adapter.d.ts +99 -0
  60. package/dist/transport/adapter.d.ts.map +1 -0
  61. package/dist/transport/adapter.js +8 -0
  62. package/dist/transport/adapter.js.map +1 -0
  63. package/dist/transport/local.d.ts +192 -0
  64. package/dist/transport/local.d.ts.map +1 -0
  65. package/dist/transport/local.js +435 -0
  66. package/dist/transport/local.js.map +1 -0
  67. package/dist/transport/transforming.d.ts +177 -0
  68. package/dist/transport/transforming.d.ts.map +1 -0
  69. package/dist/transport/transforming.js +407 -0
  70. package/dist/transport/transforming.js.map +1 -0
  71. package/dist/transport/webrtc.d.ts +285 -0
  72. package/dist/transport/webrtc.d.ts.map +1 -0
  73. package/dist/transport/webrtc.js +734 -0
  74. package/dist/transport/webrtc.js.map +1 -0
  75. package/dist/types.d.ts +394 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +256 -0
  78. package/dist/types.js.map +1 -0
  79. package/dist/utils/rate-limiter.d.ts +59 -0
  80. package/dist/utils/rate-limiter.d.ts.map +1 -0
  81. package/dist/utils/rate-limiter.js +93 -0
  82. package/dist/utils/rate-limiter.js.map +1 -0
  83. package/package.json +61 -0
@@ -0,0 +1,734 @@
1
+ /**
2
+ * WebRTC transport implementation.
3
+ *
4
+ * Provides peer-to-peer communication using WebRTC DataChannels.
5
+ * Uses dual channels: reliable (ordered, guaranteed) and unreliable (best-effort).
6
+ *
7
+ * This implementation is signaling-agnostic - you must provide your own signaling
8
+ * mechanism (WebSocket, HTTP, etc.) to exchange SDP offers/answers and ICE candidates.
9
+ */
10
+ /** Delay in ms before treating a disconnected state as failed */
11
+ const DISCONNECT_DETECTION_DELAY_MS = 5000;
12
+ /** Default maximum reconnection attempts */
13
+ const DEFAULT_MAX_RECONNECT_ATTEMPTS = 3;
14
+ /** Default delay between reconnection attempts in ms */
15
+ const DEFAULT_RECONNECT_DELAY_MS = 1000;
16
+ /** Maximum delay between reconnection attempts in ms */
17
+ const MAX_RECONNECT_DELAY_MS = 30000;
18
+ /** Jitter factor for reconnection delay (0.2 = 20% random variance) */
19
+ const RECONNECT_JITTER_FACTOR = 0.2;
20
+ /** Number of RTT samples to keep for jitter calculation */
21
+ const RTT_SAMPLE_COUNT = 10;
22
+ /** Default keepalive interval in milliseconds */
23
+ const DEFAULT_KEEPALIVE_INTERVAL_MS = 5000;
24
+ /** Maximum time without response before considering peer dead (in ms) */
25
+ const DEFAULT_KEEPALIVE_TIMEOUT_MS = 15000;
26
+ /** Default timeout for connection establishment (in ms) */
27
+ const DEFAULT_CONNECTION_TIMEOUT_MS = 30000;
28
+ /**
29
+ * Default RTCPeerConnection configuration.
30
+ */
31
+ const DEFAULT_RTC_CONFIG = {
32
+ iceServers: [
33
+ { urls: "stun:stun.l.google.com:19302" },
34
+ { urls: "stun:stun1.l.google.com:19302" },
35
+ ],
36
+ };
37
+ /**
38
+ * Default reliable channel configuration.
39
+ */
40
+ const DEFAULT_RELIABLE_CHANNEL_CONFIG = {
41
+ ordered: true,
42
+ };
43
+ /**
44
+ * Default unreliable channel configuration.
45
+ */
46
+ const DEFAULT_UNRELIABLE_CHANNEL_CONFIG = {
47
+ ordered: false,
48
+ maxRetransmits: 0,
49
+ };
50
+ /**
51
+ * WebRTC transport implementation.
52
+ */
53
+ export class WebRTCTransport {
54
+ localPeerId;
55
+ rtcConfig;
56
+ reliableChannelConfig;
57
+ unreliableChannelConfig;
58
+ maxReconnectAttempts;
59
+ reconnectDelay;
60
+ keepaliveInterval;
61
+ keepaliveTimeout;
62
+ connectionTimeout;
63
+ peers = new Map();
64
+ _connectedPeers = new Set();
65
+ peerMetrics = new Map();
66
+ signalingCallbacks = null;
67
+ keepaliveTimer = null;
68
+ /** Callback for keepalive ping - set by Session to send Ping messages */
69
+ onKeepalivePing = null;
70
+ onMessage = null;
71
+ onConnect = null;
72
+ onDisconnect = null;
73
+ onError = null;
74
+ /**
75
+ * Create a new WebRTC transport.
76
+ *
77
+ * @param localPeerId - Unique identifier for this peer
78
+ * @param config - Optional configuration
79
+ */
80
+ constructor(localPeerId, config = {}) {
81
+ this.localPeerId = localPeerId;
82
+ this.rtcConfig = config.rtcConfiguration ?? DEFAULT_RTC_CONFIG;
83
+ this.reliableChannelConfig =
84
+ config.reliableChannelConfig ?? DEFAULT_RELIABLE_CHANNEL_CONFIG;
85
+ this.unreliableChannelConfig =
86
+ config.unreliableChannelConfig ?? DEFAULT_UNRELIABLE_CHANNEL_CONFIG;
87
+ this.maxReconnectAttempts =
88
+ config.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS;
89
+ this.reconnectDelay = config.reconnectDelay ?? DEFAULT_RECONNECT_DELAY_MS;
90
+ this.keepaliveInterval =
91
+ config.keepaliveInterval ?? DEFAULT_KEEPALIVE_INTERVAL_MS;
92
+ this.keepaliveTimeout =
93
+ config.keepaliveTimeout ?? DEFAULT_KEEPALIVE_TIMEOUT_MS;
94
+ this.connectionTimeout =
95
+ config.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT_MS;
96
+ // Note: Keepalive timer is started when first peer connects, not here
97
+ }
98
+ /**
99
+ * Set of currently connected peer IDs.
100
+ */
101
+ get connectedPeers() {
102
+ return this._connectedPeers;
103
+ }
104
+ /**
105
+ * Set signaling callbacks for SDP and ICE candidate exchange.
106
+ * Must be called before initiating or accepting connections.
107
+ *
108
+ * @param callbacks - Signaling callbacks
109
+ */
110
+ setSignalingCallbacks(callbacks) {
111
+ this.signalingCallbacks = callbacks;
112
+ }
113
+ /**
114
+ * Connect to a peer by creating an offer.
115
+ * This initiates the WebRTC connection handshake.
116
+ *
117
+ * @param peerId - The peer's ID
118
+ */
119
+ async connect(peerId) {
120
+ if (this._connectedPeers.has(peerId)) {
121
+ return; // Already connected
122
+ }
123
+ const peer = this.getOrCreatePeer(peerId, true);
124
+ if (peer.connectionPromise) {
125
+ return peer.connectionPromise;
126
+ }
127
+ peer.connectionPromise = new Promise((resolve, reject) => {
128
+ peer.connectionResolve = resolve;
129
+ peer.connectionReject = reject;
130
+ // Set connection timeout
131
+ if (this.connectionTimeout > 0) {
132
+ peer.connectionTimer = setTimeout(() => {
133
+ if (!peer.isConnected && peer.connectionReject) {
134
+ peer.connectionReject(new Error(`Connection to ${peerId} timed out`));
135
+ peer.connectionResolve = null;
136
+ peer.connectionReject = null;
137
+ peer.connectionTimer = null;
138
+ this.cleanupPeer(peerId, peer);
139
+ }
140
+ }, this.connectionTimeout);
141
+ }
142
+ });
143
+ try {
144
+ await this.createOffer(peerId);
145
+ }
146
+ catch (error) {
147
+ // If offer creation fails, reject the connection promise and clean up
148
+ if (peer.connectionReject) {
149
+ peer.connectionReject(error instanceof Error ? error : new Error(String(error)));
150
+ peer.connectionResolve = null;
151
+ peer.connectionReject = null;
152
+ }
153
+ if (peer.connectionTimer) {
154
+ clearTimeout(peer.connectionTimer);
155
+ peer.connectionTimer = null;
156
+ }
157
+ this.cleanupPeer(peerId, peer);
158
+ // Don't re-throw - the promise rejection is sufficient
159
+ }
160
+ return peer.connectionPromise;
161
+ }
162
+ /**
163
+ * Create and send an offer to a peer.
164
+ *
165
+ * @param peerId - The peer's ID
166
+ */
167
+ async createOffer(peerId) {
168
+ if (!this.signalingCallbacks) {
169
+ throw new Error("Signaling callbacks not set");
170
+ }
171
+ const peer = this.getOrCreatePeer(peerId, true);
172
+ // Create data channels (initiator creates them)
173
+ peer.reliableChannel = peer.connection.createDataChannel("reliable", this.reliableChannelConfig);
174
+ peer.unreliableChannel = peer.connection.createDataChannel("unreliable", this.unreliableChannelConfig);
175
+ this.setupDataChannel(peerId, peer.reliableChannel, true);
176
+ this.setupDataChannel(peerId, peer.unreliableChannel, false);
177
+ // Create and set local description
178
+ const offer = await peer.connection.createOffer();
179
+ await peer.connection.setLocalDescription(offer);
180
+ // Send offer through signaling
181
+ this.signalingCallbacks.onSignal(peerId, {
182
+ type: "description",
183
+ description: offer,
184
+ });
185
+ }
186
+ /**
187
+ * Handle a remote SDP description (offer or answer).
188
+ *
189
+ * @param peerId - The peer's ID
190
+ * @param description - The SDP description
191
+ */
192
+ async handleRemoteDescription(peerId, description) {
193
+ if (!this.signalingCallbacks) {
194
+ throw new Error("Signaling callbacks not set");
195
+ }
196
+ const isOffer = description.type === "offer";
197
+ const peer = this.getOrCreatePeer(peerId, !isOffer);
198
+ await peer.connection.setRemoteDescription(description);
199
+ // If we received an offer, create and send an answer
200
+ if (isOffer) {
201
+ const answer = await peer.connection.createAnswer();
202
+ await peer.connection.setLocalDescription(answer);
203
+ this.signalingCallbacks.onSignal(peerId, {
204
+ type: "description",
205
+ description: answer,
206
+ });
207
+ }
208
+ }
209
+ /**
210
+ * Handle a remote ICE candidate.
211
+ *
212
+ * @param peerId - The peer's ID
213
+ * @param candidate - The ICE candidate
214
+ */
215
+ async handleRemoteCandidate(peerId, candidate) {
216
+ const peer = this.peers.get(peerId);
217
+ if (!peer) {
218
+ return; // Unknown peer, ignore
219
+ }
220
+ try {
221
+ await peer.connection.addIceCandidate(candidate);
222
+ }
223
+ catch (error) {
224
+ const err = error instanceof Error ? error : new Error(String(error));
225
+ console.warn(`Failed to add ICE candidate for peer ${peerId}:`, error);
226
+ this.onError?.(peerId, err, "addIceCandidate");
227
+ }
228
+ }
229
+ /**
230
+ * Disconnect from a peer.
231
+ *
232
+ * @param peerId - The peer's ID
233
+ */
234
+ disconnect(peerId) {
235
+ const peer = this.peers.get(peerId);
236
+ if (!peer) {
237
+ return;
238
+ }
239
+ this.cleanupPeer(peerId, peer);
240
+ }
241
+ /**
242
+ * Disconnect from all peers.
243
+ */
244
+ disconnectAll() {
245
+ for (const peerId of [...this.peers.keys()]) {
246
+ this.disconnect(peerId);
247
+ }
248
+ this.stopKeepaliveTimer();
249
+ }
250
+ /**
251
+ * Destroy the transport and clean up all resources.
252
+ */
253
+ destroy() {
254
+ this.disconnectAll();
255
+ this.stopKeepaliveTimer();
256
+ this.peerMetrics.clear();
257
+ }
258
+ /**
259
+ * Send a message to a peer.
260
+ *
261
+ * @param peerId - The peer's ID
262
+ * @param message - The message data
263
+ * @param reliable - Whether to use the reliable channel
264
+ */
265
+ send(peerId, message, reliable) {
266
+ const peer = this.peers.get(peerId);
267
+ if (!peer || !peer.isConnected) {
268
+ return; // Not connected
269
+ }
270
+ const channel = reliable ? peer.reliableChannel : peer.unreliableChannel;
271
+ if (!channel || channel.readyState !== "open") {
272
+ return;
273
+ }
274
+ try {
275
+ // RTCDataChannel.send accepts Uint8Array at runtime, but TypeScript's
276
+ // strict types complain about SharedArrayBuffer. Cast to satisfy types.
277
+ channel.send(message);
278
+ }
279
+ catch (error) {
280
+ const err = error instanceof Error ? error : new Error(String(error));
281
+ console.warn(`Failed to send message to peer ${peerId}:`, error);
282
+ this.onError?.(peerId, err, "send");
283
+ }
284
+ }
285
+ /**
286
+ * Broadcast a message to all connected peers.
287
+ *
288
+ * @param message - The message data
289
+ * @param reliable - Whether to use the reliable channel
290
+ */
291
+ broadcast(message, reliable) {
292
+ for (const peerId of this._connectedPeers) {
293
+ this.send(peerId, message, reliable);
294
+ }
295
+ }
296
+ /**
297
+ * Get connection statistics for a peer.
298
+ *
299
+ * @param peerId - The peer's ID
300
+ * @returns Connection stats or null if not connected
301
+ */
302
+ async getConnectionStats(peerId) {
303
+ const peer = this.peers.get(peerId);
304
+ if (!peer) {
305
+ return null;
306
+ }
307
+ return peer.connection.getStats();
308
+ }
309
+ /**
310
+ * Get or create a peer connection.
311
+ */
312
+ getOrCreatePeer(peerId, isInitiator) {
313
+ let peer = this.peers.get(peerId);
314
+ if (peer) {
315
+ return peer;
316
+ }
317
+ const connection = new RTCPeerConnection(this.rtcConfig);
318
+ peer = {
319
+ connection,
320
+ reliableChannel: null,
321
+ unreliableChannel: null,
322
+ isInitiator,
323
+ reconnectAttempts: 0,
324
+ isConnected: false,
325
+ connectionPromise: null,
326
+ connectionResolve: null,
327
+ connectionReject: null,
328
+ connectionTimer: null,
329
+ disconnectTimer: null,
330
+ reconnectTimer: null,
331
+ };
332
+ this.setupPeerConnection(peerId, peer);
333
+ this.peers.set(peerId, peer);
334
+ return peer;
335
+ }
336
+ /**
337
+ * Set up event handlers for a peer connection.
338
+ */
339
+ setupPeerConnection(peerId, peer) {
340
+ const connection = peer.connection;
341
+ // Handle ICE candidates
342
+ connection.onicecandidate = (event) => {
343
+ if (event.candidate && this.signalingCallbacks) {
344
+ this.signalingCallbacks.onSignal(peerId, {
345
+ type: "candidate",
346
+ candidate: event.candidate,
347
+ });
348
+ }
349
+ };
350
+ // Handle ICE connection state changes
351
+ connection.oniceconnectionstatechange = () => {
352
+ const state = connection.iceConnectionState;
353
+ if (state === "failed") {
354
+ this.handleConnectionFailure(peerId, peer);
355
+ }
356
+ else if (state === "disconnected") {
357
+ // Might recover, wait a bit before declaring disconnection
358
+ // Clear any existing disconnect timer
359
+ if (peer.disconnectTimer) {
360
+ clearTimeout(peer.disconnectTimer);
361
+ }
362
+ peer.disconnectTimer = setTimeout(() => {
363
+ peer.disconnectTimer = null;
364
+ if (connection.iceConnectionState === "disconnected") {
365
+ this.handleConnectionFailure(peerId, peer);
366
+ }
367
+ }, DISCONNECT_DETECTION_DELAY_MS);
368
+ }
369
+ else if (state === "closed") {
370
+ this.cleanupPeer(peerId, peer);
371
+ }
372
+ };
373
+ // Handle data channels (for non-initiator)
374
+ connection.ondatachannel = (event) => {
375
+ const channel = event.channel;
376
+ const isReliable = channel.label === "reliable";
377
+ if (isReliable) {
378
+ peer.reliableChannel = channel;
379
+ }
380
+ else {
381
+ peer.unreliableChannel = channel;
382
+ }
383
+ this.setupDataChannel(peerId, channel, isReliable);
384
+ };
385
+ }
386
+ /**
387
+ * Set up event handlers for a data channel.
388
+ */
389
+ setupDataChannel(peerId, channel, isReliable) {
390
+ channel.binaryType = "arraybuffer";
391
+ channel.onopen = () => {
392
+ this.checkConnectionReady(peerId);
393
+ };
394
+ channel.onclose = () => {
395
+ const peer = this.peers.get(peerId);
396
+ if (peer?.isConnected) {
397
+ this.handleConnectionFailure(peerId, peer);
398
+ }
399
+ };
400
+ channel.onerror = (event) => {
401
+ const channelType = isReliable ? "reliable" : "unreliable";
402
+ console.warn(`DataChannel error for peer ${peerId} (${channelType}):`, event);
403
+ // RTCErrorEvent has an error property, but the type might be just Event
404
+ const rtcEvent = event;
405
+ const error = rtcEvent.error ?? new Error(`DataChannel error (${channelType})`);
406
+ this.onError?.(peerId, error, `dataChannel.${channelType}`);
407
+ };
408
+ channel.onmessage = (event) => {
409
+ if (this.onMessage && event.data instanceof ArrayBuffer) {
410
+ this.onMessage(peerId, new Uint8Array(event.data));
411
+ }
412
+ };
413
+ }
414
+ /**
415
+ * Check if both data channels are ready and mark connection as established.
416
+ */
417
+ checkConnectionReady(peerId) {
418
+ const peer = this.peers.get(peerId);
419
+ if (!peer) {
420
+ return;
421
+ }
422
+ const reliableReady = peer.reliableChannel?.readyState === "open";
423
+ const unreliableReady = peer.unreliableChannel?.readyState === "open";
424
+ if (reliableReady && unreliableReady && !peer.isConnected) {
425
+ peer.isConnected = true;
426
+ peer.reconnectAttempts = 0;
427
+ // Start keepalive timer when first peer connects
428
+ const wasEmpty = this._connectedPeers.size === 0;
429
+ this._connectedPeers.add(peerId);
430
+ if (wasEmpty && this.keepaliveInterval > 0) {
431
+ this.startKeepaliveTimer();
432
+ }
433
+ // Clear connection timeout
434
+ if (peer.connectionTimer) {
435
+ clearTimeout(peer.connectionTimer);
436
+ peer.connectionTimer = null;
437
+ }
438
+ // Resolve connection promise
439
+ if (peer.connectionResolve) {
440
+ peer.connectionResolve();
441
+ peer.connectionResolve = null;
442
+ peer.connectionReject = null;
443
+ }
444
+ this.onConnect?.(peerId);
445
+ }
446
+ }
447
+ /**
448
+ * Handle a connection failure.
449
+ */
450
+ handleConnectionFailure(peerId, peer) {
451
+ const wasConnected = peer.isConnected;
452
+ peer.isConnected = false;
453
+ this._connectedPeers.delete(peerId);
454
+ // Stop keepalive timer when last peer disconnects
455
+ if (this._connectedPeers.size === 0) {
456
+ this.stopKeepaliveTimer();
457
+ }
458
+ // Reject connection promise if pending
459
+ if (peer.connectionReject) {
460
+ peer.connectionReject(new Error("Connection failed"));
461
+ peer.connectionResolve = null;
462
+ peer.connectionReject = null;
463
+ }
464
+ // Notify disconnection
465
+ if (wasConnected) {
466
+ this.onDisconnect?.(peerId);
467
+ }
468
+ // Attempt reconnection if we were the initiator
469
+ if (peer.isInitiator &&
470
+ peer.reconnectAttempts < this.maxReconnectAttempts) {
471
+ peer.reconnectAttempts++;
472
+ const delay = this.getReconnectDelayWithJitter(peer.reconnectAttempts);
473
+ // Clear any existing reconnect timer
474
+ if (peer.reconnectTimer) {
475
+ clearTimeout(peer.reconnectTimer);
476
+ }
477
+ peer.reconnectTimer = setTimeout(() => {
478
+ peer.reconnectTimer = null;
479
+ this.attemptReconnect(peerId);
480
+ }, delay);
481
+ }
482
+ else {
483
+ this.cleanupPeer(peerId, peer);
484
+ }
485
+ }
486
+ /**
487
+ * Calculate reconnection delay with exponential backoff.
488
+ *
489
+ * @param attempt - The reconnection attempt number (1-based)
490
+ * @returns The base delay in milliseconds
491
+ */
492
+ getReconnectDelay(attempt) {
493
+ // Exponential backoff: baseDelay * 2^(attempt-1)
494
+ const delay = this.reconnectDelay * 2 ** (attempt - 1);
495
+ return Math.min(delay, MAX_RECONNECT_DELAY_MS);
496
+ }
497
+ /**
498
+ * Calculate reconnection delay with exponential backoff and jitter.
499
+ * Jitter prevents multiple peers from reconnecting at exactly the same time.
500
+ *
501
+ * @param attempt - The reconnection attempt number (1-based)
502
+ * @returns The delay in milliseconds with random jitter applied
503
+ */
504
+ getReconnectDelayWithJitter(attempt) {
505
+ const baseDelay = this.getReconnectDelay(attempt);
506
+ const jitter = baseDelay * RECONNECT_JITTER_FACTOR * Math.random();
507
+ return Math.floor(baseDelay + jitter);
508
+ }
509
+ /**
510
+ * Attempt to reconnect to a peer.
511
+ */
512
+ async attemptReconnect(peerId) {
513
+ const peer = this.peers.get(peerId);
514
+ if (!peer) {
515
+ return;
516
+ }
517
+ // Close old connection
518
+ peer.connection.close();
519
+ // Create new connection
520
+ const connection = new RTCPeerConnection(this.rtcConfig);
521
+ peer.connection = connection;
522
+ peer.reliableChannel = null;
523
+ peer.unreliableChannel = null;
524
+ this.setupPeerConnection(peerId, peer);
525
+ try {
526
+ await this.createOffer(peerId);
527
+ }
528
+ catch (error) {
529
+ const err = error instanceof Error ? error : new Error(String(error));
530
+ console.warn(`Reconnection attempt failed for peer ${peerId}:`, error);
531
+ this.onError?.(peerId, err, "reconnect");
532
+ }
533
+ }
534
+ /**
535
+ * Clean up a peer connection.
536
+ */
537
+ cleanupPeer(peerId, peer) {
538
+ const wasConnected = peer.isConnected;
539
+ // Clear all pending timers
540
+ if (peer.connectionTimer) {
541
+ clearTimeout(peer.connectionTimer);
542
+ peer.connectionTimer = null;
543
+ }
544
+ if (peer.disconnectTimer) {
545
+ clearTimeout(peer.disconnectTimer);
546
+ peer.disconnectTimer = null;
547
+ }
548
+ if (peer.reconnectTimer) {
549
+ clearTimeout(peer.reconnectTimer);
550
+ peer.reconnectTimer = null;
551
+ }
552
+ peer.reliableChannel?.close();
553
+ peer.unreliableChannel?.close();
554
+ peer.connection.close();
555
+ peer.isConnected = false;
556
+ this._connectedPeers.delete(peerId);
557
+ this.peers.delete(peerId);
558
+ this.peerMetrics.delete(peerId);
559
+ // Stop keepalive timer when last peer disconnects
560
+ if (this._connectedPeers.size === 0) {
561
+ this.stopKeepaliveTimer();
562
+ }
563
+ // Reject connection promise if pending
564
+ if (peer.connectionReject) {
565
+ peer.connectionReject(new Error("Connection closed"));
566
+ peer.connectionResolve = null;
567
+ peer.connectionReject = null;
568
+ }
569
+ if (wasConnected) {
570
+ this.onDisconnect?.(peerId);
571
+ }
572
+ }
573
+ /**
574
+ * Get connection quality metrics for a peer.
575
+ *
576
+ * @param peerId - The peer's ID
577
+ * @returns Connection metrics or null if not available
578
+ */
579
+ getConnectionMetrics(peerId) {
580
+ const metrics = this.peerMetrics.get(peerId);
581
+ if (!metrics) {
582
+ return null;
583
+ }
584
+ return {
585
+ rtt: metrics.rtt,
586
+ jitter: metrics.jitter,
587
+ packetLoss: metrics.packetLoss,
588
+ lastUpdated: metrics.lastUpdated,
589
+ };
590
+ }
591
+ /**
592
+ * Record a ping sent to a peer for RTT tracking.
593
+ * Call this when sending a Ping message.
594
+ *
595
+ * @param peerId - The peer's ID
596
+ * @param timestamp - The timestamp sent in the ping
597
+ */
598
+ recordPingSent(peerId, timestamp) {
599
+ const metrics = this.getOrCreateMetrics(peerId);
600
+ metrics.pendingPings.set(timestamp, Date.now());
601
+ }
602
+ /**
603
+ * Record a pong received from a peer for RTT calculation.
604
+ * Call this when receiving a Pong message.
605
+ *
606
+ * @param peerId - The peer's ID
607
+ * @param timestamp - The timestamp from the pong (originally from our ping)
608
+ */
609
+ recordPongReceived(peerId, timestamp) {
610
+ const metrics = this.getOrCreateMetrics(peerId);
611
+ const sentAt = metrics.pendingPings.get(timestamp);
612
+ if (sentAt !== undefined) {
613
+ // Clamp RTT to non-negative (can be negative with clock skew)
614
+ const rtt = Math.max(0, Date.now() - sentAt);
615
+ this.updateRttMetrics(metrics, rtt);
616
+ metrics.pendingPings.delete(timestamp);
617
+ }
618
+ }
619
+ /**
620
+ * Update packet loss estimate.
621
+ * Call this when detecting gaps in received input acks.
622
+ *
623
+ * @param peerId - The peer's ID
624
+ * @param received - Number of packets received
625
+ * @param expected - Number of packets expected
626
+ */
627
+ updatePacketLoss(peerId, received, expected) {
628
+ if (expected <= 0)
629
+ return;
630
+ const metrics = this.getOrCreateMetrics(peerId);
631
+ const lossRate = Math.max(0, Math.min(1, 1 - received / expected));
632
+ // Exponential moving average
633
+ metrics.packetLoss = metrics.packetLoss * 0.8 + lossRate * 0.2;
634
+ metrics.lastUpdated = Date.now();
635
+ }
636
+ /**
637
+ * Get or create metrics data for a peer.
638
+ */
639
+ getOrCreateMetrics(peerId) {
640
+ let metrics = this.peerMetrics.get(peerId);
641
+ if (!metrics) {
642
+ const now = Date.now();
643
+ metrics = {
644
+ rttSamples: [],
645
+ rtt: 0,
646
+ jitter: 0,
647
+ packetLoss: 0,
648
+ lastUpdated: now,
649
+ pendingPings: new Map(),
650
+ lastResponseTime: now,
651
+ };
652
+ this.peerMetrics.set(peerId, metrics);
653
+ }
654
+ return metrics;
655
+ }
656
+ /**
657
+ * Start the keepalive timer.
658
+ */
659
+ startKeepaliveTimer() {
660
+ if (this.keepaliveTimer) {
661
+ return;
662
+ }
663
+ this.keepaliveTimer = setInterval(() => {
664
+ this.checkKeepalives();
665
+ }, this.keepaliveInterval);
666
+ // Unref to not block process exit (important for tests)
667
+ if (this.keepaliveTimer.unref) {
668
+ this.keepaliveTimer.unref();
669
+ }
670
+ }
671
+ /**
672
+ * Stop the keepalive timer.
673
+ */
674
+ stopKeepaliveTimer() {
675
+ if (this.keepaliveTimer) {
676
+ clearInterval(this.keepaliveTimer);
677
+ this.keepaliveTimer = null;
678
+ }
679
+ }
680
+ /**
681
+ * Check keepalive status for all connected peers.
682
+ * Sends pings and detects dead connections.
683
+ */
684
+ checkKeepalives() {
685
+ const now = Date.now();
686
+ for (const peerId of this._connectedPeers) {
687
+ const metrics = this.peerMetrics.get(peerId);
688
+ // Check for timeout
689
+ if (metrics && now - metrics.lastResponseTime > this.keepaliveTimeout) {
690
+ // Peer is dead - trigger disconnect
691
+ const peer = this.peers.get(peerId);
692
+ if (peer) {
693
+ this.handleConnectionFailure(peerId, peer);
694
+ }
695
+ continue;
696
+ }
697
+ // Send keepalive ping via callback
698
+ if (this.onKeepalivePing) {
699
+ this.onKeepalivePing(peerId);
700
+ }
701
+ }
702
+ }
703
+ /**
704
+ * Record that we received a response from a peer.
705
+ * Call this when any message is received from a peer.
706
+ *
707
+ * @param peerId - The peer's ID
708
+ */
709
+ recordPeerResponse(peerId) {
710
+ const metrics = this.getOrCreateMetrics(peerId);
711
+ metrics.lastResponseTime = Date.now();
712
+ }
713
+ /**
714
+ * Update RTT metrics with a new sample.
715
+ */
716
+ updateRttMetrics(metrics, rtt) {
717
+ // Add sample to buffer
718
+ metrics.rttSamples.push(rtt);
719
+ if (metrics.rttSamples.length > RTT_SAMPLE_COUNT) {
720
+ metrics.rttSamples.shift();
721
+ }
722
+ // Calculate average RTT
723
+ const sum = metrics.rttSamples.reduce((a, b) => a + b, 0);
724
+ metrics.rtt = sum / metrics.rttSamples.length;
725
+ // Calculate jitter (standard deviation)
726
+ if (metrics.rttSamples.length > 1) {
727
+ const squaredDiffs = metrics.rttSamples.map((sample) => (sample - metrics.rtt) ** 2);
728
+ const avgSquaredDiff = squaredDiffs.reduce((a, b) => a + b, 0) / squaredDiffs.length;
729
+ metrics.jitter = Math.sqrt(avgSquaredDiff);
730
+ }
731
+ metrics.lastUpdated = Date.now();
732
+ }
733
+ }
734
+ //# sourceMappingURL=webrtc.js.map