@agentdance/node-webrtc-sdp 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/parser.ts ADDED
@@ -0,0 +1,476 @@
1
+ import type {
2
+ SessionDescription,
3
+ Origin,
4
+ Timing,
5
+ Group,
6
+ MediaDescription,
7
+ Connection,
8
+ Bandwidth,
9
+ RtpMap,
10
+ Fmtp,
11
+ RtcpFb,
12
+ IceCandidate,
13
+ Fingerprint,
14
+ Direction,
15
+ Ssrc,
16
+ SsrcGroup,
17
+ Extmap,
18
+ RtcpAttr,
19
+ } from './types.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Candidate line parser (exported for standalone use)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Parse the *value* of a candidate attribute (the part after "a=candidate:" or
27
+ * a bare "candidate:" prefix is also accepted for convenience).
28
+ */
29
+ export function parseCandidate(line: string): IceCandidate {
30
+ // Strip optional "a=candidate:" or "candidate:" prefix
31
+ const value = line
32
+ .replace(/^a=candidate:/i, '')
33
+ .replace(/^candidate:/i, '');
34
+
35
+ const parts = value.trim().split(/\s+/);
36
+ if (parts.length < 8) {
37
+ throw new Error(`Invalid candidate line: ${line}`);
38
+ }
39
+
40
+ const foundation = parts[0]!;
41
+ const component = parseInt(parts[1]!, 10);
42
+ const transport = parts[2]!;
43
+ const priority = parseInt(parts[3]!, 10);
44
+ const address = parts[4]!;
45
+ const port = parseInt(parts[5]!, 10);
46
+ // parts[6] === 'typ'
47
+ const type = parts[7]!;
48
+
49
+ const candidate: IceCandidate = {
50
+ foundation,
51
+ component,
52
+ transport,
53
+ priority,
54
+ address,
55
+ port,
56
+ type,
57
+ };
58
+
59
+ // Parse extension fields that come in key/value pairs after the type
60
+ let i = 8;
61
+ while (i < parts.length - 1) {
62
+ const key = parts[i]!;
63
+ const val = parts[i + 1]!;
64
+ i += 2;
65
+
66
+ switch (key.toLowerCase()) {
67
+ case 'raddr':
68
+ candidate.relatedAddress = val;
69
+ break;
70
+ case 'rport':
71
+ candidate.relatedPort = parseInt(val, 10);
72
+ break;
73
+ case 'tcptype':
74
+ candidate.tcpType = val;
75
+ break;
76
+ case 'generation':
77
+ candidate.generation = parseInt(val, 10);
78
+ break;
79
+ case 'ufrag':
80
+ candidate.ufrag = val;
81
+ break;
82
+ case 'network-id':
83
+ candidate.networkId = parseInt(val, 10);
84
+ break;
85
+ case 'network-cost':
86
+ candidate.networkCost = parseInt(val, 10);
87
+ break;
88
+ default: {
89
+ if (!candidate.extensions) candidate.extensions = {};
90
+ candidate.extensions[key] = val;
91
+ }
92
+ }
93
+ }
94
+
95
+ return candidate;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Helpers
100
+ // ---------------------------------------------------------------------------
101
+
102
+ function parseOrigin(value: string): Origin {
103
+ const parts = value.split(' ');
104
+ if (parts.length < 6) throw new Error(`Invalid origin line: ${value}`);
105
+ return {
106
+ username: parts[0]!,
107
+ sessionId: parts[1]!,
108
+ sessionVersion: parseInt(parts[2]!, 10),
109
+ networkType: parts[3]!,
110
+ addressType: parts[4]!,
111
+ unicastAddress: parts[5]!,
112
+ };
113
+ }
114
+
115
+ function parseTiming(value: string): Timing {
116
+ const parts = value.split(' ');
117
+ return {
118
+ startTime: parseInt(parts[0] ?? '0', 10),
119
+ stopTime: parseInt(parts[1] ?? '0', 10),
120
+ };
121
+ }
122
+
123
+ function parseConnection(value: string): Connection {
124
+ const parts = value.split(' ');
125
+ return {
126
+ networkType: parts[0]!,
127
+ addressType: parts[1]!,
128
+ address: parts[2]!,
129
+ };
130
+ }
131
+
132
+ function parseBandwidth(value: string): Bandwidth {
133
+ const idx = value.indexOf(':');
134
+ return {
135
+ type: value.substring(0, idx),
136
+ bandwidth: parseInt(value.substring(idx + 1), 10),
137
+ };
138
+ }
139
+
140
+ function parseRtpMap(value: string): RtpMap {
141
+ // e.g. "111 opus/48000/2"
142
+ const spaceIdx = value.indexOf(' ');
143
+ const payloadType = parseInt(value.substring(0, spaceIdx), 10);
144
+ const rest = value.substring(spaceIdx + 1);
145
+ const slashParts = rest.split('/');
146
+ const encoding = slashParts[0]!;
147
+ const clockRate = parseInt(slashParts[1] ?? '0', 10);
148
+ const encodingParams = slashParts[2];
149
+ const result: RtpMap = { payloadType, encoding, clockRate };
150
+ if (encodingParams !== undefined) result.encodingParams = encodingParams;
151
+ return result;
152
+ }
153
+
154
+ function parseFmtp(value: string): Fmtp {
155
+ const spaceIdx = value.indexOf(' ');
156
+ const payloadType = parseInt(value.substring(0, spaceIdx), 10);
157
+ const parameters = value.substring(spaceIdx + 1);
158
+ return { payloadType, parameters };
159
+ }
160
+
161
+ function parseRtcpFb(value: string): RtcpFb {
162
+ // e.g. "111 transport-cc" or "96 ccm fir"
163
+ const parts = value.split(' ');
164
+ const payloadType = parseInt(parts[0]!, 10);
165
+ const type = parts[1]!;
166
+ const parameter = parts.length > 2 ? parts.slice(2).join(' ') : undefined;
167
+ const result: RtcpFb = { payloadType, type };
168
+ if (parameter !== undefined) result.parameter = parameter;
169
+ return result;
170
+ }
171
+
172
+ function parseSsrc(value: string): Ssrc {
173
+ // e.g. "1234567890 cname:some-cname" or "1234567890 msid:stream1 audio1"
174
+ const spaceIdx = value.indexOf(' ');
175
+ const id = parseInt(value.substring(0, spaceIdx), 10);
176
+ const rest = value.substring(spaceIdx + 1);
177
+ const colonIdx = rest.indexOf(':');
178
+ if (colonIdx === -1) {
179
+ return { id, attribute: rest };
180
+ }
181
+ const attribute = rest.substring(0, colonIdx);
182
+ const val = rest.substring(colonIdx + 1);
183
+ const result: Ssrc = { id, attribute };
184
+ if (val !== '') result.value = val;
185
+ return result;
186
+ }
187
+
188
+ function parseSsrcGroup(value: string): SsrcGroup {
189
+ // e.g. "FID 2222222222 3333333333"
190
+ const parts = value.split(' ');
191
+ const semantic = parts[0]!;
192
+ const ssrcIds = parts.slice(1).map((s) => parseInt(s, 10));
193
+ return { semantic, ssrcIds };
194
+ }
195
+
196
+ function parseExtmap(value: string): Extmap {
197
+ // e.g. "1 urn:ietf:params:rtp-hdrext:ssrc-audio-level"
198
+ // "2/sendrecv urn:... some attributes"
199
+ const parts = value.split(' ');
200
+ const idPart = parts[0]!;
201
+ const uri = parts[1]!;
202
+ const attributes = parts.length > 2 ? parts.slice(2).join(' ') : undefined;
203
+
204
+ let id: number;
205
+ let direction: string | undefined;
206
+
207
+ const slashIdx = idPart.indexOf('/');
208
+ if (slashIdx !== -1) {
209
+ id = parseInt(idPart.substring(0, slashIdx), 10);
210
+ direction = idPart.substring(slashIdx + 1);
211
+ } else {
212
+ id = parseInt(idPart, 10);
213
+ }
214
+
215
+ const result: Extmap = { id, uri };
216
+ if (direction !== undefined) result.direction = direction;
217
+ if (attributes !== undefined) result.attributes = attributes;
218
+ return result;
219
+ }
220
+
221
+ function parseRtcpAttr(value: string): RtcpAttr {
222
+ // e.g. "9 IN IP4 0.0.0.0" or just "9"
223
+ const parts = value.split(' ');
224
+ const port = parseInt(parts[0]!, 10);
225
+ const result: RtcpAttr = { port };
226
+ if (parts.length >= 4) {
227
+ result.networkType = parts[1]!;
228
+ result.addressType = parts[2]!;
229
+ result.address = parts[3]!;
230
+ }
231
+ return result;
232
+ }
233
+
234
+ function parseGroup(value: string): Group {
235
+ // e.g. "BUNDLE 0 1"
236
+ const parts = value.split(' ');
237
+ return {
238
+ semantic: parts[0]!,
239
+ mids: parts.slice(1),
240
+ };
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Main parser
245
+ // ---------------------------------------------------------------------------
246
+
247
+ export function parse(sdp: string): SessionDescription {
248
+ const lines = sdp.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
249
+
250
+ // Defaults / placeholders
251
+ let version = 0;
252
+ let origin: Origin = {
253
+ username: '-',
254
+ sessionId: '0',
255
+ sessionVersion: 0,
256
+ networkType: 'IN',
257
+ addressType: 'IP4',
258
+ unicastAddress: '127.0.0.1',
259
+ };
260
+ let sessionName = '-';
261
+ let timing: Timing = { startTime: 0, stopTime: 0 };
262
+ const groups: Group[] = [];
263
+ let msidSemantic: string | undefined;
264
+ const mediaDescriptions: MediaDescription[] = [];
265
+
266
+ let currentMedia: MediaDescription | null = null;
267
+
268
+ for (const rawLine of lines) {
269
+ const line = rawLine.trim();
270
+ if (line === '') continue;
271
+
272
+ const typeChar = line[0];
273
+ if (line[1] !== '=') continue; // malformed — skip
274
+ const value = line.substring(2);
275
+
276
+ if (currentMedia === null) {
277
+ // Session-level parsing
278
+ switch (typeChar) {
279
+ case 'v':
280
+ version = parseInt(value, 10);
281
+ break;
282
+ case 'o':
283
+ origin = parseOrigin(value);
284
+ break;
285
+ case 's':
286
+ sessionName = value;
287
+ break;
288
+ case 't':
289
+ timing = parseTiming(value);
290
+ break;
291
+ case 'c':
292
+ // session-level connection — we'll attach to first media or ignore
293
+ break;
294
+ case 'a': {
295
+ const eqIdx = value.indexOf(':');
296
+ const attrName = eqIdx === -1 ? value : value.substring(0, eqIdx);
297
+ const attrVal = eqIdx === -1 ? '' : value.substring(eqIdx + 1);
298
+
299
+ switch (attrName) {
300
+ case 'group':
301
+ groups.push(parseGroup(attrVal));
302
+ break;
303
+ case 'msid-semantic':
304
+ msidSemantic = attrVal.trim();
305
+ break;
306
+ // extmap-allow-mixed is a session-level attr we just ignore for the model
307
+ }
308
+ break;
309
+ }
310
+ case 'm': {
311
+ // Start a new media section
312
+ // "audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126"
313
+ const mParts = value.split(' ');
314
+ const mType = mParts[0]!;
315
+ const mPort = parseInt(mParts[1]!, 10);
316
+ const mProtocol = mParts[2]!;
317
+ const payloadTypes = mParts
318
+ .slice(3)
319
+ .map((s) => parseInt(s, 10))
320
+ .filter((n) => !isNaN(n));
321
+
322
+ currentMedia = {
323
+ type: mType,
324
+ port: mPort,
325
+ protocol: mProtocol,
326
+ payloadTypes,
327
+ rtpMaps: [],
328
+ fmtps: [],
329
+ rtcpFbs: [],
330
+ candidates: [],
331
+ ssrcs: [],
332
+ ssrcGroups: [],
333
+ extmaps: [],
334
+ };
335
+ mediaDescriptions.push(currentMedia);
336
+ break;
337
+ }
338
+ }
339
+ } else {
340
+ // Media-level parsing
341
+ switch (typeChar) {
342
+ case 'm': {
343
+ // Start another media section
344
+ const mParts = value.split(' ');
345
+ const mType = mParts[0]!;
346
+ const mPort = parseInt(mParts[1]!, 10);
347
+ const mProtocol = mParts[2]!;
348
+ const payloadTypes = mParts
349
+ .slice(3)
350
+ .map((s) => parseInt(s, 10))
351
+ .filter((n) => !isNaN(n));
352
+
353
+ currentMedia = {
354
+ type: mType,
355
+ port: mPort,
356
+ protocol: mProtocol,
357
+ payloadTypes,
358
+ rtpMaps: [],
359
+ fmtps: [],
360
+ rtcpFbs: [],
361
+ candidates: [],
362
+ ssrcs: [],
363
+ ssrcGroups: [],
364
+ extmaps: [],
365
+ };
366
+ mediaDescriptions.push(currentMedia);
367
+ break;
368
+ }
369
+ case 'c':
370
+ currentMedia.connection = parseConnection(value);
371
+ break;
372
+ case 'b':
373
+ currentMedia.bandwidth = parseBandwidth(value);
374
+ break;
375
+ case 'a': {
376
+ const eqIdx = value.indexOf(':');
377
+ const attrName = eqIdx === -1 ? value : value.substring(0, eqIdx);
378
+ const attrVal = eqIdx === -1 ? '' : value.substring(eqIdx + 1);
379
+
380
+ switch (attrName) {
381
+ case 'rtpmap':
382
+ currentMedia.rtpMaps.push(parseRtpMap(attrVal));
383
+ break;
384
+ case 'fmtp':
385
+ currentMedia.fmtps.push(parseFmtp(attrVal));
386
+ break;
387
+ case 'rtcp-fb':
388
+ currentMedia.rtcpFbs.push(parseRtcpFb(attrVal));
389
+ break;
390
+ case 'candidate':
391
+ currentMedia.candidates.push(parseCandidate(attrVal));
392
+ break;
393
+ case 'ice-ufrag':
394
+ currentMedia.iceUfrag = attrVal;
395
+ break;
396
+ case 'ice-pwd':
397
+ currentMedia.icePwd = attrVal;
398
+ break;
399
+ case 'ice-options':
400
+ currentMedia.iceOptions = attrVal;
401
+ break;
402
+ case 'ice-gathering-state':
403
+ currentMedia.iceGatheringState = attrVal;
404
+ break;
405
+ case 'fingerprint': {
406
+ const spaceIdx = attrVal.indexOf(' ');
407
+ currentMedia.fingerprint = {
408
+ algorithm: attrVal.substring(0, spaceIdx),
409
+ value: attrVal.substring(spaceIdx + 1),
410
+ } satisfies Fingerprint;
411
+ break;
412
+ }
413
+ case 'setup':
414
+ currentMedia.setup = attrVal;
415
+ break;
416
+ case 'mid':
417
+ currentMedia.mid = attrVal;
418
+ break;
419
+ case 'sendrecv':
420
+ case 'sendonly':
421
+ case 'recvonly':
422
+ case 'inactive':
423
+ currentMedia.direction = attrName as Direction;
424
+ break;
425
+ case 'rtcp':
426
+ currentMedia.rtcp = parseRtcpAttr(attrVal);
427
+ break;
428
+ case 'rtcp-mux':
429
+ currentMedia.rtcpMux = true;
430
+ break;
431
+ case 'rtcp-rsize':
432
+ currentMedia.rtcpRsize = true;
433
+ break;
434
+ case 'ssrc':
435
+ currentMedia.ssrcs.push(parseSsrc(attrVal));
436
+ break;
437
+ case 'ssrc-group':
438
+ currentMedia.ssrcGroups.push(parseSsrcGroup(attrVal));
439
+ break;
440
+ case 'msid':
441
+ currentMedia.msid = attrVal;
442
+ break;
443
+ case 'extmap':
444
+ currentMedia.extmaps.push(parseExtmap(attrVal));
445
+ break;
446
+ case 'sctp-port':
447
+ currentMedia.sctpPort = parseInt(attrVal, 10);
448
+ break;
449
+ case 'max-message-size':
450
+ currentMedia.maxMessageSize = parseInt(attrVal, 10);
451
+ break;
452
+ case 'end-of-candidates':
453
+ currentMedia.endOfCandidates = true;
454
+ break;
455
+ // group / msid-semantic can also appear at media level in some implementations
456
+ case 'group':
457
+ // media-level group — ignore for now (unusual)
458
+ break;
459
+ }
460
+ break;
461
+ }
462
+ }
463
+ }
464
+ }
465
+
466
+ const result: SessionDescription = {
467
+ version,
468
+ origin,
469
+ sessionName,
470
+ timing,
471
+ groups,
472
+ mediaDescriptions,
473
+ };
474
+ if (msidSemantic !== undefined) result.msidSemantic = msidSemantic;
475
+ return result;
476
+ }
@@ -0,0 +1,197 @@
1
+ import type {
2
+ SessionDescription,
3
+ MediaDescription,
4
+ IceCandidate,
5
+ RtcpAttr,
6
+ Extmap,
7
+ } from './types.js';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Candidate serializer (exported for standalone use)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export function serializeCandidate(c: IceCandidate): string {
14
+ let line =
15
+ `${c.foundation} ${c.component} ${c.transport} ${c.priority} ` +
16
+ `${c.address} ${c.port} typ ${c.type}`;
17
+
18
+ if (c.relatedAddress !== undefined) line += ` raddr ${c.relatedAddress}`;
19
+ if (c.relatedPort !== undefined) line += ` rport ${c.relatedPort}`;
20
+ if (c.tcpType !== undefined) line += ` tcptype ${c.tcpType}`;
21
+ if (c.generation !== undefined) line += ` generation ${c.generation}`;
22
+ if (c.ufrag !== undefined) line += ` ufrag ${c.ufrag}`;
23
+ if (c.networkId !== undefined) line += ` network-id ${c.networkId}`;
24
+ if (c.networkCost !== undefined) line += ` network-cost ${c.networkCost}`;
25
+
26
+ if (c.extensions) {
27
+ for (const [k, v] of Object.entries(c.extensions)) {
28
+ line += ` ${k} ${v}`;
29
+ }
30
+ }
31
+
32
+ return line;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ function serializeRtcpAttr(r: RtcpAttr): string {
40
+ if (
41
+ r.networkType !== undefined &&
42
+ r.addressType !== undefined &&
43
+ r.address !== undefined
44
+ ) {
45
+ return `${r.port} ${r.networkType} ${r.addressType} ${r.address}`;
46
+ }
47
+ return `${r.port}`;
48
+ }
49
+
50
+ function serializeExtmap(e: Extmap): string {
51
+ const idPart =
52
+ e.direction !== undefined ? `${e.id}/${e.direction}` : `${e.id}`;
53
+ let s = `${idPart} ${e.uri}`;
54
+ if (e.attributes !== undefined) s += ` ${e.attributes}`;
55
+ return s;
56
+ }
57
+
58
+ function serializeMedia(m: MediaDescription): string {
59
+ const lines: string[] = [];
60
+
61
+ const ptStr = m.payloadTypes.join(' ');
62
+ lines.push(`m=${m.type} ${m.port} ${m.protocol} ${ptStr}`);
63
+
64
+ if (m.connection) {
65
+ lines.push(
66
+ `c=${m.connection.networkType} ${m.connection.addressType} ${m.connection.address}`,
67
+ );
68
+ }
69
+
70
+ if (m.bandwidth) {
71
+ lines.push(`b=${m.bandwidth.type}:${m.bandwidth.bandwidth}`);
72
+ }
73
+
74
+ if (m.rtcp) {
75
+ lines.push(`a=rtcp:${serializeRtcpAttr(m.rtcp)}`);
76
+ }
77
+
78
+ if (m.iceUfrag !== undefined) lines.push(`a=ice-ufrag:${m.iceUfrag}`);
79
+ if (m.icePwd !== undefined) lines.push(`a=ice-pwd:${m.icePwd}`);
80
+ if (m.iceOptions !== undefined) lines.push(`a=ice-options:${m.iceOptions}`);
81
+ if (m.iceGatheringState !== undefined)
82
+ lines.push(`a=ice-gathering-state:${m.iceGatheringState}`);
83
+
84
+ if (m.fingerprint) {
85
+ lines.push(
86
+ `a=fingerprint:${m.fingerprint.algorithm} ${m.fingerprint.value}`,
87
+ );
88
+ }
89
+
90
+ if (m.setup !== undefined) lines.push(`a=setup:${m.setup}`);
91
+ if (m.mid !== undefined) lines.push(`a=mid:${m.mid}`);
92
+
93
+ for (const e of m.extmaps) {
94
+ lines.push(`a=extmap:${serializeExtmap(e)}`);
95
+ }
96
+
97
+ if (m.direction !== undefined) lines.push(`a=${m.direction}`);
98
+ if (m.msid !== undefined) lines.push(`a=msid:${m.msid}`);
99
+ if (m.rtcpMux === true) lines.push('a=rtcp-mux');
100
+ if (m.rtcpRsize === true) lines.push('a=rtcp-rsize');
101
+
102
+ for (const rm of m.rtpMaps) {
103
+ const encStr =
104
+ rm.encodingParams !== undefined
105
+ ? `${rm.encoding}/${rm.clockRate}/${rm.encodingParams}`
106
+ : `${rm.encoding}/${rm.clockRate}`;
107
+ lines.push(`a=rtpmap:${rm.payloadType} ${encStr}`);
108
+
109
+ // Emit rtcp-fb for this payload type
110
+ for (const fb of m.rtcpFbs.filter(
111
+ (f) => f.payloadType === rm.payloadType,
112
+ )) {
113
+ const fbLine =
114
+ fb.parameter !== undefined
115
+ ? `a=rtcp-fb:${fb.payloadType} ${fb.type} ${fb.parameter}`
116
+ : `a=rtcp-fb:${fb.payloadType} ${fb.type}`;
117
+ lines.push(fbLine);
118
+ }
119
+
120
+ // Emit fmtp for this payload type
121
+ for (const fmtp of m.fmtps.filter(
122
+ (f) => f.payloadType === rm.payloadType,
123
+ )) {
124
+ lines.push(`a=fmtp:${fmtp.payloadType} ${fmtp.parameters}`);
125
+ }
126
+ }
127
+
128
+ // Emit any rtcp-fb / fmtp entries whose payloadType is not in rtpMaps
129
+ // (shouldn't normally happen, but be safe)
130
+ const mappedPts = new Set(m.rtpMaps.map((r) => r.payloadType));
131
+
132
+ for (const fb of m.rtcpFbs.filter((f) => !mappedPts.has(f.payloadType))) {
133
+ const fbLine =
134
+ fb.parameter !== undefined
135
+ ? `a=rtcp-fb:${fb.payloadType} ${fb.type} ${fb.parameter}`
136
+ : `a=rtcp-fb:${fb.payloadType} ${fb.type}`;
137
+ lines.push(fbLine);
138
+ }
139
+
140
+ for (const fmtp of m.fmtps.filter((f) => !mappedPts.has(f.payloadType))) {
141
+ lines.push(`a=fmtp:${fmtp.payloadType} ${fmtp.parameters}`);
142
+ }
143
+
144
+ for (const sg of m.ssrcGroups) {
145
+ lines.push(`a=ssrc-group:${sg.semantic} ${sg.ssrcIds.join(' ')}`);
146
+ }
147
+
148
+ for (const ssrc of m.ssrcs) {
149
+ if (ssrc.value !== undefined) {
150
+ lines.push(`a=ssrc:${ssrc.id} ${ssrc.attribute}:${ssrc.value}`);
151
+ } else {
152
+ lines.push(`a=ssrc:${ssrc.id} ${ssrc.attribute}`);
153
+ }
154
+ }
155
+
156
+ for (const cand of m.candidates) {
157
+ lines.push(`a=candidate:${serializeCandidate(cand)}`);
158
+ }
159
+
160
+ if (m.sctpPort !== undefined) lines.push(`a=sctp-port:${m.sctpPort}`);
161
+ if (m.maxMessageSize !== undefined)
162
+ lines.push(`a=max-message-size:${m.maxMessageSize}`);
163
+ if (m.endOfCandidates === true) lines.push('a=end-of-candidates');
164
+
165
+ return lines.join('\r\n');
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Main serializer
170
+ // ---------------------------------------------------------------------------
171
+
172
+ export function serialize(desc: SessionDescription): string {
173
+ const lines: string[] = [];
174
+
175
+ lines.push(`v=${desc.version}`);
176
+ lines.push(
177
+ `o=${desc.origin.username} ${desc.origin.sessionId} ${desc.origin.sessionVersion} ` +
178
+ `${desc.origin.networkType} ${desc.origin.addressType} ${desc.origin.unicastAddress}`,
179
+ );
180
+ lines.push(`s=${desc.sessionName}`);
181
+ lines.push(`t=${desc.timing.startTime} ${desc.timing.stopTime}`);
182
+
183
+ for (const g of desc.groups) {
184
+ lines.push(`a=group:${g.semantic} ${g.mids.join(' ')}`);
185
+ }
186
+
187
+ if (desc.msidSemantic !== undefined) {
188
+ lines.push(`a=msid-semantic: ${desc.msidSemantic}`);
189
+ }
190
+
191
+ for (const m of desc.mediaDescriptions) {
192
+ lines.push(serializeMedia(m));
193
+ }
194
+
195
+ // RFC 8866: SDP must end with CRLF
196
+ return lines.join('\r\n') + '\r\n';
197
+ }