@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.
Files changed (66) hide show
  1. package/dist/data-channel.d.ts +33 -0
  2. package/dist/data-channel.d.ts.map +1 -0
  3. package/dist/data-channel.js +138 -0
  4. package/dist/data-channel.js.map +1 -0
  5. package/dist/ice-candidate.d.ts +30 -0
  6. package/dist/ice-candidate.d.ts.map +1 -0
  7. package/dist/ice-candidate.js +78 -0
  8. package/dist/ice-candidate.js.map +1 -0
  9. package/dist/index.d.ts +9 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +9 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/internal/peer-internals.d.ts +55 -0
  14. package/dist/internal/peer-internals.d.ts.map +1 -0
  15. package/dist/internal/peer-internals.js +412 -0
  16. package/dist/internal/peer-internals.js.map +1 -0
  17. package/dist/internal/sdp-factory.d.ts +9 -0
  18. package/dist/internal/sdp-factory.d.ts.map +1 -0
  19. package/dist/internal/sdp-factory.js +58 -0
  20. package/dist/internal/sdp-factory.js.map +1 -0
  21. package/dist/internal/sdp-parser.d.ts +15 -0
  22. package/dist/internal/sdp-parser.d.ts.map +1 -0
  23. package/dist/internal/sdp-parser.js +70 -0
  24. package/dist/internal/sdp-parser.js.map +1 -0
  25. package/dist/peer-connection.d.ts +72 -0
  26. package/dist/peer-connection.d.ts.map +1 -0
  27. package/dist/peer-connection.js +198 -0
  28. package/dist/peer-connection.js.map +1 -0
  29. package/dist/rtp-receiver.d.ts +28 -0
  30. package/dist/rtp-receiver.d.ts.map +1 -0
  31. package/dist/rtp-receiver.js +39 -0
  32. package/dist/rtp-receiver.js.map +1 -0
  33. package/dist/rtp-sender.d.ts +29 -0
  34. package/dist/rtp-sender.d.ts.map +1 -0
  35. package/dist/rtp-sender.js +58 -0
  36. package/dist/rtp-sender.js.map +1 -0
  37. package/dist/rtp-transceiver.d.ts +25 -0
  38. package/dist/rtp-transceiver.d.ts.map +1 -0
  39. package/dist/rtp-transceiver.js +28 -0
  40. package/dist/rtp-transceiver.js.map +1 -0
  41. package/dist/session-description.d.ts +8 -0
  42. package/dist/session-description.d.ts.map +1 -0
  43. package/dist/session-description.js +12 -0
  44. package/dist/session-description.js.map +1 -0
  45. package/dist/stats.d.ts +16 -0
  46. package/dist/stats.d.ts.map +1 -0
  47. package/dist/stats.js +22 -0
  48. package/dist/stats.js.map +1 -0
  49. package/dist/types.d.ts +113 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +2 -0
  52. package/dist/types.js.map +1 -0
  53. package/package.json +68 -0
  54. package/src/data-channel.ts +165 -0
  55. package/src/ice-candidate.ts +91 -0
  56. package/src/index.ts +8 -0
  57. package/src/internal/peer-internals.ts +477 -0
  58. package/src/internal/sdp-factory.ts +81 -0
  59. package/src/internal/sdp-parser.ts +69 -0
  60. package/src/peer-connection.ts +253 -0
  61. package/src/rtp-receiver.ts +51 -0
  62. package/src/rtp-sender.ts +77 -0
  63. package/src/rtp-transceiver.ts +40 -0
  64. package/src/session-description.ts +15 -0
  65. package/src/stats.ts +35 -0
  66. 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
+ }