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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/dist/debug.d.ts +29 -0
- package/dist/debug.d.ts.map +1 -0
- package/dist/debug.js +56 -0
- package/dist/debug.js.map +1 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol/encoding.d.ts +80 -0
- package/dist/protocol/encoding.d.ts.map +1 -0
- package/dist/protocol/encoding.js +992 -0
- package/dist/protocol/encoding.js.map +1 -0
- package/dist/protocol/messages.d.ts +271 -0
- package/dist/protocol/messages.d.ts.map +1 -0
- package/dist/protocol/messages.js +114 -0
- package/dist/protocol/messages.js.map +1 -0
- package/dist/rollback/engine.d.ts +261 -0
- package/dist/rollback/engine.d.ts.map +1 -0
- package/dist/rollback/engine.js +543 -0
- package/dist/rollback/engine.js.map +1 -0
- package/dist/rollback/input-buffer.d.ts +225 -0
- package/dist/rollback/input-buffer.d.ts.map +1 -0
- package/dist/rollback/input-buffer.js +483 -0
- package/dist/rollback/input-buffer.js.map +1 -0
- package/dist/rollback/snapshot-buffer.d.ts +119 -0
- package/dist/rollback/snapshot-buffer.d.ts.map +1 -0
- package/dist/rollback/snapshot-buffer.js +256 -0
- package/dist/rollback/snapshot-buffer.js.map +1 -0
- package/dist/session/desync-manager.d.ts +106 -0
- package/dist/session/desync-manager.d.ts.map +1 -0
- package/dist/session/desync-manager.js +136 -0
- package/dist/session/desync-manager.js.map +1 -0
- package/dist/session/lag-monitor.d.ts +69 -0
- package/dist/session/lag-monitor.d.ts.map +1 -0
- package/dist/session/lag-monitor.js +74 -0
- package/dist/session/lag-monitor.js.map +1 -0
- package/dist/session/message-builders.d.ts +86 -0
- package/dist/session/message-builders.d.ts.map +1 -0
- package/dist/session/message-builders.js +199 -0
- package/dist/session/message-builders.js.map +1 -0
- package/dist/session/message-router.d.ts +61 -0
- package/dist/session/message-router.d.ts.map +1 -0
- package/dist/session/message-router.js +105 -0
- package/dist/session/message-router.js.map +1 -0
- package/dist/session/player-manager.d.ts +100 -0
- package/dist/session/player-manager.d.ts.map +1 -0
- package/dist/session/player-manager.js +160 -0
- package/dist/session/player-manager.js.map +1 -0
- package/dist/session/session.d.ts +379 -0
- package/dist/session/session.d.ts.map +1 -0
- package/dist/session/session.js +1294 -0
- package/dist/session/session.js.map +1 -0
- package/dist/session/topology.d.ts +66 -0
- package/dist/session/topology.d.ts.map +1 -0
- package/dist/session/topology.js +72 -0
- package/dist/session/topology.js.map +1 -0
- package/dist/transport/adapter.d.ts +99 -0
- package/dist/transport/adapter.d.ts.map +1 -0
- package/dist/transport/adapter.js +8 -0
- package/dist/transport/adapter.js.map +1 -0
- package/dist/transport/local.d.ts +192 -0
- package/dist/transport/local.d.ts.map +1 -0
- package/dist/transport/local.js +435 -0
- package/dist/transport/local.js.map +1 -0
- package/dist/transport/transforming.d.ts +177 -0
- package/dist/transport/transforming.d.ts.map +1 -0
- package/dist/transport/transforming.js +407 -0
- package/dist/transport/transforming.js.map +1 -0
- package/dist/transport/webrtc.d.ts +285 -0
- package/dist/transport/webrtc.d.ts.map +1 -0
- package/dist/transport/webrtc.js +734 -0
- package/dist/transport/webrtc.js.map +1 -0
- package/dist/types.d.ts +394 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +256 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/rate-limiter.d.ts +59 -0
- package/dist/utils/rate-limiter.d.ts.map +1 -0
- package/dist/utils/rate-limiter.js +93 -0
- package/dist/utils/rate-limiter.js.map +1 -0
- 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
|