@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/candidate.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import type { CandidateType, IceCandidate, TransportProtocol } from './types.js';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// RFC 8445 Section 5.1.2.1 – Candidate priority
|
|
6
|
+
// priority = (2^24) * type_pref + (2^8) * local_pref + (256 - component)
|
|
7
|
+
// type_pref: host=126, srflx=100, prflx=110, relay=0
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const TYPE_PREFERENCES: Record<CandidateType, number> = {
|
|
11
|
+
host: 126,
|
|
12
|
+
prflx: 110,
|
|
13
|
+
srflx: 100,
|
|
14
|
+
relay: 0,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function computePriority(
|
|
18
|
+
type: CandidateType,
|
|
19
|
+
localPref: number,
|
|
20
|
+
component: number,
|
|
21
|
+
): number {
|
|
22
|
+
const typePref = TYPE_PREFERENCES[type];
|
|
23
|
+
return ((1 << 24) * typePref + (1 << 8) * localPref + (256 - component)) >>> 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// RFC 8445 Section 5.1.1 – Candidate foundation
|
|
28
|
+
// Foundation is a string that groups candidates that have the same type,
|
|
29
|
+
// base address, protocol and STUN/TURN server.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export function computeFoundation(
|
|
33
|
+
type: CandidateType,
|
|
34
|
+
baseAddress: string,
|
|
35
|
+
protocol: TransportProtocol,
|
|
36
|
+
): string {
|
|
37
|
+
const input = `${type}:${baseAddress}:${protocol}`;
|
|
38
|
+
return crypto.createHash('md5').update(input).digest('hex').slice(0, 8);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// RFC 8445 Section 6.1.2.3 – Candidate pair priority
|
|
43
|
+
// priority = 2^32 * min(G,D) + 2*max(G,D) + (G>D ? 1 : 0)
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export function computePairPriority(
|
|
47
|
+
controlling: IceCandidate,
|
|
48
|
+
controlled: IceCandidate,
|
|
49
|
+
): bigint {
|
|
50
|
+
const g = BigInt(controlling.priority >>> 0);
|
|
51
|
+
const d = BigInt(controlled.priority >>> 0);
|
|
52
|
+
const minVal = g < d ? g : d;
|
|
53
|
+
const maxVal = g > d ? g : d;
|
|
54
|
+
const gtFlag = g > d ? 1n : 0n;
|
|
55
|
+
return (1n << 32n) * minVal + 2n * maxVal + gtFlag;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Parse candidate from SDP attribute value string
|
|
60
|
+
// a=candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> [...]
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
export function parseCandidateAttribute(value: string): IceCandidate {
|
|
64
|
+
const parts = value.trim().split(/\s+/);
|
|
65
|
+
|
|
66
|
+
if (parts.length < 8) {
|
|
67
|
+
throw new Error(`Invalid candidate attribute: ${value}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const foundation = parts[0]!;
|
|
71
|
+
const component = parseInt(parts[1]!, 10) as 1 | 2;
|
|
72
|
+
const transport = parts[2]!.toLowerCase() as TransportProtocol;
|
|
73
|
+
const priority = parseInt(parts[3]!, 10);
|
|
74
|
+
const address = parts[4]!;
|
|
75
|
+
const port = parseInt(parts[5]!, 10);
|
|
76
|
+
// parts[6] should be 'typ'
|
|
77
|
+
const type = parts[7]! as CandidateType;
|
|
78
|
+
|
|
79
|
+
const candidate: IceCandidate = {
|
|
80
|
+
foundation,
|
|
81
|
+
component,
|
|
82
|
+
transport,
|
|
83
|
+
priority,
|
|
84
|
+
address,
|
|
85
|
+
port,
|
|
86
|
+
type,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Parse optional extension attributes
|
|
90
|
+
let i = 8;
|
|
91
|
+
while (i < parts.length - 1) {
|
|
92
|
+
const key = parts[i]!;
|
|
93
|
+
const val = parts[i + 1]!;
|
|
94
|
+
switch (key) {
|
|
95
|
+
case 'raddr':
|
|
96
|
+
candidate.relatedAddress = val;
|
|
97
|
+
break;
|
|
98
|
+
case 'rport':
|
|
99
|
+
candidate.relatedPort = parseInt(val, 10);
|
|
100
|
+
break;
|
|
101
|
+
case 'tcptype':
|
|
102
|
+
candidate.tcpType = val as 'active' | 'passive' | 'so';
|
|
103
|
+
break;
|
|
104
|
+
case 'generation':
|
|
105
|
+
candidate.generation = parseInt(val, 10);
|
|
106
|
+
break;
|
|
107
|
+
case 'ufrag':
|
|
108
|
+
candidate.ufrag = val;
|
|
109
|
+
break;
|
|
110
|
+
case 'network-id':
|
|
111
|
+
candidate.networkId = parseInt(val, 10);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
i += 2;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return candidate;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Serialize candidate to SDP attribute value
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
export function serializeCandidateAttribute(candidate: IceCandidate): string {
|
|
125
|
+
let s =
|
|
126
|
+
`${candidate.foundation} ${candidate.component} ${candidate.transport} ` +
|
|
127
|
+
`${candidate.priority} ${candidate.address} ${candidate.port} typ ${candidate.type}`;
|
|
128
|
+
|
|
129
|
+
if (candidate.relatedAddress !== undefined) {
|
|
130
|
+
s += ` raddr ${candidate.relatedAddress}`;
|
|
131
|
+
}
|
|
132
|
+
if (candidate.relatedPort !== undefined) {
|
|
133
|
+
s += ` rport ${candidate.relatedPort}`;
|
|
134
|
+
}
|
|
135
|
+
if (candidate.tcpType !== undefined) {
|
|
136
|
+
s += ` tcptype ${candidate.tcpType}`;
|
|
137
|
+
}
|
|
138
|
+
if (candidate.generation !== undefined) {
|
|
139
|
+
s += ` generation ${candidate.generation}`;
|
|
140
|
+
}
|
|
141
|
+
if (candidate.ufrag !== undefined) {
|
|
142
|
+
s += ` ufrag ${candidate.ufrag}`;
|
|
143
|
+
}
|
|
144
|
+
if (candidate.networkId !== undefined) {
|
|
145
|
+
s += ` network-id ${candidate.networkId}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return s;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Generate random ICE ufrag and password
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
const ICE_CHARS =
|
|
156
|
+
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
157
|
+
|
|
158
|
+
function randomIceString(length: number): string {
|
|
159
|
+
const bytes = crypto.randomBytes(length);
|
|
160
|
+
let result = '';
|
|
161
|
+
for (let i = 0; i < length; i++) {
|
|
162
|
+
result += ICE_CHARS[bytes[i]! % ICE_CHARS.length];
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 4 chars minimum per RFC 5245 §15.4
|
|
168
|
+
export function generateUfrag(): string {
|
|
169
|
+
return randomIceString(4);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 22 chars minimum per RFC 5245 §15.4
|
|
173
|
+
export function generatePassword(): string {
|
|
174
|
+
return randomIceString(22);
|
|
175
|
+
}
|
package/src/checklist.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CandidatePair,
|
|
3
|
+
IceCandidate,
|
|
4
|
+
IceRole,
|
|
5
|
+
} from './types.js';
|
|
6
|
+
import { CandidatePairState } from './types.js';
|
|
7
|
+
import { computePairPriority } from './candidate.js';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Pair ID generation
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export function makePairId(local: IceCandidate, remote: IceCandidate): string {
|
|
14
|
+
return `${local.foundation}:${local.port}|${remote.foundation}:${remote.port}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Form candidate pairs from local × remote candidates (same component)
|
|
19
|
+
// sorted descending by priority
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export function formCandidatePairs(
|
|
23
|
+
localCandidates: IceCandidate[],
|
|
24
|
+
remoteCandidates: IceCandidate[],
|
|
25
|
+
role: IceRole,
|
|
26
|
+
): CandidatePair[] {
|
|
27
|
+
const pairs: CandidatePair[] = [];
|
|
28
|
+
|
|
29
|
+
for (const local of localCandidates) {
|
|
30
|
+
for (const remote of remoteCandidates) {
|
|
31
|
+
if (local.component !== remote.component) continue;
|
|
32
|
+
// ts-rtc uses UDP-only transport; skip TCP remote candidates
|
|
33
|
+
if (remote.transport !== 'udp') continue;
|
|
34
|
+
if (local.transport !== 'udp') continue;
|
|
35
|
+
// ts-rtc binds a udp4 socket; IPv6 candidates will silently fail — skip them
|
|
36
|
+
if (remote.address.includes(':')) continue;
|
|
37
|
+
|
|
38
|
+
const controlling = role === 'controlling' ? local : remote;
|
|
39
|
+
const controlled = role === 'controlling' ? remote : local;
|
|
40
|
+
const priority = computePairPriority(controlling, controlled);
|
|
41
|
+
|
|
42
|
+
const pair: CandidatePair = {
|
|
43
|
+
id: makePairId(local, remote),
|
|
44
|
+
local,
|
|
45
|
+
remote,
|
|
46
|
+
state: CandidatePairState.Frozen,
|
|
47
|
+
priority,
|
|
48
|
+
nominated: false,
|
|
49
|
+
valid: false,
|
|
50
|
+
nominateOnSuccess: false,
|
|
51
|
+
retransmitCount: 0,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
pairs.push(pair);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Sort by priority descending
|
|
59
|
+
pairs.sort((a, b) => {
|
|
60
|
+
if (b.priority > a.priority) return 1;
|
|
61
|
+
if (b.priority < a.priority) return -1;
|
|
62
|
+
return 0;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return pairs;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Unfreeze the highest priority pair per component (RFC 8445 §6.1.2.6)
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
export function unfreezeInitialPairs(pairs: CandidatePair[]): void {
|
|
73
|
+
const seenComponents = new Set<number>();
|
|
74
|
+
for (const pair of pairs) {
|
|
75
|
+
if (
|
|
76
|
+
!seenComponents.has(pair.local.component) &&
|
|
77
|
+
pair.state === CandidatePairState.Frozen
|
|
78
|
+
) {
|
|
79
|
+
pair.state = CandidatePairState.Waiting;
|
|
80
|
+
seenComponents.add(pair.local.component);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Find pair by address/port tuples
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
export function findPairByAddresses(
|
|
90
|
+
pairs: CandidatePair[],
|
|
91
|
+
localAddr: string,
|
|
92
|
+
localPort: number,
|
|
93
|
+
remoteAddr: string,
|
|
94
|
+
remotePort: number,
|
|
95
|
+
): CandidatePair | undefined {
|
|
96
|
+
return pairs.find(
|
|
97
|
+
(p) =>
|
|
98
|
+
p.local.address === localAddr &&
|
|
99
|
+
p.local.port === localPort &&
|
|
100
|
+
p.remote.address === remoteAddr &&
|
|
101
|
+
p.remote.port === remotePort,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Get or create a candidate pair
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export function getOrCreatePair(
|
|
110
|
+
pairs: CandidatePair[],
|
|
111
|
+
local: IceCandidate,
|
|
112
|
+
remote: IceCandidate,
|
|
113
|
+
role: IceRole,
|
|
114
|
+
): { pair: CandidatePair; isNew: boolean } {
|
|
115
|
+
const existing = pairs.find(
|
|
116
|
+
(p) =>
|
|
117
|
+
p.local.address === local.address &&
|
|
118
|
+
p.local.port === local.port &&
|
|
119
|
+
p.remote.address === remote.address &&
|
|
120
|
+
p.remote.port === remote.port,
|
|
121
|
+
);
|
|
122
|
+
if (existing) return { pair: existing, isNew: false };
|
|
123
|
+
|
|
124
|
+
const controlling = role === 'controlling' ? local : remote;
|
|
125
|
+
const controlled = role === 'controlling' ? remote : local;
|
|
126
|
+
const priority = computePairPriority(controlling, controlled);
|
|
127
|
+
|
|
128
|
+
const pair: CandidatePair = {
|
|
129
|
+
id: makePairId(local, remote),
|
|
130
|
+
local,
|
|
131
|
+
remote,
|
|
132
|
+
state: CandidatePairState.Waiting,
|
|
133
|
+
priority,
|
|
134
|
+
nominated: false,
|
|
135
|
+
valid: false,
|
|
136
|
+
nominateOnSuccess: false,
|
|
137
|
+
retransmitCount: 0,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return { pair, isNew: true };
|
|
141
|
+
}
|
|
142
|
+
|
package/src/gather.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import * as os from 'node:os';
|
|
2
|
+
import * as dgram from 'node:dgram';
|
|
3
|
+
import {
|
|
4
|
+
AttributeType,
|
|
5
|
+
MessageClass,
|
|
6
|
+
decodeMessage,
|
|
7
|
+
encodeMessage,
|
|
8
|
+
createBindingRequest,
|
|
9
|
+
isStunMessage,
|
|
10
|
+
decodeXorMappedAddress,
|
|
11
|
+
} from '@agentdance/node-webrtc-stun';
|
|
12
|
+
import type { StunAttribute } from '@agentdance/node-webrtc-stun';
|
|
13
|
+
import { computeFoundation, computePriority } from './candidate.js';
|
|
14
|
+
import type { IceCandidate, TransportProtocol } from './types.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Interface tier classification
|
|
18
|
+
//
|
|
19
|
+
// Mirrors pion/ice's approach (RFC 8421 §4.1 "Interface Type Preferences"):
|
|
20
|
+
// loopback → tier 0 (highest localPref base = 65535)
|
|
21
|
+
// physical → tier 1 (Ethernet / WiFi)
|
|
22
|
+
// virtual → tier 2 (VPN tunnels, Docker bridges, VM adapters)
|
|
23
|
+
//
|
|
24
|
+
// Within each tier, interfaces are assigned descending localPref values so
|
|
25
|
+
// the first enumerated interface of each tier gets the highest priority.
|
|
26
|
+
// localPref for interface i = tierBase - i, where tierBase is spaced 1024
|
|
27
|
+
// apart so tiers never overlap: loopback ≥ 65535, physical 64511..63488,
|
|
28
|
+
// virtual 63487..62464.
|
|
29
|
+
//
|
|
30
|
+
// This ensures:
|
|
31
|
+
// loopback↔loopback pair > physical↔physical pair > virtual pair
|
|
32
|
+
// (all host candidates; type_pref=126 is the same for all)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const TIER_BASE: Record<0 | 1 | 2, number> = {
|
|
36
|
+
0: 65535, // loopback
|
|
37
|
+
1: 64511, // physical (Ethernet / WiFi)
|
|
38
|
+
2: 63487, // virtual (VPN, Docker, VM)
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Heuristics for "virtual" interface detection that work across platforms
|
|
42
|
+
// without requiring OS-specific syscalls.
|
|
43
|
+
const VIRTUAL_PREFIXES = [
|
|
44
|
+
'docker',
|
|
45
|
+
'br-', // Docker bridge networks
|
|
46
|
+
'veth', // Docker container-side veth
|
|
47
|
+
'virbr', // libvirt bridges
|
|
48
|
+
'vmnet', // VMware host-only
|
|
49
|
+
'vboxnet', // VirtualBox host-only
|
|
50
|
+
'tun', // OpenVPN / WireGuard tun
|
|
51
|
+
'tap', // tap adapters
|
|
52
|
+
'utun', // macOS utun (WireGuard / built-in VPN)
|
|
53
|
+
'ipsec',
|
|
54
|
+
'ppp',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// On macOS, en0 is typically WiFi/Ethernet. We treat the generic "en"
|
|
58
|
+
// prefix as physical; everything else that is non-loopback, non-virtual
|
|
59
|
+
// is also treated as physical.
|
|
60
|
+
function classifyInterface(name: string): 0 | 1 | 2 {
|
|
61
|
+
if (name === 'lo' || name === 'lo0') return 0;
|
|
62
|
+
for (const prefix of VIRTUAL_PREFIXES) {
|
|
63
|
+
if (name.startsWith(prefix)) return 2;
|
|
64
|
+
}
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Local address enumeration (includes loopback, excludes IPv6)
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
export interface LocalAddress {
|
|
73
|
+
address: string;
|
|
74
|
+
family: 4 | 6;
|
|
75
|
+
name: string;
|
|
76
|
+
tier: 0 | 1 | 2;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getLocalAddresses(): LocalAddress[] {
|
|
80
|
+
const ifaces = os.networkInterfaces();
|
|
81
|
+
const result: LocalAddress[] = [];
|
|
82
|
+
|
|
83
|
+
for (const [name, addrs] of Object.entries(ifaces)) {
|
|
84
|
+
if (!addrs) continue;
|
|
85
|
+
for (const addr of addrs) {
|
|
86
|
+
// IPv4 only – ts-rtc binds udp4 sockets
|
|
87
|
+
if (addr.family !== 'IPv4') continue;
|
|
88
|
+
const tier = addr.internal ? 0 : classifyInterface(name);
|
|
89
|
+
result.push({ address: addr.address, family: 4, name, tier });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Gather host candidates from all local interfaces
|
|
98
|
+
//
|
|
99
|
+
// Candidates are sorted by tier (loopback first) and within a tier by
|
|
100
|
+
// enumeration order. localPref is assigned so that loopback always gets the
|
|
101
|
+
// highest value, physical interfaces are in the middle, and virtual/VPN
|
|
102
|
+
// interfaces are lowest – matching pion's RFC 8421 §4.1 intent.
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
export async function gatherHostCandidates(
|
|
106
|
+
port: number,
|
|
107
|
+
component: 1 | 2,
|
|
108
|
+
protocol: TransportProtocol,
|
|
109
|
+
): Promise<IceCandidate[]> {
|
|
110
|
+
const addrs = getLocalAddresses();
|
|
111
|
+
|
|
112
|
+
// Sort: loopback(0) < physical(1) < virtual(2) so localPref decrements
|
|
113
|
+
// in the right order within each tier.
|
|
114
|
+
addrs.sort((a, b) => a.tier - b.tier);
|
|
115
|
+
|
|
116
|
+
// Track per-tier counter for localPref assignment
|
|
117
|
+
const tierCount: Record<0 | 1 | 2, number> = { 0: 0, 1: 0, 2: 0 };
|
|
118
|
+
|
|
119
|
+
const candidates: IceCandidate[] = [];
|
|
120
|
+
|
|
121
|
+
for (const addr of addrs) {
|
|
122
|
+
const localPref = TIER_BASE[addr.tier] - tierCount[addr.tier];
|
|
123
|
+
tierCount[addr.tier]++;
|
|
124
|
+
|
|
125
|
+
candidates.push({
|
|
126
|
+
foundation: computeFoundation('host', addr.address, protocol),
|
|
127
|
+
component,
|
|
128
|
+
transport: protocol,
|
|
129
|
+
priority: computePriority('host', localPref, component),
|
|
130
|
+
address: addr.address,
|
|
131
|
+
port,
|
|
132
|
+
type: 'host',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return candidates;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Gather server-reflexive candidate via STUN binding request
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
export async function gatherSrflxCandidate(
|
|
144
|
+
socket: dgram.Socket,
|
|
145
|
+
localCandidate: IceCandidate,
|
|
146
|
+
stunServer: { host: string; port: number },
|
|
147
|
+
): Promise<IceCandidate | null> {
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
const req = createBindingRequest();
|
|
150
|
+
const buf = encodeMessage(req);
|
|
151
|
+
const txId = req.transactionId;
|
|
152
|
+
|
|
153
|
+
const timeout = setTimeout(() => {
|
|
154
|
+
socket.removeListener('message', onMessage);
|
|
155
|
+
resolve(null);
|
|
156
|
+
}, 3000);
|
|
157
|
+
|
|
158
|
+
function onMessage(msg: Buffer, rinfo: dgram.RemoteInfo): void {
|
|
159
|
+
if (!isStunMessage(msg)) return;
|
|
160
|
+
|
|
161
|
+
let decoded;
|
|
162
|
+
try {
|
|
163
|
+
decoded = decodeMessage(msg);
|
|
164
|
+
} catch {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Match transaction ID
|
|
169
|
+
if (!decoded.transactionId.equals(txId)) return;
|
|
170
|
+
if (decoded.messageClass !== MessageClass.SuccessResponse) return;
|
|
171
|
+
|
|
172
|
+
clearTimeout(timeout);
|
|
173
|
+
socket.removeListener('message', onMessage);
|
|
174
|
+
|
|
175
|
+
// Extract XOR-MAPPED-ADDRESS
|
|
176
|
+
const xorAttr = decoded.attributes.find(
|
|
177
|
+
(a: StunAttribute) => a.type === AttributeType.XorMappedAddress,
|
|
178
|
+
);
|
|
179
|
+
if (!xorAttr) {
|
|
180
|
+
resolve(null);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let mapped;
|
|
185
|
+
try {
|
|
186
|
+
mapped = decodeXorMappedAddress(xorAttr.value, decoded.transactionId);
|
|
187
|
+
} catch {
|
|
188
|
+
resolve(null);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const foundation = computeFoundation(
|
|
193
|
+
'srflx',
|
|
194
|
+
localCandidate.address,
|
|
195
|
+
localCandidate.transport,
|
|
196
|
+
);
|
|
197
|
+
const priority = computePriority('srflx', 65535, localCandidate.component);
|
|
198
|
+
|
|
199
|
+
const srflx: IceCandidate = {
|
|
200
|
+
foundation,
|
|
201
|
+
component: localCandidate.component,
|
|
202
|
+
transport: localCandidate.transport,
|
|
203
|
+
priority,
|
|
204
|
+
address: mapped.address,
|
|
205
|
+
port: mapped.port,
|
|
206
|
+
type: 'srflx',
|
|
207
|
+
relatedAddress: localCandidate.address,
|
|
208
|
+
relatedPort: localCandidate.port,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
resolve(srflx);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
socket.on('message', onMessage);
|
|
215
|
+
|
|
216
|
+
socket.send(buf, stunServer.port, stunServer.host, (err) => {
|
|
217
|
+
if (err) {
|
|
218
|
+
clearTimeout(timeout);
|
|
219
|
+
socket.removeListener('message', onMessage);
|
|
220
|
+
resolve(null);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
package/src/index.ts
ADDED
package/src/transport.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as dgram from 'node:dgram';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Packet type detection (RFC 7983 / RFC 5764)
|
|
6
|
+
// STUN: first byte 0x00-0x03
|
|
7
|
+
// DTLS: first byte 0x14-0x19 (ContentType: change_cipher_spec=20...23=heartbeat)
|
|
8
|
+
// RTP/RTCP: first byte 0x80-0xFF (version 2 flag set)
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
export function detectPacketType(
|
|
12
|
+
buf: Buffer,
|
|
13
|
+
): 'stun' | 'dtls' | 'rtp' | 'unknown' {
|
|
14
|
+
if (buf.length === 0) return 'unknown';
|
|
15
|
+
const b = buf[0]!;
|
|
16
|
+
if (b <= 0x03) return 'stun';
|
|
17
|
+
if (b >= 0x14 && b <= 0x19) return 'dtls';
|
|
18
|
+
if (b >= 0x80) return 'rtp';
|
|
19
|
+
return 'unknown';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// UdpTransport – single UDP socket with packet demultiplexing
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export declare interface UdpTransport {
|
|
27
|
+
on(event: 'stun', listener: (buf: Buffer, rinfo: dgram.RemoteInfo) => void): this;
|
|
28
|
+
on(event: 'dtls', listener: (buf: Buffer, rinfo: dgram.RemoteInfo) => void): this;
|
|
29
|
+
on(event: 'rtp', listener: (buf: Buffer, rinfo: dgram.RemoteInfo) => void): this;
|
|
30
|
+
on(event: 'error', listener: (err: Error) => void): this;
|
|
31
|
+
on(event: string, listener: (...args: unknown[]) => void): this;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class UdpTransport extends EventEmitter {
|
|
35
|
+
private _socket!: dgram.Socket;
|
|
36
|
+
private _localPort = 0;
|
|
37
|
+
private _localAddress = '0.0.0.0';
|
|
38
|
+
|
|
39
|
+
get localPort(): number {
|
|
40
|
+
return this._localPort;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get localAddress(): string {
|
|
44
|
+
return this._localAddress;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async bind(port = 0, address = '0.0.0.0'): Promise<void> {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const socket = dgram.createSocket('udp4');
|
|
50
|
+
|
|
51
|
+
socket.on('error', (err) => {
|
|
52
|
+
this.emit('error', err);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
socket.on('message', (buf: Buffer, rinfo: dgram.RemoteInfo) => {
|
|
56
|
+
const type = detectPacketType(buf);
|
|
57
|
+
this.emit(type, buf, rinfo);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
socket.bind(port, address, () => {
|
|
61
|
+
const addr = socket.address();
|
|
62
|
+
this._localPort = addr.port;
|
|
63
|
+
this._localAddress = addr.address;
|
|
64
|
+
this._socket = socket;
|
|
65
|
+
resolve();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
socket.once('error', reject);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
send(buf: Buffer, port: number, address: string): Promise<void> {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
this._socket.send(buf, port, address, (err) => {
|
|
75
|
+
if (err) reject(err);
|
|
76
|
+
else resolve();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
close(): void {
|
|
82
|
+
try {
|
|
83
|
+
this._socket.close();
|
|
84
|
+
} catch {
|
|
85
|
+
// already closed
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|