@zero-server/webrtc 0.9.7
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/LICENSE +21 -0
- package/README.md +37 -0
- package/index.d.ts +2 -0
- package/index.js +53 -0
- package/lib/auth/index.js +1 -0
- package/lib/debug.js +372 -0
- package/lib/errors.js +1 -0
- package/lib/middleware/index.js +1 -0
- package/lib/observe/index.js +1 -0
- package/lib/webrtc/bot.js +361 -0
- package/lib/webrtc/cli.js +182 -0
- package/lib/webrtc/cluster.js +350 -0
- package/lib/webrtc/e2ee.js +282 -0
- package/lib/webrtc/ice.js +370 -0
- package/lib/webrtc/index.js +132 -0
- package/lib/webrtc/joinToken.js +116 -0
- package/lib/webrtc/observe.js +229 -0
- package/lib/webrtc/peer.js +116 -0
- package/lib/webrtc/room.js +171 -0
- package/lib/webrtc/sdp.js +508 -0
- package/lib/webrtc/sfu/index.js +201 -0
- package/lib/webrtc/sfu/livekit.js +301 -0
- package/lib/webrtc/sfu/mediasoup.js +317 -0
- package/lib/webrtc/sfu/memory.js +204 -0
- package/lib/webrtc/signaling.js +546 -0
- package/lib/webrtc/stun.js +492 -0
- package/lib/webrtc/turn/codec.js +370 -0
- package/lib/webrtc/turn/credentials.js +141 -0
- package/lib/webrtc/turn/server.js +633 -0
- package/lib/ws/index.js +1 -0
- package/package.json +62 -0
- package/types/app.d.ts +223 -0
- package/types/auth.d.ts +520 -0
- package/types/body.d.ts +14 -0
- package/types/cli.d.ts +2 -0
- package/types/cluster.d.ts +75 -0
- package/types/env.d.ts +80 -0
- package/types/errors.d.ts +316 -0
- package/types/fetch.d.ts +43 -0
- package/types/grpc.d.ts +432 -0
- package/types/index.d.ts +396 -0
- package/types/lifecycle.d.ts +60 -0
- package/types/middleware.d.ts +320 -0
- package/types/observe.d.ts +304 -0
- package/types/orm.d.ts +1887 -0
- package/types/request.d.ts +109 -0
- package/types/response.d.ts +157 -0
- package/types/router.d.ts +78 -0
- package/types/sse.d.ts +78 -0
- package/types/webrtc.d.ts +501 -0
- package/types/websocket.d.ts +126 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/sdp
|
|
3
|
+
* @description Zero-dependency RFC 8866 Session Description Protocol parser and
|
|
4
|
+
* serializer, with WebRTC-specific attribute extraction (JSEP per
|
|
5
|
+
* RFC 8829: ice-ufrag, ice-pwd, fingerprint, setup, mid, rtcp-mux,
|
|
6
|
+
* direction, rtpmap, fmtp, rid, simulcast, ssrc, extmap, candidate).
|
|
7
|
+
*
|
|
8
|
+
* The parser deliberately does NOT validate semantics that belong
|
|
9
|
+
* to a SignalingHub policy layer (codec allowlists, mDNS blocking,
|
|
10
|
+
* etc.) - it only structures the document. Policy lives in
|
|
11
|
+
* `lib/webrtc/signaling.js`.
|
|
12
|
+
*
|
|
13
|
+
* @see https://datatracker.ietf.org/doc/html/rfc8866
|
|
14
|
+
* @see https://datatracker.ietf.org/doc/html/rfc8829
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const { SdpError } = require('../errors');
|
|
20
|
+
|
|
21
|
+
// -- Constants -----------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const CRLF = '\r\n';
|
|
24
|
+
const DEFAULT_MAX_BYTES = 65_536; // 64 KiB - sane WebRTC offer ceiling
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Valid SDP direction attributes (RFC 8866 §6.7).
|
|
28
|
+
* @type {ReadonlyArray<string>}
|
|
29
|
+
*/
|
|
30
|
+
const DIRECTIONS = Object.freeze(['sendrecv', 'sendonly', 'recvonly', 'inactive']);
|
|
31
|
+
|
|
32
|
+
// -- Public types --------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {object} SdpOrigin
|
|
36
|
+
* @property {string} username
|
|
37
|
+
* @property {string} sessionId
|
|
38
|
+
* @property {number} sessionVersion
|
|
39
|
+
* @property {string} netType
|
|
40
|
+
* @property {string} addrType
|
|
41
|
+
* @property {string} address
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {object} SdpConnection
|
|
46
|
+
* @property {string} netType
|
|
47
|
+
* @property {string} addrType
|
|
48
|
+
* @property {string} address
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {object} SdpAttribute
|
|
53
|
+
* @property {string} key
|
|
54
|
+
* @property {string} value - Empty string for flag-only attributes.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {object} SdpRtpMap
|
|
59
|
+
* @property {number} payload
|
|
60
|
+
* @property {string} codec
|
|
61
|
+
* @property {number} clockRate
|
|
62
|
+
* @property {number|undefined} channels
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {object} SdpFmtp
|
|
67
|
+
* @property {number} payload
|
|
68
|
+
* @property {string} config
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @typedef {object} SdpRid
|
|
73
|
+
* @property {string} id
|
|
74
|
+
* @property {string} direction - 'send' or 'recv'.
|
|
75
|
+
* @property {string} params - Remaining rid params (may be empty).
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {object} SdpExtMap
|
|
80
|
+
* @property {number} id
|
|
81
|
+
* @property {string|undefined} direction
|
|
82
|
+
* @property {string} uri
|
|
83
|
+
* @property {string|undefined} config
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @typedef {object} SdpSsrcAttr
|
|
88
|
+
* @property {number} id
|
|
89
|
+
* @property {string} attribute
|
|
90
|
+
* @property {string} value
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @typedef {object} SdpFingerprint
|
|
95
|
+
* @property {string} algorithm
|
|
96
|
+
* @property {string} value
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @typedef {object} SdpMedia
|
|
101
|
+
* @property {string} kind - 'audio', 'video', 'application', etc.
|
|
102
|
+
* @property {number} port
|
|
103
|
+
* @property {number|undefined} numPorts
|
|
104
|
+
* @property {string} proto - e.g. 'UDP/TLS/RTP/SAVPF'.
|
|
105
|
+
* @property {string[]} fmts - Format / payload-type list.
|
|
106
|
+
* @property {SdpConnection|undefined} connection
|
|
107
|
+
* @property {SdpAttribute[]} attributes - Raw attribute list (round-trip source of truth).
|
|
108
|
+
* @property {string|undefined} mid
|
|
109
|
+
* @property {boolean} rtcpMux
|
|
110
|
+
* @property {SdpFingerprint|undefined} fingerprint
|
|
111
|
+
* @property {string|undefined} iceUfrag
|
|
112
|
+
* @property {string|undefined} icePwd
|
|
113
|
+
* @property {string|undefined} setup - 'actpass' | 'active' | 'passive' | 'holdconn'.
|
|
114
|
+
* @property {string|undefined} direction
|
|
115
|
+
* @property {string[]} candidates - Raw candidate lines (without "a=" prefix).
|
|
116
|
+
* @property {SdpRtpMap[]} rtpmaps
|
|
117
|
+
* @property {SdpFmtp[]} fmtps
|
|
118
|
+
* @property {SdpRid[]} rids
|
|
119
|
+
* @property {Object<string,string>} simulcast - { send?: '<layers>', recv?: '<layers>' }.
|
|
120
|
+
* @property {SdpExtMap[]} extmaps
|
|
121
|
+
* @property {SdpSsrcAttr[]} ssrcs
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @typedef {object} SessionDescription
|
|
126
|
+
* @property {number} version
|
|
127
|
+
* @property {SdpOrigin} origin
|
|
128
|
+
* @property {string} sessionName
|
|
129
|
+
* @property {SdpConnection|undefined} connection
|
|
130
|
+
* @property {Array<{start:number,stop:number}>} timing
|
|
131
|
+
* @property {SdpAttribute[]} attributes
|
|
132
|
+
* @property {SdpMedia[]} media
|
|
133
|
+
*/
|
|
134
|
+
|
|
135
|
+
// =================================================================
|
|
136
|
+
// Parser
|
|
137
|
+
// =================================================================
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parse an SDP document into a structured `SessionDescription`.
|
|
141
|
+
*
|
|
142
|
+
* Accepts CRLF (RFC 8866) or LF-only line endings. Validates the leading
|
|
143
|
+
* `v=` line, refuses oversized payloads, and tolerates unknown attribute
|
|
144
|
+
* keys by preserving them on the raw `attributes` list.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} text - The SDP document text.
|
|
147
|
+
* @param {object} [opts]
|
|
148
|
+
* @param {number} [opts.maxBytes=65536] - Reject payloads larger than this.
|
|
149
|
+
* @returns {SessionDescription} Parsed structure.
|
|
150
|
+
* @throws {SdpError} On malformed input, oversized payload, or non-string arg.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* const { parseSdp } = require('@zero-server/webrtc');
|
|
154
|
+
* const desc = parseSdp(offer.sdp);
|
|
155
|
+
* console.log(desc.media[0].iceUfrag, desc.media[0].fingerprint);
|
|
156
|
+
*
|
|
157
|
+
* @section Signaling
|
|
158
|
+
*/
|
|
159
|
+
function parseSdp(text, opts = {})
|
|
160
|
+
{
|
|
161
|
+
if (typeof text !== 'string') throw new SdpError('parseSdp: input must be a string');
|
|
162
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
163
|
+
if (text.length > maxBytes) throw new SdpError(`parseSdp: payload exceeds ${maxBytes} bytes`);
|
|
164
|
+
if (text.length === 0) throw new SdpError('parseSdp: empty input');
|
|
165
|
+
|
|
166
|
+
const lines = text.replace(/\r\n/g, '\n').split('\n').filter(l => l.length > 0);
|
|
167
|
+
if (lines.length === 0) throw new SdpError('parseSdp: no non-empty lines');
|
|
168
|
+
|
|
169
|
+
/** @type {SessionDescription} */
|
|
170
|
+
const session = {
|
|
171
|
+
version: 0,
|
|
172
|
+
origin: undefined,
|
|
173
|
+
sessionName: '',
|
|
174
|
+
connection: undefined,
|
|
175
|
+
timing: [],
|
|
176
|
+
attributes: [],
|
|
177
|
+
media: [],
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
let current = session; // session-level or current media section
|
|
181
|
+
let inMedia = false;
|
|
182
|
+
|
|
183
|
+
for (let i = 0; i < lines.length; i++)
|
|
184
|
+
{
|
|
185
|
+
const raw = lines[i];
|
|
186
|
+
const eq = raw.indexOf('=');
|
|
187
|
+
if (eq < 1) throw new SdpError(`parseSdp: malformed line ${i + 1}`, { line: i + 1 });
|
|
188
|
+
const type = raw.slice(0, eq);
|
|
189
|
+
const val = raw.slice(eq + 1);
|
|
190
|
+
|
|
191
|
+
if (i === 0 && type !== 'v')
|
|
192
|
+
throw new SdpError('parseSdp: SDP must start with v=', { line: 1 });
|
|
193
|
+
|
|
194
|
+
switch (type)
|
|
195
|
+
{
|
|
196
|
+
case 'v':
|
|
197
|
+
session.version = Number(val);
|
|
198
|
+
break;
|
|
199
|
+
|
|
200
|
+
case 'o':
|
|
201
|
+
session.origin = _parseOrigin(val, i + 1);
|
|
202
|
+
break;
|
|
203
|
+
|
|
204
|
+
case 's':
|
|
205
|
+
session.sessionName = val;
|
|
206
|
+
break;
|
|
207
|
+
|
|
208
|
+
case 'c':
|
|
209
|
+
current.connection = _parseConnection(val, i + 1);
|
|
210
|
+
break;
|
|
211
|
+
|
|
212
|
+
case 't':
|
|
213
|
+
{
|
|
214
|
+
const [start, stop] = val.split(/\s+/).map(Number);
|
|
215
|
+
session.timing.push({ start, stop });
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
case 'm':
|
|
220
|
+
current = _newMedia(val, i + 1);
|
|
221
|
+
session.media.push(current);
|
|
222
|
+
inMedia = true;
|
|
223
|
+
break;
|
|
224
|
+
|
|
225
|
+
case 'a':
|
|
226
|
+
{
|
|
227
|
+
const attr = _parseAttribute(val);
|
|
228
|
+
current.attributes.push(attr);
|
|
229
|
+
if (inMedia) _absorbMediaAttr(current, attr);
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Other RFC 8866 line types we don't lift into structured fields but
|
|
234
|
+
// also do not reject - they survive as session.attributes is the
|
|
235
|
+
// round-trip source of truth for media attributes only. v/o/s/t/c
|
|
236
|
+
// are the only session-level lines we currently emit on serialize.
|
|
237
|
+
default:
|
|
238
|
+
// Tolerated but not stored (i, u, e, p, b, r, z, k).
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!session.origin)
|
|
244
|
+
throw new SdpError('parseSdp: missing o= line');
|
|
245
|
+
|
|
246
|
+
return session;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// -- Parser helpers ------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/** @private */
|
|
252
|
+
function _parseOrigin(val, line)
|
|
253
|
+
{
|
|
254
|
+
const parts = val.split(/\s+/);
|
|
255
|
+
if (parts.length < 6)
|
|
256
|
+
throw new SdpError('parseSdp: malformed o= line', { line });
|
|
257
|
+
return {
|
|
258
|
+
username: parts[0],
|
|
259
|
+
sessionId: parts[1],
|
|
260
|
+
sessionVersion: Number(parts[2]),
|
|
261
|
+
netType: parts[3],
|
|
262
|
+
addrType: parts[4],
|
|
263
|
+
address: parts.slice(5).join(' '),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** @private */
|
|
268
|
+
function _parseConnection(val, line)
|
|
269
|
+
{
|
|
270
|
+
const parts = val.split(/\s+/);
|
|
271
|
+
if (parts.length < 3)
|
|
272
|
+
throw new SdpError('parseSdp: malformed c= line', { line });
|
|
273
|
+
return { netType: parts[0], addrType: parts[1], address: parts[2] };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @private */
|
|
277
|
+
function _newMedia(val, line)
|
|
278
|
+
{
|
|
279
|
+
const parts = val.split(/\s+/);
|
|
280
|
+
if (parts.length < 4)
|
|
281
|
+
throw new SdpError('parseSdp: malformed m= line', { line });
|
|
282
|
+
const [kind, portSpec, proto, ...fmts] = parts;
|
|
283
|
+
let port, numPorts;
|
|
284
|
+
if (portSpec.includes('/'))
|
|
285
|
+
{
|
|
286
|
+
const [p, n] = portSpec.split('/');
|
|
287
|
+
port = Number(p); numPorts = Number(n);
|
|
288
|
+
}
|
|
289
|
+
else
|
|
290
|
+
{
|
|
291
|
+
port = Number(portSpec);
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
kind, port, numPorts, proto, fmts,
|
|
295
|
+
connection: undefined,
|
|
296
|
+
attributes: [],
|
|
297
|
+
mid: undefined,
|
|
298
|
+
rtcpMux: false,
|
|
299
|
+
fingerprint: undefined,
|
|
300
|
+
iceUfrag: undefined,
|
|
301
|
+
icePwd: undefined,
|
|
302
|
+
setup: undefined,
|
|
303
|
+
direction: undefined,
|
|
304
|
+
candidates: [],
|
|
305
|
+
rtpmaps: [],
|
|
306
|
+
fmtps: [],
|
|
307
|
+
rids: [],
|
|
308
|
+
simulcast: {},
|
|
309
|
+
extmaps: [],
|
|
310
|
+
ssrcs: [],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** @private */
|
|
315
|
+
function _parseAttribute(val)
|
|
316
|
+
{
|
|
317
|
+
const colon = val.indexOf(':');
|
|
318
|
+
if (colon === -1) return { key: val, value: '' };
|
|
319
|
+
return { key: val.slice(0, colon), value: val.slice(colon + 1) };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** @private */
|
|
323
|
+
function _absorbMediaAttr(media, attr)
|
|
324
|
+
{
|
|
325
|
+
const { key, value } = attr;
|
|
326
|
+
|
|
327
|
+
if (DIRECTIONS.includes(key)) { media.direction = key; return; }
|
|
328
|
+
|
|
329
|
+
switch (key)
|
|
330
|
+
{
|
|
331
|
+
case 'mid': media.mid = value; return;
|
|
332
|
+
case 'rtcp-mux': media.rtcpMux = true; return;
|
|
333
|
+
case 'ice-ufrag': media.iceUfrag = value; return;
|
|
334
|
+
case 'ice-pwd': media.icePwd = value; return;
|
|
335
|
+
case 'setup': media.setup = value; return;
|
|
336
|
+
|
|
337
|
+
case 'fingerprint':
|
|
338
|
+
{
|
|
339
|
+
const space = value.indexOf(' ');
|
|
340
|
+
if (space === -1) return;
|
|
341
|
+
media.fingerprint = {
|
|
342
|
+
algorithm: value.slice(0, space).toLowerCase(),
|
|
343
|
+
value: value.slice(space + 1).trim().toUpperCase(),
|
|
344
|
+
};
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
case 'candidate':
|
|
349
|
+
media.candidates.push(`candidate:${value}`);
|
|
350
|
+
return;
|
|
351
|
+
|
|
352
|
+
case 'rtpmap':
|
|
353
|
+
{
|
|
354
|
+
// <PT> <codec>/<rate>[/<channels>]
|
|
355
|
+
const space = value.indexOf(' ');
|
|
356
|
+
if (space === -1) return;
|
|
357
|
+
const payload = Number(value.slice(0, space));
|
|
358
|
+
const tail = value.slice(space + 1).split('/');
|
|
359
|
+
media.rtpmaps.push({
|
|
360
|
+
payload,
|
|
361
|
+
codec: tail[0],
|
|
362
|
+
clockRate: Number(tail[1]),
|
|
363
|
+
channels: tail[2] !== undefined ? Number(tail[2]) : undefined,
|
|
364
|
+
});
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
case 'fmtp':
|
|
369
|
+
{
|
|
370
|
+
const space = value.indexOf(' ');
|
|
371
|
+
if (space === -1) return;
|
|
372
|
+
media.fmtps.push({
|
|
373
|
+
payload: Number(value.slice(0, space)),
|
|
374
|
+
config: value.slice(space + 1),
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
case 'rid':
|
|
380
|
+
{
|
|
381
|
+
// <id> <direction> [params]
|
|
382
|
+
const parts = value.split(/\s+/);
|
|
383
|
+
if (parts.length < 2) return;
|
|
384
|
+
media.rids.push({
|
|
385
|
+
id: parts[0],
|
|
386
|
+
direction: parts[1],
|
|
387
|
+
params: parts.slice(2).join(' '),
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
case 'simulcast':
|
|
393
|
+
{
|
|
394
|
+
// simulcast:<dir> <layers> [<dir> <layers>]
|
|
395
|
+
const parts = value.split(/\s+/);
|
|
396
|
+
for (let i = 0; i < parts.length; i += 2)
|
|
397
|
+
{
|
|
398
|
+
if (parts[i] && parts[i + 1] !== undefined)
|
|
399
|
+
media.simulcast[parts[i]] = parts[i + 1];
|
|
400
|
+
}
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
case 'extmap':
|
|
405
|
+
{
|
|
406
|
+
// <id>[/<direction>] <uri> [<config>]
|
|
407
|
+
const space = value.indexOf(' ');
|
|
408
|
+
if (space === -1) return;
|
|
409
|
+
const idPart = value.slice(0, space);
|
|
410
|
+
const rest = value.slice(space + 1).trim();
|
|
411
|
+
const [idStr, direction] = idPart.split('/');
|
|
412
|
+
const space2 = rest.indexOf(' ');
|
|
413
|
+
const uri = space2 === -1 ? rest : rest.slice(0, space2);
|
|
414
|
+
const config = space2 === -1 ? undefined : rest.slice(space2 + 1);
|
|
415
|
+
media.extmaps.push({ id: Number(idStr), direction, uri, config });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
case 'ssrc':
|
|
420
|
+
{
|
|
421
|
+
// <id> <attr>[:<value>]
|
|
422
|
+
const space = value.indexOf(' ');
|
|
423
|
+
if (space === -1) return;
|
|
424
|
+
const id = Number(value.slice(0, space));
|
|
425
|
+
const attrTok = value.slice(space + 1);
|
|
426
|
+
const colon = attrTok.indexOf(':');
|
|
427
|
+
const attribute = colon === -1 ? attrTok : attrTok.slice(0, colon);
|
|
428
|
+
const v = colon === -1 ? '' : attrTok.slice(colon + 1);
|
|
429
|
+
media.ssrcs.push({ id, attribute, value: v });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
default:
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// =================================================================
|
|
439
|
+
// Serializer
|
|
440
|
+
// =================================================================
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Serialize a `SessionDescription` back to RFC 8866 text with CRLF
|
|
444
|
+
* line endings. The serializer is round-trip safe for documents
|
|
445
|
+
* produced by `parseSdp`: it emits the raw attribute list verbatim so
|
|
446
|
+
* any media-level attribute we did not lift into a structured field is
|
|
447
|
+
* still preserved.
|
|
448
|
+
*
|
|
449
|
+
* @param {SessionDescription} session - Parsed session description.
|
|
450
|
+
* @returns {string} SDP document terminated with CRLF.
|
|
451
|
+
* @throws {SdpError} If required session fields are missing.
|
|
452
|
+
*
|
|
453
|
+
* @example
|
|
454
|
+
* const sdp = stringifySdp(parseSdp(offer.sdp));
|
|
455
|
+
*
|
|
456
|
+
* @section Signaling
|
|
457
|
+
*/
|
|
458
|
+
function stringifySdp(session)
|
|
459
|
+
{
|
|
460
|
+
if (!session || typeof session !== 'object')
|
|
461
|
+
throw new SdpError('stringifySdp: session must be an object');
|
|
462
|
+
if (typeof session.version !== 'number')
|
|
463
|
+
throw new SdpError('stringifySdp: missing version');
|
|
464
|
+
if (!session.origin)
|
|
465
|
+
throw new SdpError('stringifySdp: missing origin');
|
|
466
|
+
|
|
467
|
+
const out = [];
|
|
468
|
+
out.push(`v=${session.version}`);
|
|
469
|
+
out.push(`o=${_stringifyOrigin(session.origin)}`);
|
|
470
|
+
out.push(`s=${session.sessionName || '-'}`);
|
|
471
|
+
if (session.connection)
|
|
472
|
+
out.push(`c=${_stringifyConnection(session.connection)}`);
|
|
473
|
+
for (const t of session.timing || [])
|
|
474
|
+
out.push(`t=${t.start} ${t.stop}`);
|
|
475
|
+
for (const a of session.attributes || [])
|
|
476
|
+
out.push(a.value === '' ? `a=${a.key}` : `a=${a.key}:${a.value}`);
|
|
477
|
+
|
|
478
|
+
for (const m of session.media || [])
|
|
479
|
+
{
|
|
480
|
+
const portSpec = m.numPorts ? `${m.port}/${m.numPorts}` : String(m.port);
|
|
481
|
+
out.push(`m=${m.kind} ${portSpec} ${m.proto} ${(m.fmts || []).join(' ')}`.trim());
|
|
482
|
+
if (m.connection)
|
|
483
|
+
out.push(`c=${_stringifyConnection(m.connection)}`);
|
|
484
|
+
for (const a of m.attributes || [])
|
|
485
|
+
out.push(a.value === '' ? `a=${a.key}` : `a=${a.key}:${a.value}`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return out.join(CRLF) + CRLF;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** @private */
|
|
492
|
+
function _stringifyOrigin(o)
|
|
493
|
+
{
|
|
494
|
+
return `${o.username} ${o.sessionId} ${o.sessionVersion} ${o.netType} ${o.addrType} ${o.address}`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** @private */
|
|
498
|
+
function _stringifyConnection(c)
|
|
499
|
+
{
|
|
500
|
+
return `${c.netType} ${c.addrType} ${c.address}`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
module.exports = {
|
|
504
|
+
parseSdp,
|
|
505
|
+
stringifySdp,
|
|
506
|
+
SdpError,
|
|
507
|
+
DIRECTIONS,
|
|
508
|
+
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/sfu
|
|
3
|
+
* @description SFU adapter base interface and discovery loader.
|
|
4
|
+
*
|
|
5
|
+
* `SfuAdapter` defines the contract every backend (memory / mediasoup /
|
|
6
|
+
* livekit / custom) must implement. `loadSfuAdapter()` resolves either
|
|
7
|
+
* a pre-constructed instance, a known name ('memory', 'mediasoup',
|
|
8
|
+
* 'livekit'), or a duck-typed object into a concrete adapter, throwing
|
|
9
|
+
* `WEBRTC_SFU_NOT_INSTALLED` when a native peerDep is missing.
|
|
10
|
+
*/
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { WebRTCError } = require('../../errors');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Base class every SFU adapter inherits from. Subclasses MUST override
|
|
17
|
+
* every async method; the default implementations throw
|
|
18
|
+
* `WEBRTC_SFU_NOT_IMPLEMENTED` so partial adapters fail loudly.
|
|
19
|
+
*
|
|
20
|
+
* The interface is intentionally tiny so a backend can be written in a
|
|
21
|
+
* single file:
|
|
22
|
+
*
|
|
23
|
+
* class MyAdapter extends SfuAdapter {
|
|
24
|
+
* async createRouter(opts) { ... }
|
|
25
|
+
* async createTransport(router, peer) { ... }
|
|
26
|
+
* ...
|
|
27
|
+
* }
|
|
28
|
+
*/
|
|
29
|
+
class SfuAdapter
|
|
30
|
+
{
|
|
31
|
+
constructor()
|
|
32
|
+
{
|
|
33
|
+
this._handlers = new Set();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Override to create a routing context for a single room. */
|
|
37
|
+
async createRouter(_opts)
|
|
38
|
+
{
|
|
39
|
+
throw new WebRTCError('SfuAdapter.createRouter() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Override to allocate a WebRTC transport for a peer in a router. */
|
|
43
|
+
async createTransport(_router, _peer)
|
|
44
|
+
{
|
|
45
|
+
throw new WebRTCError('SfuAdapter.createTransport() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Override to bind a producer ('audio' | 'video') to a transport. */
|
|
49
|
+
async produce(_transport, _kind, _rtpParams)
|
|
50
|
+
{
|
|
51
|
+
throw new WebRTCError('SfuAdapter.produce() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Override to bind a consumer of `producerId` to a transport. */
|
|
55
|
+
async consume(_transport, _producerId, _rtpCaps)
|
|
56
|
+
{
|
|
57
|
+
throw new WebRTCError('SfuAdapter.consume() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Override to pause a producer (mute upstream forwarding). */
|
|
61
|
+
async pauseProducer(_producerId)
|
|
62
|
+
{
|
|
63
|
+
throw new WebRTCError('SfuAdapter.pauseProducer() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Override to resume a previously paused producer. */
|
|
67
|
+
async resumeProducer(_producerId)
|
|
68
|
+
{
|
|
69
|
+
throw new WebRTCError('SfuAdapter.resumeProducer() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Override to close a router and cascade-close its transports. */
|
|
73
|
+
async closeRouter(_routerId)
|
|
74
|
+
{
|
|
75
|
+
throw new WebRTCError('SfuAdapter.closeRouter() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Override to return adapter stats; `scope` may be a routerId/transportId. */
|
|
79
|
+
async stats(_scope)
|
|
80
|
+
{
|
|
81
|
+
throw new WebRTCError('SfuAdapter.stats() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Register a handler invoked as `(event, payload)` for adapter-level
|
|
86
|
+
* events ('producer-new', 'producer-pause', 'consumer-new',
|
|
87
|
+
* 'transport-close', 'router-close', etc.).
|
|
88
|
+
*
|
|
89
|
+
* Returns an unsubscribe function.
|
|
90
|
+
*/
|
|
91
|
+
onEvent(handler)
|
|
92
|
+
{
|
|
93
|
+
if (typeof handler !== 'function')
|
|
94
|
+
{
|
|
95
|
+
throw new WebRTCError('onEvent() handler must be a function', { code: 'WEBRTC_SFU_INVALID_HANDLER' });
|
|
96
|
+
}
|
|
97
|
+
this._handlers.add(handler);
|
|
98
|
+
return () => this._handlers.delete(handler);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Emit `event` with `payload` to every registered handler. */
|
|
102
|
+
_emit(event, payload)
|
|
103
|
+
{
|
|
104
|
+
for (const fn of this._handlers)
|
|
105
|
+
{
|
|
106
|
+
try { fn(event, payload); }
|
|
107
|
+
catch (_) { /* swallow handler errors so adapters keep running */ }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Lazy-load and instantiate an SFU adapter.
|
|
114
|
+
*
|
|
115
|
+
* @param {object|string} spec - one of:
|
|
116
|
+
* - an object exposing the SfuAdapter contract (returned as-is),
|
|
117
|
+
* - 'memory' | 'mediasoup' | 'livekit' | adapter package id.
|
|
118
|
+
* @param {object} [opts] - constructor options forwarded to the adapter.
|
|
119
|
+
* @returns {SfuAdapter}
|
|
120
|
+
*/
|
|
121
|
+
function loadSfuAdapter(spec, opts)
|
|
122
|
+
{
|
|
123
|
+
if (spec && typeof spec === 'object' && typeof spec.createRouter === 'function')
|
|
124
|
+
{
|
|
125
|
+
return spec;
|
|
126
|
+
}
|
|
127
|
+
if (typeof spec !== 'string' || spec.length === 0)
|
|
128
|
+
{
|
|
129
|
+
throw new WebRTCError(
|
|
130
|
+
'loadSfuAdapter() requires an adapter instance or a name (memory|mediasoup|livekit|<package>)',
|
|
131
|
+
{ code: 'WEBRTC_SFU_INVALID_SPEC' },
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (spec === 'memory')
|
|
136
|
+
{
|
|
137
|
+
const { MemorySfuAdapter } = require('./memory');
|
|
138
|
+
return new MemorySfuAdapter(opts);
|
|
139
|
+
}
|
|
140
|
+
if (spec === 'mediasoup')
|
|
141
|
+
{
|
|
142
|
+
const Ctor = _tryRequireAdapter('./mediasoup', 'mediasoup');
|
|
143
|
+
return new Ctor(opts);
|
|
144
|
+
}
|
|
145
|
+
if (spec === 'livekit')
|
|
146
|
+
{
|
|
147
|
+
const Ctor = _tryRequireAdapter('./livekit', 'livekit-server-sdk');
|
|
148
|
+
return new Ctor(opts);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// External adapter package - must export `default` or a class.
|
|
152
|
+
let mod;
|
|
153
|
+
try { mod = require(spec); }
|
|
154
|
+
catch (err)
|
|
155
|
+
{
|
|
156
|
+
throw new WebRTCError(
|
|
157
|
+
`SFU adapter package '${spec}' is not installed: ${err.message}`,
|
|
158
|
+
{ code: 'WEBRTC_SFU_NOT_INSTALLED', cause: err },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const Ctor = mod && (mod.default || mod);
|
|
162
|
+
if (typeof Ctor !== 'function')
|
|
163
|
+
{
|
|
164
|
+
throw new WebRTCError(
|
|
165
|
+
`SFU adapter package '${spec}' does not export a class or default constructor`,
|
|
166
|
+
{ code: 'WEBRTC_SFU_INVALID_PACKAGE' },
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return new Ctor(opts);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @private
|
|
174
|
+
* Try to load a built-in adapter module; surface a clean install message
|
|
175
|
+
* when the wrapped peerDependency is missing.
|
|
176
|
+
*/
|
|
177
|
+
function _tryRequireAdapter(localPath, peerPkg)
|
|
178
|
+
{
|
|
179
|
+
let mod;
|
|
180
|
+
try { mod = require(localPath); }
|
|
181
|
+
catch (err)
|
|
182
|
+
{
|
|
183
|
+
throw new WebRTCError(
|
|
184
|
+
`SFU adapter '${peerPkg}' requires the '${peerPkg}' peerDependency: npm install ${peerPkg}`,
|
|
185
|
+
{ code: 'WEBRTC_SFU_NOT_INSTALLED', cause: err },
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
// The wrapper itself tries `require(peerPkg)`; rethrow with the install
|
|
189
|
+
// hint if construction fails for that reason.
|
|
190
|
+
const Ctor = mod && (mod.default || Object.values(mod).find((v) => typeof v === 'function'));
|
|
191
|
+
if (typeof Ctor !== 'function')
|
|
192
|
+
{
|
|
193
|
+
throw new WebRTCError(
|
|
194
|
+
`SFU adapter module '${localPath}' did not export a constructor`,
|
|
195
|
+
{ code: 'WEBRTC_SFU_INVALID_ADAPTER' },
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return Ctor;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = { SfuAdapter, loadSfuAdapter };
|