@agentdance/node-webrtc 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/data-channel.d.ts +33 -0
- package/dist/data-channel.d.ts.map +1 -0
- package/dist/data-channel.js +138 -0
- package/dist/data-channel.js.map +1 -0
- package/dist/ice-candidate.d.ts +30 -0
- package/dist/ice-candidate.d.ts.map +1 -0
- package/dist/ice-candidate.js +78 -0
- package/dist/ice-candidate.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/peer-internals.d.ts +55 -0
- package/dist/internal/peer-internals.d.ts.map +1 -0
- package/dist/internal/peer-internals.js +412 -0
- package/dist/internal/peer-internals.js.map +1 -0
- package/dist/internal/sdp-factory.d.ts +9 -0
- package/dist/internal/sdp-factory.d.ts.map +1 -0
- package/dist/internal/sdp-factory.js +58 -0
- package/dist/internal/sdp-factory.js.map +1 -0
- package/dist/internal/sdp-parser.d.ts +15 -0
- package/dist/internal/sdp-parser.d.ts.map +1 -0
- package/dist/internal/sdp-parser.js +70 -0
- package/dist/internal/sdp-parser.js.map +1 -0
- package/dist/peer-connection.d.ts +72 -0
- package/dist/peer-connection.d.ts.map +1 -0
- package/dist/peer-connection.js +198 -0
- package/dist/peer-connection.js.map +1 -0
- package/dist/rtp-receiver.d.ts +28 -0
- package/dist/rtp-receiver.d.ts.map +1 -0
- package/dist/rtp-receiver.js +39 -0
- package/dist/rtp-receiver.js.map +1 -0
- package/dist/rtp-sender.d.ts +29 -0
- package/dist/rtp-sender.d.ts.map +1 -0
- package/dist/rtp-sender.js +58 -0
- package/dist/rtp-sender.js.map +1 -0
- package/dist/rtp-transceiver.d.ts +25 -0
- package/dist/rtp-transceiver.d.ts.map +1 -0
- package/dist/rtp-transceiver.js +28 -0
- package/dist/rtp-transceiver.js.map +1 -0
- package/dist/session-description.d.ts +8 -0
- package/dist/session-description.d.ts.map +1 -0
- package/dist/session-description.js +12 -0
- package/dist/session-description.js.map +1 -0
- package/dist/stats.d.ts +16 -0
- package/dist/stats.d.ts.map +1 -0
- package/dist/stats.js +22 -0
- package/dist/stats.js.map +1 -0
- package/dist/types.d.ts +113 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +68 -0
- package/src/data-channel.ts +165 -0
- package/src/ice-candidate.ts +91 -0
- package/src/index.ts +8 -0
- package/src/internal/peer-internals.ts +477 -0
- package/src/internal/sdp-factory.ts +81 -0
- package/src/internal/sdp-parser.ts +69 -0
- package/src/peer-connection.ts +253 -0
- package/src/rtp-receiver.ts +51 -0
- package/src/rtp-sender.ts +77 -0
- package/src/rtp-transceiver.ts +40 -0
- package/src/session-description.ts +15 -0
- package/src/stats.ts +35 -0
- package/src/types.ts +156 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PeerInternals - The glue layer connecting ICE, DTLS, SRTP, SCTP and SDP
|
|
3
|
+
* for the RTCPeerConnection implementation.
|
|
4
|
+
*
|
|
5
|
+
* Connection flow:
|
|
6
|
+
* 1. createOffer/createAnswer → SDP generation
|
|
7
|
+
* 2. setLocalDescription → start ICE gathering
|
|
8
|
+
* 3. setRemoteDescription → parse remote SDP, set remote ICE params
|
|
9
|
+
* 4. addIceCandidate → feed candidates to ICE agent
|
|
10
|
+
* 5. ICE connects → start DTLS handshake
|
|
11
|
+
* 6. DTLS connects → extract SRTP keying material + start SCTP
|
|
12
|
+
* 7. SCTP connects → DataChannels available
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { EventEmitter } from 'events';
|
|
16
|
+
import type {
|
|
17
|
+
RTCConfiguration,
|
|
18
|
+
RTCOfferOptions,
|
|
19
|
+
RTCAnswerOptions,
|
|
20
|
+
RTCSessionDescriptionInit,
|
|
21
|
+
RTCIceCandidateInit,
|
|
22
|
+
} from '../types.js';
|
|
23
|
+
import type { RTCPeerConnection } from '../peer-connection.js';
|
|
24
|
+
import { RTCDataChannel as WebRTCDataChannel } from '../data-channel.js';
|
|
25
|
+
import type { RTCDataChannel } from '../data-channel.js';
|
|
26
|
+
import { RTCStatsReportImpl } from '../stats.js';
|
|
27
|
+
import type { RTCStats } from '../stats.js';
|
|
28
|
+
|
|
29
|
+
// Types from sub-packages
|
|
30
|
+
type IceAgent = import('@agentdance/node-webrtc-ice').IceAgent;
|
|
31
|
+
type DtlsTransport = import('@agentdance/node-webrtc-dtls').DtlsTransport;
|
|
32
|
+
type DtlsCertificate = import('@agentdance/node-webrtc-dtls').DtlsCertificate;
|
|
33
|
+
type SctpAssociation = import('@agentdance/node-webrtc-sctp').SctpAssociation;
|
|
34
|
+
|
|
35
|
+
export class PeerInternals extends EventEmitter {
|
|
36
|
+
private readonly _pc: RTCPeerConnection;
|
|
37
|
+
private readonly _config: Required<RTCConfiguration>;
|
|
38
|
+
|
|
39
|
+
iceAgent: IceAgent | undefined;
|
|
40
|
+
dtlsTransport: DtlsTransport | undefined;
|
|
41
|
+
sctpAssociation: SctpAssociation | undefined;
|
|
42
|
+
localCertificate: DtlsCertificate | undefined;
|
|
43
|
+
|
|
44
|
+
private _localSdp: string | undefined;
|
|
45
|
+
private _remoteSdp: string | undefined;
|
|
46
|
+
private _remoteFingerprint: { algorithm: string; value: string } | null = null;
|
|
47
|
+
private _remoteSctpPort: number | null = null;
|
|
48
|
+
private _iceRole: 'controlling' | 'controlled' = 'controlling';
|
|
49
|
+
private _dtlsRole: 'client' | 'server' = 'client';
|
|
50
|
+
private readonly _pendingDataChannels: RTCDataChannel[] = [];
|
|
51
|
+
private readonly _stunServers: Array<{ host: string; port: number }> = [];
|
|
52
|
+
|
|
53
|
+
constructor(config: Required<RTCConfiguration>, pc: RTCPeerConnection) {
|
|
54
|
+
super();
|
|
55
|
+
this._config = config;
|
|
56
|
+
this._pc = pc;
|
|
57
|
+
|
|
58
|
+
// Parse ICE server URLs
|
|
59
|
+
for (const server of config.iceServers ?? []) {
|
|
60
|
+
const urls = Array.isArray(server.urls) ? server.urls : [server.urls];
|
|
61
|
+
for (const url of urls) {
|
|
62
|
+
const match = url.match(/^stun:(.+):(\d+)$/);
|
|
63
|
+
if (match) {
|
|
64
|
+
this._stunServers.push({ host: match[1]!, port: parseInt(match[2]!, 10) });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async createOffer(_options: RTCOfferOptions): Promise<RTCSessionDescriptionInit> {
|
|
71
|
+
const { generateSdpOffer } = await import('./sdp-factory.js');
|
|
72
|
+
await this._ensureIceAgent();
|
|
73
|
+
await this._ensureCertificate();
|
|
74
|
+
const sdp = await generateSdpOffer(this.iceAgent!, this._config, this.localCertificate!);
|
|
75
|
+
this._localSdp = sdp;
|
|
76
|
+
return { type: 'offer', sdp };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async createAnswer(_options: RTCAnswerOptions): Promise<RTCSessionDescriptionInit> {
|
|
80
|
+
const { generateSdpAnswer } = await import('./sdp-factory.js');
|
|
81
|
+
await this._ensureIceAgent();
|
|
82
|
+
await this._ensureCertificate();
|
|
83
|
+
const sdp = await generateSdpAnswer(this.iceAgent!, this._remoteSdp!, this._config, this.localCertificate!);
|
|
84
|
+
this._localSdp = sdp;
|
|
85
|
+
return { type: 'answer', sdp };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async setLocalDescription(desc: RTCSessionDescriptionInit): Promise<void> {
|
|
89
|
+
this._localSdp = desc.sdp;
|
|
90
|
+
await this._ensureIceAgent();
|
|
91
|
+
|
|
92
|
+
if (desc.type === 'offer') {
|
|
93
|
+
this._iceRole = 'controlling';
|
|
94
|
+
this._dtlsRole = 'client';
|
|
95
|
+
} else if (desc.type === 'answer') {
|
|
96
|
+
if (desc.sdp.includes('a=setup:passive')) {
|
|
97
|
+
this._dtlsRole = 'server';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this._pc._updateIceGatheringState('gathering');
|
|
102
|
+
await this.iceAgent!.gather();
|
|
103
|
+
|
|
104
|
+
// If remote description already set, start connectivity now (answerer case)
|
|
105
|
+
if (this._remoteSdp && desc.type !== 'offer') {
|
|
106
|
+
this._startConnectivity(this._remoteFingerprint, this._remoteSctpPort).catch(() => {});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async setRemoteDescription(desc: RTCSessionDescriptionInit): Promise<void> {
|
|
111
|
+
this._remoteSdp = desc.sdp;
|
|
112
|
+
|
|
113
|
+
// Set ICE/DTLS role before creating the ICE agent
|
|
114
|
+
if (desc.type === 'offer') {
|
|
115
|
+
this._iceRole = 'controlled';
|
|
116
|
+
// DTLS role will be determined by local SDP (a=setup:active/passive)
|
|
117
|
+
} else if (desc.type === 'answer') {
|
|
118
|
+
// Offerer path: determine DTLS role from the remote answer's a=setup attribute.
|
|
119
|
+
// If remote answered 'active' they are DTLS client → we must be server.
|
|
120
|
+
// If remote answered 'passive' they are DTLS server → we must be client.
|
|
121
|
+
const remoteSetup = desc.sdp.match(/a=setup:(\w+)/)?.[1];
|
|
122
|
+
if (remoteSetup === 'active') {
|
|
123
|
+
this._dtlsRole = 'server';
|
|
124
|
+
} else if (remoteSetup === 'passive') {
|
|
125
|
+
this._dtlsRole = 'client';
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await this._ensureIceAgent();
|
|
130
|
+
|
|
131
|
+
const { parseIceParameters, parseDtlsFingerprint, parseSctpPort, parseCandidatesFromSdp } =
|
|
132
|
+
await import('./sdp-parser.js');
|
|
133
|
+
|
|
134
|
+
const iceParams = parseIceParameters(desc.sdp);
|
|
135
|
+
const fingerprint = parseDtlsFingerprint(desc.sdp);
|
|
136
|
+
const sctpPort = parseSctpPort(desc.sdp);
|
|
137
|
+
|
|
138
|
+
// Cache for use by setLocalDescription (answerer flow)
|
|
139
|
+
this._remoteFingerprint = fingerprint;
|
|
140
|
+
this._remoteSctpPort = sctpPort;
|
|
141
|
+
|
|
142
|
+
if (iceParams) {
|
|
143
|
+
this.iceAgent!.setRemoteParameters(iceParams);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const candidates = parseCandidatesFromSdp(desc.sdp);
|
|
147
|
+
for (const candidate of candidates) {
|
|
148
|
+
this.iceAgent!.addRemoteCandidate(candidate);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (desc.sdp.includes('a=end-of-candidates')) {
|
|
152
|
+
this.iceAgent!.remoteGatheringComplete();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (this._localSdp) {
|
|
156
|
+
this._startConnectivity(fingerprint, sctpPort).catch(() => {});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async addIceCandidate(init: RTCIceCandidateInit): Promise<void> {
|
|
161
|
+
if (!this.iceAgent) return;
|
|
162
|
+
const { parseCandidatesFromSdp } = await import('./sdp-parser.js');
|
|
163
|
+
// Wrap the candidate string in a minimal SDP-like format for the parser
|
|
164
|
+
const candidateStr = init.candidate ?? '';
|
|
165
|
+
if (!candidateStr) return;
|
|
166
|
+
const fakeSdp = `a=candidate:${candidateStr.replace(/^candidate:/, '')}`;
|
|
167
|
+
const [candidate] = parseCandidatesFromSdp(fakeSdp);
|
|
168
|
+
if (candidate) {
|
|
169
|
+
this.iceAgent.addRemoteCandidate(candidate);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
openDataChannel(channel: RTCDataChannel): void {
|
|
174
|
+
if (this.sctpAssociation?.state === 'connected') {
|
|
175
|
+
this._openDataChannelOnSctp(channel);
|
|
176
|
+
} else {
|
|
177
|
+
this._pendingDataChannels.push(channel);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
restartIce(): void {
|
|
182
|
+
if (!this.iceAgent) return;
|
|
183
|
+
// Reset ICE credentials and re-gather — forces re-negotiation
|
|
184
|
+
this.iceAgent.restart().catch(() => {});
|
|
185
|
+
this._pc._updateIceConnectionState('checking');
|
|
186
|
+
// Signal that new local candidates will be available
|
|
187
|
+
this._pc._updateIceGatheringState('gathering');
|
|
188
|
+
// Trigger re-negotiation so the new ICE credentials are exchanged
|
|
189
|
+
this._pc.emit('negotiationneeded');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
close(): void {
|
|
193
|
+
this.sctpAssociation?.close();
|
|
194
|
+
this.dtlsTransport?.close();
|
|
195
|
+
this.iceAgent?.close();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async getStats(): Promise<RTCStatsReportImpl> {
|
|
199
|
+
const stats = new Map<string, RTCStats>();
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
|
|
202
|
+
// Candidate pair stats
|
|
203
|
+
const pair = this.iceAgent?.getSelectedPair();
|
|
204
|
+
if (pair) {
|
|
205
|
+
const localId = `local-candidate-${pair.local.foundation}`;
|
|
206
|
+
const remoteId = `remote-candidate-${pair.remote.foundation}`;
|
|
207
|
+
stats.set('candidate-pair-0', {
|
|
208
|
+
id: 'candidate-pair-0',
|
|
209
|
+
type: 'candidate-pair',
|
|
210
|
+
timestamp: now,
|
|
211
|
+
localCandidateId: localId,
|
|
212
|
+
remoteCandidateId: remoteId,
|
|
213
|
+
state: 'succeeded',
|
|
214
|
+
nominated: true,
|
|
215
|
+
bytesSent: 0,
|
|
216
|
+
bytesReceived: 0,
|
|
217
|
+
totalRoundTripTime: 0,
|
|
218
|
+
} as import('../types.js').RTCIceCandidatePairStats);
|
|
219
|
+
|
|
220
|
+
stats.set(localId, {
|
|
221
|
+
id: localId,
|
|
222
|
+
type: 'local-candidate',
|
|
223
|
+
timestamp: now,
|
|
224
|
+
candidateType: pair.local.type,
|
|
225
|
+
ip: pair.local.address,
|
|
226
|
+
port: pair.local.port,
|
|
227
|
+
protocol: pair.local.transport,
|
|
228
|
+
priority: pair.local.priority,
|
|
229
|
+
} as RTCStats & Record<string, unknown>);
|
|
230
|
+
|
|
231
|
+
stats.set(remoteId, {
|
|
232
|
+
id: remoteId,
|
|
233
|
+
type: 'remote-candidate',
|
|
234
|
+
timestamp: now,
|
|
235
|
+
candidateType: pair.remote.type,
|
|
236
|
+
ip: pair.remote.address,
|
|
237
|
+
port: pair.remote.port,
|
|
238
|
+
protocol: pair.remote.transport,
|
|
239
|
+
priority: pair.remote.priority,
|
|
240
|
+
} as RTCStats & Record<string, unknown>);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Transport stats
|
|
244
|
+
if (this.dtlsTransport) {
|
|
245
|
+
stats.set('transport-0', {
|
|
246
|
+
id: 'transport-0',
|
|
247
|
+
type: 'transport',
|
|
248
|
+
timestamp: now,
|
|
249
|
+
dtlsState: this.dtlsTransport.getState(),
|
|
250
|
+
selectedCandidatePairId: pair ? 'candidate-pair-0' : undefined,
|
|
251
|
+
} as RTCStats & Record<string, unknown>);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Data channel stats (one per channel)
|
|
255
|
+
if (this.sctpAssociation) {
|
|
256
|
+
let dcIndex = 0;
|
|
257
|
+
for (const [, sctpCh] of (this.sctpAssociation as unknown as { _channels: Map<number, import('@agentdance/node-webrtc-sctp').SctpDataChannel> })._channels) {
|
|
258
|
+
const dcId = `data-channel-${dcIndex++}`;
|
|
259
|
+
stats.set(dcId, {
|
|
260
|
+
id: dcId,
|
|
261
|
+
type: 'data-channel',
|
|
262
|
+
timestamp: now,
|
|
263
|
+
label: sctpCh.label,
|
|
264
|
+
protocol: sctpCh.protocol,
|
|
265
|
+
dataChannelIdentifier: sctpCh.id,
|
|
266
|
+
state: sctpCh.state,
|
|
267
|
+
messagesSent: 0,
|
|
268
|
+
bytesSent: 0,
|
|
269
|
+
messagesReceived: 0,
|
|
270
|
+
bytesReceived: 0,
|
|
271
|
+
} as RTCStats & Record<string, unknown>);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Peer connection stats
|
|
276
|
+
stats.set('peer-connection', {
|
|
277
|
+
id: 'peer-connection',
|
|
278
|
+
type: 'peer-connection',
|
|
279
|
+
timestamp: now,
|
|
280
|
+
dataChannelsOpened: this.sctpAssociation
|
|
281
|
+
? (this.sctpAssociation as unknown as { _channels: Map<number, unknown> })._channels.size
|
|
282
|
+
: 0,
|
|
283
|
+
dataChannelsClosed: 0,
|
|
284
|
+
} as RTCStats & Record<string, unknown>);
|
|
285
|
+
|
|
286
|
+
return new RTCStatsReportImpl(stats);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private async _ensureCertificate(): Promise<void> {
|
|
290
|
+
if (this.localCertificate) return;
|
|
291
|
+
const { generateSelfSignedCertificate } = await import('@agentdance/node-webrtc-dtls');
|
|
292
|
+
this.localCertificate = generateSelfSignedCertificate();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async _ensureIceAgent(): Promise<void> {
|
|
296
|
+
if (this.iceAgent) return;
|
|
297
|
+
|
|
298
|
+
const { IceAgent, serializeCandidateAttribute } = await import('@agentdance/node-webrtc-ice');
|
|
299
|
+
this.iceAgent = new IceAgent({
|
|
300
|
+
stunServers: this._stunServers,
|
|
301
|
+
role: this._iceRole,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
this.iceAgent.on('local-candidate', (candidate: import('@agentdance/node-webrtc-ice').IceCandidate) => {
|
|
305
|
+
this._pc.emit('icecandidate', {
|
|
306
|
+
candidate: 'candidate:' + serializeCandidateAttribute(candidate),
|
|
307
|
+
sdpMid: '0',
|
|
308
|
+
sdpMLineIndex: 0,
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
this.iceAgent.on('gathering-complete', () => {
|
|
313
|
+
this._pc._updateIceGatheringState('complete');
|
|
314
|
+
this._pc.emit('icecandidate', null);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
this.iceAgent.on('connection-state', (state: string) => {
|
|
318
|
+
const stateMap: Record<string, import('../types.js').RTCIceConnectionState> = {
|
|
319
|
+
new: 'new',
|
|
320
|
+
checking: 'checking',
|
|
321
|
+
connected: 'connected',
|
|
322
|
+
completed: 'completed',
|
|
323
|
+
failed: 'failed',
|
|
324
|
+
disconnected: 'disconnected',
|
|
325
|
+
closed: 'closed',
|
|
326
|
+
};
|
|
327
|
+
const mapped = stateMap[state] ?? 'new';
|
|
328
|
+
this._pc._updateIceConnectionState(mapped);
|
|
329
|
+
|
|
330
|
+
// Connection recovery: when ICE reconnects after a disconnect/failure,
|
|
331
|
+
// restore peer connection state
|
|
332
|
+
if (state === 'connected' || state === 'completed') {
|
|
333
|
+
if (this._pc.connectionState === 'disconnected' || this._pc.connectionState === 'failed') {
|
|
334
|
+
this._pc._updateConnectionState('connected');
|
|
335
|
+
}
|
|
336
|
+
} else if (state === 'disconnected') {
|
|
337
|
+
if (this._pc.connectionState === 'connected') {
|
|
338
|
+
this._pc._updateConnectionState('disconnected');
|
|
339
|
+
}
|
|
340
|
+
} else if (state === 'failed') {
|
|
341
|
+
this._pc._updateConnectionState('failed');
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
this.iceAgent.on('connected', async () => {
|
|
346
|
+
this._pc._updateConnectionState('connecting');
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private async _startConnectivity(
|
|
351
|
+
remoteFingerprint: { algorithm: string; value: string } | null,
|
|
352
|
+
sctpPort: number | null,
|
|
353
|
+
): Promise<void> {
|
|
354
|
+
if (!this.iceAgent) return;
|
|
355
|
+
if (this.dtlsTransport) return; // Guard against double-start
|
|
356
|
+
|
|
357
|
+
this._pc._updateIceConnectionState('checking');
|
|
358
|
+
|
|
359
|
+
// Pre-create DTLS transport and wire ICE→DTLS BEFORE ICE connects,
|
|
360
|
+
// so DTLS handshake packets are not lost when the remote sends first.
|
|
361
|
+
const { DtlsTransport } = await import('@agentdance/node-webrtc-dtls');
|
|
362
|
+
const dtlsOpts: import('@agentdance/node-webrtc-dtls').DtlsTransportOptions = {
|
|
363
|
+
role: this._dtlsRole,
|
|
364
|
+
...(this.localCertificate ? { certificate: this.localCertificate } : {}),
|
|
365
|
+
};
|
|
366
|
+
if (remoteFingerprint !== null) {
|
|
367
|
+
dtlsOpts.remoteFingerprint = remoteFingerprint;
|
|
368
|
+
}
|
|
369
|
+
this.dtlsTransport = new DtlsTransport(dtlsOpts);
|
|
370
|
+
|
|
371
|
+
// Wire ICE → DTLS (packets received from remote)
|
|
372
|
+
this.iceAgent.on('data', (data: Buffer) => {
|
|
373
|
+
this.dtlsTransport!.handleIncoming(data);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Wire DTLS → ICE (packets to send to remote)
|
|
377
|
+
this.dtlsTransport.setSendCallback((data: Buffer) => {
|
|
378
|
+
this.iceAgent!.send(data);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Use event-driven approach: start DTLS when ICE nominates a pair.
|
|
382
|
+
// This handles the race condition where connect() fails early in trickle ICE.
|
|
383
|
+
const dtlsTransport = this.dtlsTransport;
|
|
384
|
+
const startDtls = async () => {
|
|
385
|
+
try {
|
|
386
|
+
console.log('[PeerInternals] starting DTLS...');
|
|
387
|
+
await dtlsTransport.start();
|
|
388
|
+
console.log('[PeerInternals] DTLS connected, starting SCTP...');
|
|
389
|
+
if (sctpPort !== null) {
|
|
390
|
+
await this._startSctp(sctpPort);
|
|
391
|
+
console.log('[PeerInternals] SCTP connected!');
|
|
392
|
+
}
|
|
393
|
+
this._pc._updateConnectionState('connected');
|
|
394
|
+
console.log('[PeerInternals] connectionState=connected');
|
|
395
|
+
} catch (e) {
|
|
396
|
+
console.error('[PeerInternals] DTLS/SCTP failed:', e);
|
|
397
|
+
this._pc._updateIceConnectionState('failed');
|
|
398
|
+
this._pc._updateConnectionState('failed');
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// Start ICE connectivity checks
|
|
403
|
+
try {
|
|
404
|
+
await this.iceAgent.connect();
|
|
405
|
+
// ICE is connected — start DTLS handshake
|
|
406
|
+
await startDtls();
|
|
407
|
+
} catch {
|
|
408
|
+
// connect() may fail early due to trickle ICE timing.
|
|
409
|
+
// The iceAgent 'connected' event will fire when ICE actually connects.
|
|
410
|
+
// Register one-time handler.
|
|
411
|
+
this.iceAgent.once('connected', () => {
|
|
412
|
+
startDtls().catch(() => {});
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private async _startSctp(remotePort: number): Promise<void> {
|
|
418
|
+
const { SctpAssociation } = await import('@agentdance/node-webrtc-sctp');
|
|
419
|
+
|
|
420
|
+
this.sctpAssociation = new SctpAssociation({
|
|
421
|
+
localPort: 5000,
|
|
422
|
+
remotePort,
|
|
423
|
+
role: this._dtlsRole === 'client' ? 'client' : 'server',
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Wire DTLS ↔ SCTP
|
|
427
|
+
this.dtlsTransport!.on('data', (data: Buffer) => {
|
|
428
|
+
this.sctpAssociation!.handleIncoming(data);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
this.sctpAssociation.setSendCallback((data: Buffer) => {
|
|
432
|
+
this.dtlsTransport!.send(data);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// Handle incoming data channels
|
|
436
|
+
this.sctpAssociation.on(
|
|
437
|
+
'datachannel',
|
|
438
|
+
(sctpChannel: import('@agentdance/node-webrtc-sctp').SctpDataChannel) => {
|
|
439
|
+
const channel = new WebRTCDataChannel(
|
|
440
|
+
sctpChannel.label,
|
|
441
|
+
{
|
|
442
|
+
ordered: sctpChannel.ordered,
|
|
443
|
+
id: sctpChannel.id,
|
|
444
|
+
protocol: sctpChannel.protocol,
|
|
445
|
+
},
|
|
446
|
+
sctpChannel,
|
|
447
|
+
);
|
|
448
|
+
this._pc.emit('datachannel', channel);
|
|
449
|
+
},
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
await this.sctpAssociation.connect();
|
|
453
|
+
|
|
454
|
+
// Open any pending data channels
|
|
455
|
+
for (const dc of this._pendingDataChannels) {
|
|
456
|
+
this._openDataChannelOnSctp(dc);
|
|
457
|
+
}
|
|
458
|
+
this._pendingDataChannels.length = 0;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private _openDataChannelOnSctp(channel: RTCDataChannel): void {
|
|
462
|
+
if (!this.sctpAssociation) return;
|
|
463
|
+
const opts: import('@agentdance/node-webrtc-sctp').DataChannelOptions = {
|
|
464
|
+
label: channel.label,
|
|
465
|
+
ordered: channel.ordered,
|
|
466
|
+
protocol: channel.protocol,
|
|
467
|
+
negotiated: channel.negotiated,
|
|
468
|
+
};
|
|
469
|
+
if (channel.maxRetransmits !== null) opts.maxRetransmits = channel.maxRetransmits;
|
|
470
|
+
if (channel.maxPacketLifeTime !== null) opts.maxPacketLifeTime = channel.maxPacketLifeTime;
|
|
471
|
+
// For negotiated channels, id must be provided; for auto channels, let SCTP assign
|
|
472
|
+
if (channel.id !== null) opts.id = channel.id;
|
|
473
|
+
|
|
474
|
+
const sctpChannel = this.sctpAssociation.createDataChannel(opts);
|
|
475
|
+
channel._bindSctpChannel(sctpChannel);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDP generation for offer/answer
|
|
3
|
+
*/
|
|
4
|
+
import type { IceAgent } from '@agentdance/node-webrtc-ice';
|
|
5
|
+
import type { DtlsCertificate } from '@agentdance/node-webrtc-dtls';
|
|
6
|
+
import type { RTCConfiguration } from '../types.js';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
export async function generateSdpOffer(
|
|
10
|
+
iceAgent: IceAgent,
|
|
11
|
+
config: Required<RTCConfiguration>,
|
|
12
|
+
certificate: DtlsCertificate,
|
|
13
|
+
): Promise<string> {
|
|
14
|
+
const localParams = iceAgent.localParameters;
|
|
15
|
+
|
|
16
|
+
const sessionId = crypto.randomBytes(8).readBigUInt64BE(0).toString();
|
|
17
|
+
|
|
18
|
+
const lines: string[] = [
|
|
19
|
+
'v=0',
|
|
20
|
+
`o=- ${sessionId} 1 IN IP4 127.0.0.1`,
|
|
21
|
+
's=-',
|
|
22
|
+
't=0 0',
|
|
23
|
+
'a=group:BUNDLE 0',
|
|
24
|
+
'a=extmap-allow-mixed',
|
|
25
|
+
'a=msid-semantic: WMS',
|
|
26
|
+
// Data channel media section
|
|
27
|
+
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
|
|
28
|
+
'c=IN IP4 0.0.0.0',
|
|
29
|
+
`a=ice-ufrag:${localParams.usernameFragment}`,
|
|
30
|
+
`a=ice-pwd:${localParams.password}`,
|
|
31
|
+
'a=ice-options:trickle',
|
|
32
|
+
`a=fingerprint:sha-256 ${certificate.fingerprint.value}`,
|
|
33
|
+
'a=setup:actpass',
|
|
34
|
+
'a=mid:0',
|
|
35
|
+
'a=sctp-port:5000',
|
|
36
|
+
'a=max-message-size:262144',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
return lines.join('\r\n') + '\r\n';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function generateSdpAnswer(
|
|
43
|
+
iceAgent: IceAgent,
|
|
44
|
+
remoteSdp: string,
|
|
45
|
+
config: Required<RTCConfiguration>,
|
|
46
|
+
certificate: DtlsCertificate,
|
|
47
|
+
): Promise<string> {
|
|
48
|
+
const localParams = iceAgent.localParameters;
|
|
49
|
+
|
|
50
|
+
// Determine DTLS setup role per RFC 5763 §5:
|
|
51
|
+
// remote=actpass → we pick active (we become DTLS client)
|
|
52
|
+
// remote=active → we must be passive (we become DTLS server)
|
|
53
|
+
// remote=passive → we must be active (we become DTLS client)
|
|
54
|
+
const remoteSetup = remoteSdp.match(/a=setup:(\w+)/)?.[1] ?? 'actpass';
|
|
55
|
+
const localSetup = remoteSetup === 'actpass' ? 'active' :
|
|
56
|
+
remoteSetup === 'active' ? 'passive' : 'active';
|
|
57
|
+
|
|
58
|
+
const sessionId = Math.floor(Math.random() * 1e15).toString();
|
|
59
|
+
|
|
60
|
+
const lines: string[] = [
|
|
61
|
+
'v=0',
|
|
62
|
+
`o=- ${sessionId} 1 IN IP4 127.0.0.1`,
|
|
63
|
+
's=-',
|
|
64
|
+
't=0 0',
|
|
65
|
+
'a=group:BUNDLE 0',
|
|
66
|
+
'a=extmap-allow-mixed',
|
|
67
|
+
'a=msid-semantic: WMS',
|
|
68
|
+
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
|
|
69
|
+
'c=IN IP4 0.0.0.0',
|
|
70
|
+
`a=ice-ufrag:${localParams.usernameFragment}`,
|
|
71
|
+
`a=ice-pwd:${localParams.password}`,
|
|
72
|
+
'a=ice-options:trickle',
|
|
73
|
+
`a=fingerprint:sha-256 ${certificate.fingerprint.value}`,
|
|
74
|
+
`a=setup:${localSetup}`,
|
|
75
|
+
'a=mid:0',
|
|
76
|
+
'a=sctp-port:5000',
|
|
77
|
+
'a=max-message-size:262144',
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
return lines.join('\r\n') + '\r\n';
|
|
81
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse ICE/DTLS/SCTP parameters from SDP strings
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface ParsedIceParameters {
|
|
6
|
+
usernameFragment: string;
|
|
7
|
+
password: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseIceParameters(sdp: string): ParsedIceParameters | null {
|
|
11
|
+
const ufragMatch = sdp.match(/a=ice-ufrag:(\S+)/);
|
|
12
|
+
const pwdMatch = sdp.match(/a=ice-pwd:(\S+)/);
|
|
13
|
+
if (!ufragMatch || !pwdMatch) return null;
|
|
14
|
+
return {
|
|
15
|
+
usernameFragment: ufragMatch[1]!,
|
|
16
|
+
password: pwdMatch[1]!,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseDtlsFingerprint(sdp: string): { algorithm: string; value: string } | null {
|
|
21
|
+
const match = sdp.match(/a=fingerprint:(\S+)\s+(\S+)/);
|
|
22
|
+
if (!match) return null;
|
|
23
|
+
return { algorithm: match[1]!, value: match[2]! };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseSctpPort(sdp: string): number | null {
|
|
27
|
+
const match = sdp.match(/a=sctp-port:(\d+)/);
|
|
28
|
+
if (!match) return null;
|
|
29
|
+
return parseInt(match[1]!, 10);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseCandidatesFromSdp(sdp: string): import('@agentdance/node-webrtc-ice').IceCandidate[] {
|
|
33
|
+
const candidates: import('@agentdance/node-webrtc-ice').IceCandidate[] = [];
|
|
34
|
+
const lines = sdp.split(/\r?\n/);
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
const match = line.match(/^a=candidate:(.+)$/);
|
|
37
|
+
if (match) {
|
|
38
|
+
const candidate = parseCandidateLine(match[1]!);
|
|
39
|
+
if (candidate) candidates.push(candidate);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return candidates;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseCandidateLine(value: string): import('@agentdance/node-webrtc-ice').IceCandidate | null {
|
|
46
|
+
const parts = value.split(' ');
|
|
47
|
+
if (parts.length < 8) return null;
|
|
48
|
+
const [foundation, componentStr, transport, priorityStr, address, portStr, , type, ...rest] = parts;
|
|
49
|
+
const candidate: import('@agentdance/node-webrtc-ice').IceCandidate = {
|
|
50
|
+
foundation: foundation ?? '',
|
|
51
|
+
component: (componentStr === '1' ? 1 : 2) as 1 | 2,
|
|
52
|
+
transport: (transport?.toLowerCase() ?? 'udp') as 'udp' | 'tcp',
|
|
53
|
+
priority: parseInt(priorityStr ?? '0', 10),
|
|
54
|
+
address: address ?? '',
|
|
55
|
+
port: parseInt(portStr ?? '0', 10),
|
|
56
|
+
type: (type ?? 'host') as 'host' | 'srflx' | 'relay' | 'prflx',
|
|
57
|
+
};
|
|
58
|
+
// Parse extensions
|
|
59
|
+
for (let i = 0; i < rest.length - 1; i += 2) {
|
|
60
|
+
const key = rest[i];
|
|
61
|
+
const val = rest[i + 1];
|
|
62
|
+
if (key === 'raddr' && val !== undefined) candidate.relatedAddress = val;
|
|
63
|
+
else if (key === 'rport' && val !== undefined) candidate.relatedPort = parseInt(val, 10);
|
|
64
|
+
else if (key === 'generation' && val !== undefined) candidate.generation = parseInt(val, 10);
|
|
65
|
+
else if (key === 'ufrag' && val !== undefined) candidate.ufrag = val;
|
|
66
|
+
else if (key === 'network-id' && val !== undefined) candidate.networkId = parseInt(val, 10);
|
|
67
|
+
}
|
|
68
|
+
return candidate;
|
|
69
|
+
}
|