@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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +37 -0
  3. package/index.d.ts +2 -0
  4. package/index.js +53 -0
  5. package/lib/auth/index.js +1 -0
  6. package/lib/debug.js +372 -0
  7. package/lib/errors.js +1 -0
  8. package/lib/middleware/index.js +1 -0
  9. package/lib/observe/index.js +1 -0
  10. package/lib/webrtc/bot.js +361 -0
  11. package/lib/webrtc/cli.js +182 -0
  12. package/lib/webrtc/cluster.js +350 -0
  13. package/lib/webrtc/e2ee.js +282 -0
  14. package/lib/webrtc/ice.js +370 -0
  15. package/lib/webrtc/index.js +132 -0
  16. package/lib/webrtc/joinToken.js +116 -0
  17. package/lib/webrtc/observe.js +229 -0
  18. package/lib/webrtc/peer.js +116 -0
  19. package/lib/webrtc/room.js +171 -0
  20. package/lib/webrtc/sdp.js +508 -0
  21. package/lib/webrtc/sfu/index.js +201 -0
  22. package/lib/webrtc/sfu/livekit.js +301 -0
  23. package/lib/webrtc/sfu/mediasoup.js +317 -0
  24. package/lib/webrtc/sfu/memory.js +204 -0
  25. package/lib/webrtc/signaling.js +546 -0
  26. package/lib/webrtc/stun.js +492 -0
  27. package/lib/webrtc/turn/codec.js +370 -0
  28. package/lib/webrtc/turn/credentials.js +141 -0
  29. package/lib/webrtc/turn/server.js +633 -0
  30. package/lib/ws/index.js +1 -0
  31. package/package.json +62 -0
  32. package/types/app.d.ts +223 -0
  33. package/types/auth.d.ts +520 -0
  34. package/types/body.d.ts +14 -0
  35. package/types/cli.d.ts +2 -0
  36. package/types/cluster.d.ts +75 -0
  37. package/types/env.d.ts +80 -0
  38. package/types/errors.d.ts +316 -0
  39. package/types/fetch.d.ts +43 -0
  40. package/types/grpc.d.ts +432 -0
  41. package/types/index.d.ts +396 -0
  42. package/types/lifecycle.d.ts +60 -0
  43. package/types/middleware.d.ts +320 -0
  44. package/types/observe.d.ts +304 -0
  45. package/types/orm.d.ts +1887 -0
  46. package/types/request.d.ts +109 -0
  47. package/types/response.d.ts +157 -0
  48. package/types/router.d.ts +78 -0
  49. package/types/sse.d.ts +78 -0
  50. package/types/webrtc.d.ts +501 -0
  51. 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 };