@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/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
+ }