@agentdance/node-webrtc-ice 1.0.0
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/dist/agent.d.ts +77 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +689 -0
- package/dist/agent.js.map +1 -0
- package/dist/candidate.d.ts +9 -0
- package/dist/candidate.d.ts.map +1 -0
- package/dist/candidate.js +139 -0
- package/dist/candidate.js.map +1 -0
- package/dist/checklist.d.ts +10 -0
- package/dist/checklist.d.ts.map +1 -0
- package/dist/checklist.js +102 -0
- package/dist/checklist.js.map +1 -0
- package/dist/gather.d.ts +15 -0
- package/dist/gather.d.ts.map +1 -0
- package/dist/gather.js +169 -0
- package/dist/gather.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/transport.d.ts +21 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +70 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +77 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +27 -0
- package/dist/types.js.map +1 -0
- package/package.json +59 -0
- package/src/agent.ts +961 -0
- package/src/candidate.ts +175 -0
- package/src/checklist.ts +142 -0
- package/src/gather.ts +224 -0
- package/src/index.ts +6 -0
- package/src/transport.ts +88 -0
- package/src/types.ts +77 -0
package/src/agent.ts
ADDED
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import type * as dgram from 'node:dgram';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import {
|
|
5
|
+
AttributeType,
|
|
6
|
+
MessageClass,
|
|
7
|
+
MessageMethod,
|
|
8
|
+
createBindingRequest,
|
|
9
|
+
decodeMessage,
|
|
10
|
+
encodeMessage,
|
|
11
|
+
encodeUsername,
|
|
12
|
+
encodePriority,
|
|
13
|
+
encodeUseCandidate,
|
|
14
|
+
encodeIceControlling,
|
|
15
|
+
encodeIceControlled,
|
|
16
|
+
computeMessageIntegrity,
|
|
17
|
+
verifyMessageIntegrity,
|
|
18
|
+
decodeXorMappedAddress,
|
|
19
|
+
encodeXorMappedAddress,
|
|
20
|
+
isStunMessage,
|
|
21
|
+
generateTransactionId,
|
|
22
|
+
encodeErrorCode,
|
|
23
|
+
decodeUsername,
|
|
24
|
+
decodePriority,
|
|
25
|
+
} from '@agentdance/node-webrtc-stun';
|
|
26
|
+
import type { StunMessage, StunAttribute } from '@agentdance/node-webrtc-stun';
|
|
27
|
+
import {
|
|
28
|
+
computeFingerprint,
|
|
29
|
+
} from '@agentdance/node-webrtc-stun';
|
|
30
|
+
import {
|
|
31
|
+
IceAgentState,
|
|
32
|
+
IceConnectionState,
|
|
33
|
+
CandidatePairState,
|
|
34
|
+
} from './types.js';
|
|
35
|
+
import type {
|
|
36
|
+
CandidatePair,
|
|
37
|
+
IceAgentOptions,
|
|
38
|
+
IceCandidate,
|
|
39
|
+
IceParameters,
|
|
40
|
+
IceRole,
|
|
41
|
+
} from './types.js';
|
|
42
|
+
import {
|
|
43
|
+
generateUfrag,
|
|
44
|
+
generatePassword,
|
|
45
|
+
computeFoundation,
|
|
46
|
+
computePriority,
|
|
47
|
+
} from './candidate.js';
|
|
48
|
+
import { gatherHostCandidates, gatherSrflxCandidate } from './gather.js';
|
|
49
|
+
import { UdpTransport } from './transport.js';
|
|
50
|
+
import {
|
|
51
|
+
formCandidatePairs,
|
|
52
|
+
unfreezeInitialPairs,
|
|
53
|
+
getOrCreatePair,
|
|
54
|
+
findPairByAddresses,
|
|
55
|
+
} from './checklist.js';
|
|
56
|
+
|
|
57
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
58
|
+
const KEEPALIVE_INTERVAL_MS = 15_000;
|
|
59
|
+
const CHECK_INTERVAL_MS = 20;
|
|
60
|
+
const CONNECT_TIMEOUT_MS = 30_000;
|
|
61
|
+
|
|
62
|
+
// ─── IceAgent event type overloads ───────────────────────────────────────────
|
|
63
|
+
export declare interface IceAgent {
|
|
64
|
+
on(event: 'gathering-state', listener: (state: IceAgentState) => void): this;
|
|
65
|
+
on(
|
|
66
|
+
event: 'connection-state',
|
|
67
|
+
listener: (state: IceConnectionState) => void,
|
|
68
|
+
): this;
|
|
69
|
+
on(
|
|
70
|
+
event: 'local-candidate',
|
|
71
|
+
listener: (candidate: IceCandidate) => void,
|
|
72
|
+
): this;
|
|
73
|
+
on(event: 'gathering-complete', listener: () => void): this;
|
|
74
|
+
on(event: 'connected', listener: (pair: CandidatePair) => void): this;
|
|
75
|
+
on(
|
|
76
|
+
event: 'data',
|
|
77
|
+
listener: (
|
|
78
|
+
data: Buffer,
|
|
79
|
+
rinfo: { address: string; port: number },
|
|
80
|
+
) => void,
|
|
81
|
+
): this;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface PendingEntry {
|
|
85
|
+
pair: CandidatePair;
|
|
86
|
+
signedBuf: Buffer;
|
|
87
|
+
useCandidate: boolean;
|
|
88
|
+
timers: NodeJS.Timeout[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── IceAgent ────────────────────────────────────────────────────────────────
|
|
92
|
+
export class IceAgent extends EventEmitter {
|
|
93
|
+
readonly localParameters: IceParameters;
|
|
94
|
+
|
|
95
|
+
private _role: IceRole;
|
|
96
|
+
private readonly _tiebreaker: bigint;
|
|
97
|
+
private readonly _nomination: 'regular' | 'aggressive';
|
|
98
|
+
|
|
99
|
+
private _gatheringState: IceAgentState = IceAgentState.New;
|
|
100
|
+
private _connectionState: IceConnectionState = IceConnectionState.New;
|
|
101
|
+
|
|
102
|
+
private _localCandidates: IceCandidate[] = [];
|
|
103
|
+
private _remoteCandidates: IceCandidate[] = [];
|
|
104
|
+
private _remoteParameters: IceParameters | undefined;
|
|
105
|
+
|
|
106
|
+
private _candidatePairs: CandidatePair[] = [];
|
|
107
|
+
private _nomineePair: CandidatePair | undefined;
|
|
108
|
+
|
|
109
|
+
private _transport: UdpTransport | undefined;
|
|
110
|
+
private _checkInterval: NodeJS.Timeout | undefined;
|
|
111
|
+
private _keepAliveTimer: NodeJS.Timeout | undefined;
|
|
112
|
+
private _failTimer: NodeJS.Timeout | undefined;
|
|
113
|
+
|
|
114
|
+
// txId hex → pending entry
|
|
115
|
+
private _pending = new Map<string, PendingEntry>();
|
|
116
|
+
private _remoteGatheringComplete = false;
|
|
117
|
+
|
|
118
|
+
private _connectResolve: ((pair: CandidatePair) => void) | undefined;
|
|
119
|
+
private _connectReject: ((err: Error) => void) | undefined;
|
|
120
|
+
|
|
121
|
+
private readonly _portRange: { min: number; max: number } | undefined;
|
|
122
|
+
private readonly _stunServers: Array<{ host: string; port: number }>;
|
|
123
|
+
|
|
124
|
+
constructor(options: IceAgentOptions = {}) {
|
|
125
|
+
super();
|
|
126
|
+
|
|
127
|
+
this._role = options.role ?? 'controlling';
|
|
128
|
+
this._tiebreaker =
|
|
129
|
+
options.tiebreaker ??
|
|
130
|
+
BigInt('0x' + crypto.randomBytes(8).toString('hex'));
|
|
131
|
+
this._nomination = options.nomination ?? 'regular';
|
|
132
|
+
this._portRange = options.portRange;
|
|
133
|
+
this._stunServers = options.stunServers ?? [];
|
|
134
|
+
|
|
135
|
+
// Build IceParameters carefully to avoid exactOptionalPropertyTypes issues
|
|
136
|
+
const params: IceParameters = {
|
|
137
|
+
usernameFragment: options.ufrag ?? generateUfrag(),
|
|
138
|
+
password: options.password ?? generatePassword(),
|
|
139
|
+
};
|
|
140
|
+
if (options.lite !== undefined) {
|
|
141
|
+
params.iceLite = options.lite;
|
|
142
|
+
}
|
|
143
|
+
this.localParameters = params;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Public API ────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
async gather(): Promise<void> {
|
|
149
|
+
if (this._gatheringState !== IceAgentState.New) return;
|
|
150
|
+
|
|
151
|
+
this._setGatheringState(IceAgentState.Gathering);
|
|
152
|
+
|
|
153
|
+
this._transport = new UdpTransport();
|
|
154
|
+
await this._transport.bind(this._pickPort(), '0.0.0.0');
|
|
155
|
+
|
|
156
|
+
const port = this._transport.localPort;
|
|
157
|
+
|
|
158
|
+
// Host candidates from all local interfaces (loopback + physical + virtual)
|
|
159
|
+
// gatherHostCandidates now includes loopback with highest localPref so that
|
|
160
|
+
// same-machine pairs (loopback↔loopback) get the highest pair priority.
|
|
161
|
+
const hostCandidates = await gatherHostCandidates(port, 1, 'udp');
|
|
162
|
+
|
|
163
|
+
for (const c of hostCandidates) {
|
|
164
|
+
this._addLocalCandidate(c);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// srflx via STUN servers
|
|
168
|
+
if (this._stunServers.length > 0) {
|
|
169
|
+
const rawSocket = (
|
|
170
|
+
this._transport as unknown as { _socket?: dgram.Socket }
|
|
171
|
+
)._socket;
|
|
172
|
+
if (rawSocket) {
|
|
173
|
+
for (const server of this._stunServers) {
|
|
174
|
+
for (const local of hostCandidates) {
|
|
175
|
+
const srflx = await gatherSrflxCandidate(rawSocket, local, server);
|
|
176
|
+
if (srflx) this._addLocalCandidate(srflx);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Wire packet demux
|
|
183
|
+
this._transport.on('stun', (buf, rinfo) => {
|
|
184
|
+
this._handleStunPacket(buf, rinfo);
|
|
185
|
+
});
|
|
186
|
+
this._transport.on('rtp', (buf, rinfo) => {
|
|
187
|
+
this.emit('data', buf, { address: rinfo.address, port: rinfo.port });
|
|
188
|
+
});
|
|
189
|
+
// Relay DTLS packets to the upper layer (PeerInternals wires to DtlsTransport)
|
|
190
|
+
this._transport.on('dtls', (buf: Buffer, rinfo: dgram.RemoteInfo) => {
|
|
191
|
+
this.emit('data', buf, { address: rinfo.address, port: rinfo.port });
|
|
192
|
+
});
|
|
193
|
+
// Also relay unknown packets as data (e.g. plain buffers in tests)
|
|
194
|
+
this._transport.on(
|
|
195
|
+
'unknown',
|
|
196
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
197
|
+
(...args: any[]) => {
|
|
198
|
+
const buf = args[0] as Buffer;
|
|
199
|
+
const rinfo = args[1] as dgram.RemoteInfo;
|
|
200
|
+
this.emit('data', buf, { address: rinfo.address, port: rinfo.port });
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
this._setGatheringState(IceAgentState.Complete);
|
|
205
|
+
this.emit('gathering-complete');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
setRemoteParameters(params: IceParameters): void {
|
|
209
|
+
this._remoteParameters = params;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
addRemoteCandidate(candidate: IceCandidate): void {
|
|
213
|
+
// ts-rtc uses UDP-only transport; ignore non-UDP remote candidates
|
|
214
|
+
if (candidate.transport !== 'udp') return;
|
|
215
|
+
// ts-rtc binds a udp4 socket; IPv6 candidates will silently fail — skip them
|
|
216
|
+
if (candidate.address.includes(':')) return;
|
|
217
|
+
this._remoteCandidates.push(candidate);
|
|
218
|
+
if (
|
|
219
|
+
this._connectionState === IceConnectionState.Checking ||
|
|
220
|
+
this._connectionState === IceConnectionState.Connected ||
|
|
221
|
+
this._connectionState === IceConnectionState.Failed
|
|
222
|
+
) {
|
|
223
|
+
// Re-enter checking if we previously failed with no candidates (trickle ICE timing)
|
|
224
|
+
if (this._connectionState === IceConnectionState.Failed && !this._nomineePair) {
|
|
225
|
+
this._setConnectionState(IceConnectionState.Checking);
|
|
226
|
+
// Restart checks
|
|
227
|
+
this._checkInterval = setInterval(() => { this._tick(); }, CHECK_INTERVAL_MS);
|
|
228
|
+
}
|
|
229
|
+
this._formPairsForRemote(candidate);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
remoteGatheringComplete(): void {
|
|
234
|
+
this._remoteGatheringComplete = true;
|
|
235
|
+
// Now that we know all remote candidates, check if we already failed
|
|
236
|
+
if (this._connectionState === IceConnectionState.Checking) {
|
|
237
|
+
this._checkAllFailed();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async connect(): Promise<CandidatePair> {
|
|
242
|
+
if (!this._transport) {
|
|
243
|
+
throw new Error('Must call gather() before connect()');
|
|
244
|
+
}
|
|
245
|
+
if (!this._remoteParameters) {
|
|
246
|
+
throw new Error('Must call setRemoteParameters() before connect()');
|
|
247
|
+
}
|
|
248
|
+
if (this._connectionState !== IceConnectionState.New) {
|
|
249
|
+
throw new Error('connect() already called');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
this._setConnectionState(IceConnectionState.Checking);
|
|
253
|
+
|
|
254
|
+
this._candidatePairs = formCandidatePairs(
|
|
255
|
+
this._localCandidates,
|
|
256
|
+
this._remoteCandidates,
|
|
257
|
+
this._role,
|
|
258
|
+
);
|
|
259
|
+
unfreezeInitialPairs(this._candidatePairs);
|
|
260
|
+
|
|
261
|
+
console.log(`[ICE] connect(): formed ${this._candidatePairs.length} pairs`);
|
|
262
|
+
for (const p of this._candidatePairs) {
|
|
263
|
+
console.log(`[ICE] pair ${p.local.address}:${p.local.port} -> ${p.remote.address}:${p.remote.port} state=${p.state} prio=${p.priority}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return new Promise<CandidatePair>((resolve, reject) => {
|
|
267
|
+
this._connectResolve = resolve;
|
|
268
|
+
this._connectReject = reject;
|
|
269
|
+
|
|
270
|
+
this._checkInterval = setInterval(() => {
|
|
271
|
+
this._tick();
|
|
272
|
+
}, CHECK_INTERVAL_MS);
|
|
273
|
+
|
|
274
|
+
const failTimer = setTimeout(() => {
|
|
275
|
+
if (this._connectionState === IceConnectionState.Checking) {
|
|
276
|
+
this._setConnectionState(IceConnectionState.Failed);
|
|
277
|
+
this._stopChecks();
|
|
278
|
+
const rej = this._connectReject;
|
|
279
|
+
this._connectReject = undefined;
|
|
280
|
+
this._connectResolve = undefined;
|
|
281
|
+
rej?.(new Error('ICE connection timed out'));
|
|
282
|
+
}
|
|
283
|
+
}, CONNECT_TIMEOUT_MS);
|
|
284
|
+
failTimer.unref?.();
|
|
285
|
+
this._failTimer = failTimer;
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
send(data: Buffer): void {
|
|
290
|
+
if (!this._transport) {
|
|
291
|
+
throw new Error('No nominated pair – not yet connected');
|
|
292
|
+
}
|
|
293
|
+
// Use nominated pair if available; otherwise fall back to the best valid
|
|
294
|
+
// pair during the window between ICE success and pair nomination (e.g. when
|
|
295
|
+
// the remote sends DTLS data before our USE-CANDIDATE response arrives).
|
|
296
|
+
const pair =
|
|
297
|
+
this._nomineePair ??
|
|
298
|
+
this._candidatePairs.find((p) => p.valid) ??
|
|
299
|
+
this._candidatePairs.find((p) => p.state === CandidatePairState.Succeeded);
|
|
300
|
+
if (!pair) {
|
|
301
|
+
throw new Error('No nominated pair – not yet connected');
|
|
302
|
+
}
|
|
303
|
+
const { address, port } = pair.remote;
|
|
304
|
+
this._transport.send(data, port, address).catch(() => {});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async restart(): Promise<void> {
|
|
308
|
+
this._stopChecks();
|
|
309
|
+
this._stopKeepAlive();
|
|
310
|
+
this._clearFailTimer();
|
|
311
|
+
this._cancelAllPending();
|
|
312
|
+
|
|
313
|
+
(this.localParameters as { usernameFragment: string }).usernameFragment =
|
|
314
|
+
generateUfrag();
|
|
315
|
+
(this.localParameters as { password: string }).password = generatePassword();
|
|
316
|
+
|
|
317
|
+
this._localCandidates = [];
|
|
318
|
+
this._remoteCandidates = [];
|
|
319
|
+
this._candidatePairs = [];
|
|
320
|
+
this._nomineePair = undefined;
|
|
321
|
+
this._gatheringState = IceAgentState.New;
|
|
322
|
+
this._connectionState = IceConnectionState.New;
|
|
323
|
+
|
|
324
|
+
if (this._transport) {
|
|
325
|
+
this._transport.close();
|
|
326
|
+
this._transport = undefined;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
await this.gather();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
close(): void {
|
|
333
|
+
this._stopChecks();
|
|
334
|
+
this._stopKeepAlive();
|
|
335
|
+
this._clearFailTimer();
|
|
336
|
+
this._cancelAllPending();
|
|
337
|
+
|
|
338
|
+
if (this._transport) {
|
|
339
|
+
this._transport.close();
|
|
340
|
+
this._transport = undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
this._setConnectionState(IceConnectionState.Closed);
|
|
344
|
+
this._setGatheringState(IceAgentState.Closed);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
get connectionState(): IceConnectionState {
|
|
348
|
+
return this._connectionState;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
getLocalCandidates(): IceCandidate[] {
|
|
352
|
+
return [...this._localCandidates];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
getSelectedPair(): CandidatePair | undefined {
|
|
356
|
+
return this._nomineePair;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ─── Private: state setters ────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
private _pickPort(): number {
|
|
362
|
+
if (this._portRange) {
|
|
363
|
+
return (
|
|
364
|
+
this._portRange.min +
|
|
365
|
+
Math.floor(
|
|
366
|
+
Math.random() * (this._portRange.max - this._portRange.min),
|
|
367
|
+
)
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private _addLocalCandidate(c: IceCandidate): void {
|
|
374
|
+
this._localCandidates.push(c);
|
|
375
|
+
this.emit('local-candidate', c);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private _setGatheringState(s: IceAgentState): void {
|
|
379
|
+
if (this._gatheringState === s) return;
|
|
380
|
+
this._gatheringState = s;
|
|
381
|
+
this.emit('gathering-state', s);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private _setConnectionState(s: IceConnectionState): void {
|
|
385
|
+
if (this._connectionState === s) return;
|
|
386
|
+
this._connectionState = s;
|
|
387
|
+
this.emit('connection-state', s);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ─── Connectivity check scheduling ────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
private _tick(): void {
|
|
393
|
+
// Pick next Waiting pair; if none, unfreeze the highest-priority Frozen pair
|
|
394
|
+
// (RFC 8445 §6.1.4.2 — must keep making progress even when no Waiting pairs)
|
|
395
|
+
let next = this._candidatePairs.find(
|
|
396
|
+
(p) => p.state === CandidatePairState.Waiting,
|
|
397
|
+
);
|
|
398
|
+
if (!next) {
|
|
399
|
+
const frozen = this._candidatePairs.find(
|
|
400
|
+
(p) => p.state === CandidatePairState.Frozen,
|
|
401
|
+
);
|
|
402
|
+
if (frozen) {
|
|
403
|
+
frozen.state = CandidatePairState.Waiting;
|
|
404
|
+
next = frozen;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (next) {
|
|
408
|
+
next.state = CandidatePairState.InProgress;
|
|
409
|
+
next.retransmitCount = 0;
|
|
410
|
+
const aggressive =
|
|
411
|
+
this._role === 'controlling' && this._nomination === 'aggressive';
|
|
412
|
+
this._doCheck(next, aggressive);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
this._checkAllFailed();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private _doCheck(pair: CandidatePair, useCandidate: boolean): void {
|
|
419
|
+
if (!this._transport || !this._remoteParameters) return;
|
|
420
|
+
|
|
421
|
+
const txId = generateTransactionId();
|
|
422
|
+
const txIdHex = txId.toString('hex');
|
|
423
|
+
|
|
424
|
+
const signedBuf = this._buildSignedRequest(txId, pair, useCandidate);
|
|
425
|
+
|
|
426
|
+
// Retransmit schedule: send at t=0, 200ms, 600ms, 1400ms; timeout at 3800ms
|
|
427
|
+
const timers: NodeJS.Timeout[] = [];
|
|
428
|
+
const retransmitDelays = [200, 600, 1400];
|
|
429
|
+
|
|
430
|
+
// Send immediately
|
|
431
|
+
this._sendBuf(signedBuf, pair.remote.address, pair.remote.port);
|
|
432
|
+
|
|
433
|
+
for (const delay of retransmitDelays) {
|
|
434
|
+
const t = setTimeout(() => {
|
|
435
|
+
if (this._pending.has(txIdHex)) {
|
|
436
|
+
this._sendBuf(signedBuf, pair.remote.address, pair.remote.port);
|
|
437
|
+
}
|
|
438
|
+
}, delay);
|
|
439
|
+
t.unref?.();
|
|
440
|
+
timers.push(t);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Final timeout
|
|
444
|
+
const finalTimer = setTimeout(() => {
|
|
445
|
+
if (this._pending.has(txIdHex)) {
|
|
446
|
+
this._pending.delete(txIdHex);
|
|
447
|
+
this._onCheckTimeout(pair);
|
|
448
|
+
}
|
|
449
|
+
}, 3800);
|
|
450
|
+
finalTimer.unref?.();
|
|
451
|
+
timers.push(finalTimer);
|
|
452
|
+
|
|
453
|
+
this._pending.set(txIdHex, { pair, signedBuf, useCandidate, timers });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private _sendBuf(buf: Buffer, address: string, port: number): void {
|
|
457
|
+
this._transport?.send(buf, port, address).catch(() => {});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private _buildSignedRequest(
|
|
461
|
+
txId: Buffer,
|
|
462
|
+
pair: CandidatePair,
|
|
463
|
+
useCandidate: boolean,
|
|
464
|
+
): Buffer {
|
|
465
|
+
const remoteParams = this._remoteParameters!;
|
|
466
|
+
const req = createBindingRequest(txId);
|
|
467
|
+
|
|
468
|
+
req.attributes.push({
|
|
469
|
+
type: AttributeType.Username,
|
|
470
|
+
value: encodeUsername(
|
|
471
|
+
`${remoteParams.usernameFragment}:${this.localParameters.usernameFragment}`,
|
|
472
|
+
),
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const prflxPriority = computePriority('prflx', 65535, pair.local.component);
|
|
476
|
+
req.attributes.push({
|
|
477
|
+
type: AttributeType.Priority,
|
|
478
|
+
value: encodePriority(prflxPriority),
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
if (useCandidate) {
|
|
482
|
+
req.attributes.push({
|
|
483
|
+
type: AttributeType.UseCandidate,
|
|
484
|
+
value: encodeUseCandidate(),
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (this._role === 'controlling') {
|
|
489
|
+
req.attributes.push({
|
|
490
|
+
type: AttributeType.IceControlling,
|
|
491
|
+
value: encodeIceControlling(this._tiebreaker),
|
|
492
|
+
});
|
|
493
|
+
} else {
|
|
494
|
+
req.attributes.push({
|
|
495
|
+
type: AttributeType.IceControlled,
|
|
496
|
+
value: encodeIceControlled(this._tiebreaker),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return this._appendMIAndFP(encodeMessage(req), remoteParams.password);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Append MESSAGE-INTEGRITY + FINGERPRINT to an already-encoded STUN buffer
|
|
504
|
+
* (RFC 5389 §15.4 + §15.5 — both required for ICE checks per RFC 8445) */
|
|
505
|
+
private _appendMIAndFP(partialBuf: Buffer, password: string): Buffer {
|
|
506
|
+
// Step 1: compute MI with length field reflecting MI TLV (24 bytes)
|
|
507
|
+
const withMiLen = Buffer.from(partialBuf);
|
|
508
|
+
withMiLen.writeUInt16BE(partialBuf.readUInt16BE(2) + 24, 2);
|
|
509
|
+
|
|
510
|
+
const key = Buffer.from(password, 'utf8');
|
|
511
|
+
const hmac = computeMessageIntegrity(withMiLen, key);
|
|
512
|
+
|
|
513
|
+
const miTlv = Buffer.alloc(24);
|
|
514
|
+
miTlv.writeUInt16BE(AttributeType.MessageIntegrity, 0);
|
|
515
|
+
miTlv.writeUInt16BE(20, 2);
|
|
516
|
+
hmac.copy(miTlv, 4);
|
|
517
|
+
|
|
518
|
+
// Append MESSAGE-INTEGRITY
|
|
519
|
+
const withMi = Buffer.concat([partialBuf, miTlv]);
|
|
520
|
+
withMi.writeUInt16BE(partialBuf.readUInt16BE(2) + miTlv.length, 2);
|
|
521
|
+
|
|
522
|
+
// Step 2: append FINGERPRINT (RFC 5389 §15.5)
|
|
523
|
+
const fpTlvSize = 8; // 2 type + 2 len + 4 value
|
|
524
|
+
const withFpLen = Buffer.from(withMi);
|
|
525
|
+
withFpLen.writeUInt16BE(withMi.readUInt16BE(2) + fpTlvSize, 2);
|
|
526
|
+
const crc = computeFingerprint(withFpLen);
|
|
527
|
+
|
|
528
|
+
const fpTlv = Buffer.alloc(fpTlvSize);
|
|
529
|
+
fpTlv.writeUInt16BE(AttributeType.Fingerprint, 0);
|
|
530
|
+
fpTlv.writeUInt16BE(4, 2);
|
|
531
|
+
fpTlv.writeUInt32BE(crc, 4);
|
|
532
|
+
|
|
533
|
+
const result = Buffer.concat([withMi, fpTlv]);
|
|
534
|
+
result.writeUInt16BE(withMi.readUInt16BE(2) + fpTlvSize, 2);
|
|
535
|
+
return result;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ─── Response handling ────────────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
private _onStunResponse(msg: StunMessage): void {
|
|
541
|
+
const txIdHex = msg.transactionId.toString('hex');
|
|
542
|
+
const entry = this._pending.get(txIdHex);
|
|
543
|
+
if (!entry) {
|
|
544
|
+
console.log(`[ICE] stun response txId=${txIdHex.slice(0,8)} — no pending entry (stale?)`);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
this._pending.delete(txIdHex);
|
|
549
|
+
for (const t of entry.timers) clearTimeout(t);
|
|
550
|
+
|
|
551
|
+
const { pair, useCandidate } = entry;
|
|
552
|
+
|
|
553
|
+
if (msg.messageClass === MessageClass.ErrorResponse) {
|
|
554
|
+
const errAttr = msg.attributes.find((a: StunAttribute) => a.type === 0x0009);
|
|
555
|
+
const errCode = errAttr ? errAttr.value.readUInt8(3) + (errAttr.value.readUInt8(2) & 0x7) * 100 : 0;
|
|
556
|
+
console.log(`[ICE] error response for ${pair.local.address} -> ${pair.remote.address}:${pair.remote.port} code=${errCode}`);
|
|
557
|
+
pair.state = CandidatePairState.Failed;
|
|
558
|
+
this._checkAllFailed();
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (msg.messageClass !== MessageClass.SuccessResponse) return;
|
|
563
|
+
|
|
564
|
+
pair.state = CandidatePairState.Succeeded;
|
|
565
|
+
pair.valid = true;
|
|
566
|
+
pair.lastBindingResponseReceived = Date.now();
|
|
567
|
+
console.log(`[ICE] success: ${pair.local.address}:${pair.local.port} -> ${pair.remote.address}:${pair.remote.port}`);
|
|
568
|
+
|
|
569
|
+
// RFC 8445 §7.2.5.2.1 – discover local peer-reflexive candidate
|
|
570
|
+
// The XOR-MAPPED-ADDRESS tells us our own address as seen by the remote.
|
|
571
|
+
// If it differs from any known local candidate, add a prflx local candidate.
|
|
572
|
+
const xorAttr = msg.attributes.find(
|
|
573
|
+
(a: StunAttribute) => a.type === AttributeType.XorMappedAddress,
|
|
574
|
+
);
|
|
575
|
+
if (xorAttr) {
|
|
576
|
+
try {
|
|
577
|
+
const mapped = decodeXorMappedAddress(xorAttr.value, msg.transactionId);
|
|
578
|
+
const alreadyKnown = this._localCandidates.some(
|
|
579
|
+
(c) => c.address === mapped.address && c.port === mapped.port,
|
|
580
|
+
);
|
|
581
|
+
if (!alreadyKnown) {
|
|
582
|
+
const base = pair.local;
|
|
583
|
+
const prflxPriority = computePriority('prflx', 65535, base.component);
|
|
584
|
+
const prflx: IceCandidate = {
|
|
585
|
+
foundation: computeFoundation('prflx', mapped.address, 'udp'),
|
|
586
|
+
component: base.component,
|
|
587
|
+
transport: 'udp',
|
|
588
|
+
priority: prflxPriority,
|
|
589
|
+
address: mapped.address,
|
|
590
|
+
port: mapped.port,
|
|
591
|
+
type: 'prflx',
|
|
592
|
+
};
|
|
593
|
+
this._localCandidates.push(prflx);
|
|
594
|
+
// Update the pair to use the prflx local candidate
|
|
595
|
+
pair.local = prflx;
|
|
596
|
+
}
|
|
597
|
+
} catch {
|
|
598
|
+
// ignore decode errors
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
this._handlePairSucceeded(pair, useCandidate);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private _handlePairSucceeded(
|
|
606
|
+
pair: CandidatePair,
|
|
607
|
+
usedCandidateFlag: boolean,
|
|
608
|
+
): void {
|
|
609
|
+
if (this._role === 'controlling') {
|
|
610
|
+
if (
|
|
611
|
+
this._nomination === 'aggressive' ||
|
|
612
|
+
usedCandidateFlag ||
|
|
613
|
+
pair.nominateOnSuccess
|
|
614
|
+
) {
|
|
615
|
+
this._nominatePair(pair);
|
|
616
|
+
} else if (!this._nomineePair) {
|
|
617
|
+
// Regular nomination: re-send with USE-CANDIDATE
|
|
618
|
+
pair.nominateOnSuccess = true;
|
|
619
|
+
pair.state = CandidatePairState.Waiting;
|
|
620
|
+
pair.retransmitCount = 0;
|
|
621
|
+
setImmediate(() => {
|
|
622
|
+
if (pair.state === CandidatePairState.Waiting) {
|
|
623
|
+
pair.state = CandidatePairState.InProgress;
|
|
624
|
+
pair.retransmitCount = 0;
|
|
625
|
+
this._doCheck(pair, true);
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
} else {
|
|
630
|
+
// Controlled: nominate if USE-CANDIDATE was set on this pair
|
|
631
|
+
if (pair.nominateOnSuccess) {
|
|
632
|
+
this._nominatePair(pair);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private _onCheckTimeout(pair: CandidatePair): void {
|
|
638
|
+
console.log(`[ICE] timeout: ${pair.local.address}:${pair.local.port} -> ${pair.remote.address}:${pair.remote.port}`);
|
|
639
|
+
if (
|
|
640
|
+
pair.state !== CandidatePairState.Succeeded &&
|
|
641
|
+
pair.state !== CandidatePairState.Failed
|
|
642
|
+
) {
|
|
643
|
+
pair.state = CandidatePairState.Failed;
|
|
644
|
+
}
|
|
645
|
+
this._checkAllFailed();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ─── Binding request handling ─────────────────────────────────────────────
|
|
649
|
+
|
|
650
|
+
private _onBindingRequest(
|
|
651
|
+
rawBuf: Buffer,
|
|
652
|
+
msg: StunMessage,
|
|
653
|
+
rinfo: dgram.RemoteInfo,
|
|
654
|
+
): void {
|
|
655
|
+
if (!this._transport) return;
|
|
656
|
+
|
|
657
|
+
// Verify MESSAGE-INTEGRITY
|
|
658
|
+
if (
|
|
659
|
+
!verifyMessageIntegrity(
|
|
660
|
+
rawBuf,
|
|
661
|
+
Buffer.from(this.localParameters.password, 'utf8'),
|
|
662
|
+
)
|
|
663
|
+
) {
|
|
664
|
+
this._sendError(msg, 401, 'Unauthorized', rinfo);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Verify USERNAME
|
|
669
|
+
const usernameAttr = msg.attributes.find(
|
|
670
|
+
(a: StunAttribute) => a.type === AttributeType.Username,
|
|
671
|
+
);
|
|
672
|
+
if (!usernameAttr) {
|
|
673
|
+
this._sendError(msg, 400, 'Bad Request', rinfo);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const username = decodeUsername(usernameAttr.value);
|
|
677
|
+
const colonIdx = username.indexOf(':');
|
|
678
|
+
const localUfragInMsg =
|
|
679
|
+
colonIdx >= 0 ? username.slice(0, colonIdx) : username;
|
|
680
|
+
if (localUfragInMsg !== this.localParameters.usernameFragment) {
|
|
681
|
+
this._sendError(msg, 401, 'Unauthorized', rinfo);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const useCandidate = msg.attributes.some(
|
|
686
|
+
(a: StunAttribute) => a.type === AttributeType.UseCandidate,
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
// Send success response
|
|
690
|
+
this._sendBindingResponse(msg, rinfo);
|
|
691
|
+
|
|
692
|
+
// Find best local candidate
|
|
693
|
+
const localPort = this._transport.localPort;
|
|
694
|
+
const localCandidate =
|
|
695
|
+
this._localCandidates.find((c) => c.port === localPort) ??
|
|
696
|
+
this._localCandidates[0];
|
|
697
|
+
|
|
698
|
+
if (!localCandidate) return;
|
|
699
|
+
|
|
700
|
+
// Find or create remote candidate
|
|
701
|
+
let remoteCandidate = this._remoteCandidates.find(
|
|
702
|
+
(c) => c.address === rinfo.address && c.port === rinfo.port,
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
if (!remoteCandidate) {
|
|
706
|
+
const priorityAttr = msg.attributes.find(
|
|
707
|
+
(a: StunAttribute) => a.type === AttributeType.Priority,
|
|
708
|
+
);
|
|
709
|
+
const prflxPriority = priorityAttr
|
|
710
|
+
? decodePriority(priorityAttr.value)
|
|
711
|
+
: computePriority('prflx', 65535, localCandidate.component);
|
|
712
|
+
|
|
713
|
+
remoteCandidate = {
|
|
714
|
+
foundation: computeFoundation('prflx', rinfo.address, 'udp'),
|
|
715
|
+
component: localCandidate.component,
|
|
716
|
+
transport: 'udp',
|
|
717
|
+
priority: prflxPriority,
|
|
718
|
+
address: rinfo.address,
|
|
719
|
+
port: rinfo.port,
|
|
720
|
+
type: 'prflx',
|
|
721
|
+
};
|
|
722
|
+
this._remoteCandidates.push(remoteCandidate);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const { pair, isNew } = getOrCreatePair(
|
|
726
|
+
this._candidatePairs,
|
|
727
|
+
localCandidate,
|
|
728
|
+
remoteCandidate,
|
|
729
|
+
this._role,
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
if (isNew) {
|
|
733
|
+
this._candidatePairs.push(pair);
|
|
734
|
+
this._sortPairs();
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
pair.lastBindingRequestReceived = Date.now();
|
|
738
|
+
|
|
739
|
+
if (this._role === 'controlled' && useCandidate) {
|
|
740
|
+
pair.nominateOnSuccess = true;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Triggered check
|
|
744
|
+
if (
|
|
745
|
+
pair.state !== CandidatePairState.InProgress &&
|
|
746
|
+
pair.state !== CandidatePairState.Succeeded
|
|
747
|
+
) {
|
|
748
|
+
pair.state = CandidatePairState.Waiting;
|
|
749
|
+
pair.retransmitCount = 0;
|
|
750
|
+
setImmediate(() => {
|
|
751
|
+
if (pair.state === CandidatePairState.Waiting) {
|
|
752
|
+
pair.state = CandidatePairState.InProgress;
|
|
753
|
+
pair.retransmitCount = 0;
|
|
754
|
+
this._doCheck(pair, false);
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Controlled: nominate immediately if USE-CANDIDATE and pair already succeeded
|
|
760
|
+
if (
|
|
761
|
+
this._role === 'controlled' &&
|
|
762
|
+
useCandidate &&
|
|
763
|
+
pair.state === CandidatePairState.Succeeded
|
|
764
|
+
) {
|
|
765
|
+
this._nominatePair(pair);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
private _sendBindingResponse(
|
|
770
|
+
msg: StunMessage,
|
|
771
|
+
rinfo: dgram.RemoteInfo,
|
|
772
|
+
): void {
|
|
773
|
+
if (!this._transport) return;
|
|
774
|
+
|
|
775
|
+
const xorValue = encodeXorMappedAddress(
|
|
776
|
+
{ family: 4, port: rinfo.port, address: rinfo.address },
|
|
777
|
+
msg.transactionId,
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
const resp: StunMessage = {
|
|
781
|
+
messageClass: MessageClass.SuccessResponse,
|
|
782
|
+
messageMethod: MessageMethod.Binding,
|
|
783
|
+
transactionId: Buffer.from(msg.transactionId),
|
|
784
|
+
attributes: [{ type: AttributeType.XorMappedAddress, value: xorValue }],
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
const partial = encodeMessage(resp);
|
|
788
|
+
const signed = this._appendMIAndFP(partial, this.localParameters.password);
|
|
789
|
+
this._transport.send(signed, rinfo.port, rinfo.address).catch(() => {});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
private _sendError(
|
|
793
|
+
request: StunMessage,
|
|
794
|
+
code: number,
|
|
795
|
+
reason: string,
|
|
796
|
+
rinfo: dgram.RemoteInfo,
|
|
797
|
+
): void {
|
|
798
|
+
if (!this._transport) return;
|
|
799
|
+
|
|
800
|
+
const resp: StunMessage = {
|
|
801
|
+
messageClass: MessageClass.ErrorResponse,
|
|
802
|
+
messageMethod: MessageMethod.Binding,
|
|
803
|
+
transactionId: Buffer.from(request.transactionId),
|
|
804
|
+
attributes: [
|
|
805
|
+
{
|
|
806
|
+
type: AttributeType.ErrorCode,
|
|
807
|
+
value: encodeErrorCode({ code, reason }),
|
|
808
|
+
},
|
|
809
|
+
],
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
this._transport
|
|
813
|
+
.send(encodeMessage(resp), rinfo.port, rinfo.address)
|
|
814
|
+
.catch(() => {});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ─── Packet demux ─────────────────────────────────────────────────────────
|
|
818
|
+
|
|
819
|
+
private _handleStunPacket(rawBuf: Buffer, rinfo: dgram.RemoteInfo): void {
|
|
820
|
+
if (!isStunMessage(rawBuf)) return;
|
|
821
|
+
|
|
822
|
+
let msg: StunMessage;
|
|
823
|
+
try {
|
|
824
|
+
msg = decodeMessage(rawBuf);
|
|
825
|
+
} catch {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (msg.messageMethod !== MessageMethod.Binding) return;
|
|
830
|
+
|
|
831
|
+
if (msg.messageClass === MessageClass.Request) {
|
|
832
|
+
this._onBindingRequest(rawBuf, msg, rinfo);
|
|
833
|
+
} else if (
|
|
834
|
+
msg.messageClass === MessageClass.SuccessResponse ||
|
|
835
|
+
msg.messageClass === MessageClass.ErrorResponse
|
|
836
|
+
) {
|
|
837
|
+
this._onStunResponse(msg);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ─── Nomination ───────────────────────────────────────────────────────────
|
|
842
|
+
|
|
843
|
+
private _nominatePair(pair: CandidatePair): void {
|
|
844
|
+
if (this._nomineePair) return;
|
|
845
|
+
|
|
846
|
+
pair.nominated = true;
|
|
847
|
+
this._nomineePair = pair;
|
|
848
|
+
|
|
849
|
+
this._stopChecks();
|
|
850
|
+
this._clearFailTimer();
|
|
851
|
+
|
|
852
|
+
this._setConnectionState(IceConnectionState.Connected);
|
|
853
|
+
this.emit('connected', pair);
|
|
854
|
+
|
|
855
|
+
const resolve = this._connectResolve;
|
|
856
|
+
this._connectResolve = undefined;
|
|
857
|
+
this._connectReject = undefined;
|
|
858
|
+
resolve?.(pair);
|
|
859
|
+
|
|
860
|
+
this._startKeepAlive();
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// ─── All-failed detection ─────────────────────────────────────────────────
|
|
864
|
+
|
|
865
|
+
private _checkAllFailed(): void {
|
|
866
|
+
if (this._nomineePair) return;
|
|
867
|
+
if (this._connectionState !== IceConnectionState.Checking) return;
|
|
868
|
+
|
|
869
|
+
// Don't fail until we know all remote candidates (trickle ICE)
|
|
870
|
+
if (!this._remoteGatheringComplete) return;
|
|
871
|
+
|
|
872
|
+
const hasAlive = this._candidatePairs.some(
|
|
873
|
+
(p) =>
|
|
874
|
+
p.state === CandidatePairState.Waiting ||
|
|
875
|
+
p.state === CandidatePairState.InProgress ||
|
|
876
|
+
p.state === CandidatePairState.Frozen,
|
|
877
|
+
);
|
|
878
|
+
if (!hasAlive && this._pending.size === 0) {
|
|
879
|
+
this._setConnectionState(IceConnectionState.Failed);
|
|
880
|
+
this._stopChecks();
|
|
881
|
+
this._clearFailTimer();
|
|
882
|
+
|
|
883
|
+
const reject = this._connectReject;
|
|
884
|
+
this._connectReject = undefined;
|
|
885
|
+
this._connectResolve = undefined;
|
|
886
|
+
reject?.(new Error('All candidate pairs failed'));
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ─── Timer management ─────────────────────────────────────────────────────
|
|
891
|
+
|
|
892
|
+
private _sortPairs(): void {
|
|
893
|
+
this._candidatePairs.sort((a, b) => {
|
|
894
|
+
if (b.priority > a.priority) return 1;
|
|
895
|
+
if (b.priority < a.priority) return -1;
|
|
896
|
+
return 0;
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
private _stopChecks(): void {
|
|
901
|
+
if (this._checkInterval) {
|
|
902
|
+
clearInterval(this._checkInterval);
|
|
903
|
+
this._checkInterval = undefined;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private _clearFailTimer(): void {
|
|
908
|
+
if (this._failTimer) {
|
|
909
|
+
clearTimeout(this._failTimer);
|
|
910
|
+
this._failTimer = undefined;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
private _startKeepAlive(): void {
|
|
915
|
+
const timer = setInterval(() => {
|
|
916
|
+
if (this._nomineePair) {
|
|
917
|
+
this._doCheck(this._nomineePair, false);
|
|
918
|
+
}
|
|
919
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
920
|
+
timer.unref?.();
|
|
921
|
+
this._keepAliveTimer = timer;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
private _stopKeepAlive(): void {
|
|
925
|
+
if (this._keepAliveTimer) {
|
|
926
|
+
clearInterval(this._keepAliveTimer);
|
|
927
|
+
this._keepAliveTimer = undefined;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
private _cancelAllPending(): void {
|
|
932
|
+
for (const entry of this._pending.values()) {
|
|
933
|
+
for (const t of entry.timers) clearTimeout(t);
|
|
934
|
+
}
|
|
935
|
+
this._pending.clear();
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
private _formPairsForRemote(remote: IceCandidate): void {
|
|
939
|
+
for (const local of this._localCandidates) {
|
|
940
|
+
if (local.component !== remote.component) continue;
|
|
941
|
+
const exists = findPairByAddresses(
|
|
942
|
+
this._candidatePairs,
|
|
943
|
+
local.address,
|
|
944
|
+
local.port,
|
|
945
|
+
remote.address,
|
|
946
|
+
remote.port,
|
|
947
|
+
);
|
|
948
|
+
if (!exists) {
|
|
949
|
+
const { pair } = getOrCreatePair(
|
|
950
|
+
this._candidatePairs,
|
|
951
|
+
local,
|
|
952
|
+
remote,
|
|
953
|
+
this._role,
|
|
954
|
+
);
|
|
955
|
+
this._candidatePairs.push(pair);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
this._sortPairs();
|
|
959
|
+
unfreezeInitialPairs(this._candidatePairs);
|
|
960
|
+
}
|
|
961
|
+
}
|