@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/dist/helpers.d.ts +9 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +151 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +8 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +434 -0
- package/dist/parser.js.map +1 -0
- package/dist/serializer.d.ts +4 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +158 -0
- package/dist/serializer.js.map +1 -0
- package/dist/types.d.ts +123 -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 +55 -0
- package/src/helpers.ts +176 -0
- package/src/index.ts +24 -0
- package/src/parser.ts +476 -0
- package/src/serializer.ts +197 -0
- package/src/types.ts +138 -0
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
|
+
}
|