@zero-server/webrtc 0.9.9 → 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/README.md +1 -1
- package/index.js +8 -0
- package/lib/webrtc/cascade.js +577 -0
- package/lib/webrtc/cluster.js +149 -3
- package/lib/webrtc/index.js +73 -11
- package/lib/webrtc/mcu/ffmpeg.js +172 -0
- package/lib/webrtc/mcu/index.js +211 -0
- package/lib/webrtc/observe.js +38 -0
- package/lib/webrtc/recording.js +410 -0
- package/lib/webrtc/room.js +42 -0
- package/lib/webrtc/sfu/index.js +129 -0
- package/lib/webrtc/sfu/livekit.js +304 -0
- package/lib/webrtc/sfu/mediasoup.js +482 -13
- package/lib/webrtc/sfu/memory.js +316 -3
- package/lib/webrtc/signaling.js +209 -7
- package/package.json +7 -7
- package/types/webrtc.d.ts +348 -0
package/lib/webrtc/sfu/memory.js
CHANGED
|
@@ -42,9 +42,14 @@ class MemorySfuAdapter extends SfuAdapter
|
|
|
42
42
|
this._opts = opts || {};
|
|
43
43
|
this._counter = 0;
|
|
44
44
|
this._routers = new Map(); // routerId -> { id, opts, transports:Set, closed }
|
|
45
|
-
this._transports = new Map(); // transportId -> { id, routerId, peer, producers:Set, consumers:Set, closed }
|
|
45
|
+
this._transports = new Map(); // transportId -> { id, routerId, peer, producers:Set, consumers:Set, closed, bitrates }
|
|
46
46
|
this._producers = new Map(); // producerId -> { id, transportId, kind, rtpParams, paused, closed }
|
|
47
|
-
this._consumers = new Map(); // consumerId -> { id, transportId, producerId, rtpCaps, closed }
|
|
47
|
+
this._consumers = new Map(); // consumerId -> { id, transportId, producerId, rtpCaps, closed, paused, priority, preferredLayers, keyFrameRequests }
|
|
48
|
+
this._dataProducers = new Map(); // dataProducerId -> { id, transportId, label, ordered, closed }
|
|
49
|
+
this._dataConsumers = new Map(); // dataConsumerId -> { id, transportId, dataProducerId, closed }
|
|
50
|
+
this._observers = new Map(); // observerId -> { id, kind, routerId, closed }
|
|
51
|
+
this._pipes = new Map(); // pipeId -> { id, producerId, localRouterId, remoteRouterId, pipeProducerId, pipeConsumerId }
|
|
52
|
+
this._traceTypes = new Map(); // routerId -> Set<string>
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
_nextId(prefix)
|
|
@@ -129,6 +134,10 @@ class MemorySfuAdapter extends SfuAdapter
|
|
|
129
134
|
rtpParams: prod.rtpParams,
|
|
130
135
|
rtpCaps: rtpCaps || {},
|
|
131
136
|
closed: false,
|
|
137
|
+
paused: false,
|
|
138
|
+
priority: 1,
|
|
139
|
+
preferredLayers: null,
|
|
140
|
+
keyFrameRequests: 0,
|
|
132
141
|
};
|
|
133
142
|
this._consumers.set(id, c);
|
|
134
143
|
t.consumers.add(id);
|
|
@@ -198,7 +207,13 @@ class MemorySfuAdapter extends SfuAdapter
|
|
|
198
207
|
if (scope && this._routers.has(scope))
|
|
199
208
|
{
|
|
200
209
|
const r = this._routers.get(scope);
|
|
201
|
-
|
|
210
|
+
const trace = this._traceTypes.get(scope);
|
|
211
|
+
return {
|
|
212
|
+
kind: 'router',
|
|
213
|
+
routerId: scope,
|
|
214
|
+
transports: r.transports.size,
|
|
215
|
+
traceTypes: trace ? [...trace] : [],
|
|
216
|
+
};
|
|
202
217
|
}
|
|
203
218
|
if (scope && this._transports.has(scope))
|
|
204
219
|
{
|
|
@@ -206,6 +221,7 @@ class MemorySfuAdapter extends SfuAdapter
|
|
|
206
221
|
return {
|
|
207
222
|
kind: 'transport', transportId: scope, routerId: t.routerId,
|
|
208
223
|
producers: t.producers.size, consumers: t.consumers.size,
|
|
224
|
+
bitrates: t.bitrates || null,
|
|
209
225
|
};
|
|
210
226
|
}
|
|
211
227
|
return {
|
|
@@ -216,6 +232,303 @@ class MemorySfuAdapter extends SfuAdapter
|
|
|
216
232
|
consumers: this._consumers.size,
|
|
217
233
|
};
|
|
218
234
|
}
|
|
235
|
+
|
|
236
|
+
// -- Consumer-side BWE / quality knobs --
|
|
237
|
+
|
|
238
|
+
async setConsumerPreferredLayers(consumerId, layers)
|
|
239
|
+
{
|
|
240
|
+
const c = this._consumers.get(consumerId);
|
|
241
|
+
if (!c || c.closed)
|
|
242
|
+
{
|
|
243
|
+
throw new WebRTCError('setConsumerPreferredLayers: unknown consumer', { code: 'WEBRTC_SFU_NO_CONSUMER' });
|
|
244
|
+
}
|
|
245
|
+
if (!layers || typeof layers.spatialLayer !== 'number')
|
|
246
|
+
{
|
|
247
|
+
throw new WebRTCError('setConsumerPreferredLayers: layers.spatialLayer must be a number', { code: 'WEBRTC_SFU_INVALID_LAYERS' });
|
|
248
|
+
}
|
|
249
|
+
c.preferredLayers = {
|
|
250
|
+
spatialLayer: layers.spatialLayer,
|
|
251
|
+
temporalLayer: typeof layers.temporalLayer === 'number' ? layers.temporalLayer : null,
|
|
252
|
+
};
|
|
253
|
+
this._emit('consumer-layers-change', { consumerId, layers: c.preferredLayers });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async setConsumerPriority(consumerId, priority)
|
|
257
|
+
{
|
|
258
|
+
const c = this._consumers.get(consumerId);
|
|
259
|
+
if (!c || c.closed)
|
|
260
|
+
{
|
|
261
|
+
throw new WebRTCError('setConsumerPriority: unknown consumer', { code: 'WEBRTC_SFU_NO_CONSUMER' });
|
|
262
|
+
}
|
|
263
|
+
const p = Number(priority);
|
|
264
|
+
if (!Number.isFinite(p) || p < 1 || p > 255)
|
|
265
|
+
{
|
|
266
|
+
throw new WebRTCError('setConsumerPriority: priority must be 1..255', { code: 'WEBRTC_SFU_INVALID_PRIORITY' });
|
|
267
|
+
}
|
|
268
|
+
c.priority = p;
|
|
269
|
+
this._emit('consumer-priority-change', { consumerId, priority: p });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async requestKeyFrame(consumerId)
|
|
273
|
+
{
|
|
274
|
+
const c = this._consumers.get(consumerId);
|
|
275
|
+
if (!c || c.closed)
|
|
276
|
+
{
|
|
277
|
+
throw new WebRTCError('requestKeyFrame: unknown consumer', { code: 'WEBRTC_SFU_NO_CONSUMER' });
|
|
278
|
+
}
|
|
279
|
+
c.keyFrameRequests += 1;
|
|
280
|
+
this._emit('consumer-keyframe', { consumerId, producerId: c.producerId, count: c.keyFrameRequests });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async pauseConsumer(consumerId)
|
|
284
|
+
{
|
|
285
|
+
const c = this._consumers.get(consumerId);
|
|
286
|
+
if (!c || c.closed)
|
|
287
|
+
{
|
|
288
|
+
throw new WebRTCError('pauseConsumer: unknown consumer', { code: 'WEBRTC_SFU_NO_CONSUMER' });
|
|
289
|
+
}
|
|
290
|
+
if (!c.paused)
|
|
291
|
+
{
|
|
292
|
+
c.paused = true;
|
|
293
|
+
this._emit('consumer-pause', { consumerId });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async resumeConsumer(consumerId)
|
|
298
|
+
{
|
|
299
|
+
const c = this._consumers.get(consumerId);
|
|
300
|
+
if (!c || c.closed)
|
|
301
|
+
{
|
|
302
|
+
throw new WebRTCError('resumeConsumer: unknown consumer', { code: 'WEBRTC_SFU_NO_CONSUMER' });
|
|
303
|
+
}
|
|
304
|
+
if (c.paused)
|
|
305
|
+
{
|
|
306
|
+
c.paused = false;
|
|
307
|
+
this._emit('consumer-resume', { consumerId });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// -- Transport bitrates --
|
|
312
|
+
|
|
313
|
+
async setTransportBitrates(transportId, opts)
|
|
314
|
+
{
|
|
315
|
+
const t = this._transports.get(transportId);
|
|
316
|
+
if (!t || t.closed)
|
|
317
|
+
{
|
|
318
|
+
throw new WebRTCError('setTransportBitrates: unknown transport', { code: 'WEBRTC_SFU_NO_TRANSPORT' });
|
|
319
|
+
}
|
|
320
|
+
t.bitrates = { ...(t.bitrates || {}), ...(opts || {}) };
|
|
321
|
+
this._emit('transport-bitrates', { transportId, bitrates: t.bitrates });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// -- Data channels --
|
|
325
|
+
|
|
326
|
+
async produceData(transport, opts)
|
|
327
|
+
{
|
|
328
|
+
const t = transport && this._transports.get(transport.id);
|
|
329
|
+
if (!t || t.closed)
|
|
330
|
+
{
|
|
331
|
+
throw new WebRTCError('produceData: unknown transport', { code: 'WEBRTC_SFU_NO_TRANSPORT' });
|
|
332
|
+
}
|
|
333
|
+
const id = this._nextId('dataProducer');
|
|
334
|
+
const dp = {
|
|
335
|
+
id,
|
|
336
|
+
dataProducerId: id,
|
|
337
|
+
transportId: t.id,
|
|
338
|
+
label: (opts && opts.label) || '',
|
|
339
|
+
protocol: (opts && opts.protocol) || '',
|
|
340
|
+
ordered: opts && opts.ordered !== undefined ? !!opts.ordered : true,
|
|
341
|
+
closed: false,
|
|
342
|
+
};
|
|
343
|
+
this._dataProducers.set(id, dp);
|
|
344
|
+
this._emit('data-producer-new', { dataProducerId: id, transportId: t.id, label: dp.label });
|
|
345
|
+
return dp;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async consumeData(transport, dataProducerId, opts)
|
|
349
|
+
{
|
|
350
|
+
const t = transport && this._transports.get(transport.id);
|
|
351
|
+
if (!t || t.closed)
|
|
352
|
+
{
|
|
353
|
+
throw new WebRTCError('consumeData: unknown transport', { code: 'WEBRTC_SFU_NO_TRANSPORT' });
|
|
354
|
+
}
|
|
355
|
+
const dp = this._dataProducers.get(dataProducerId);
|
|
356
|
+
if (!dp || dp.closed)
|
|
357
|
+
{
|
|
358
|
+
throw new WebRTCError('consumeData: unknown data producer', { code: 'WEBRTC_SFU_NO_DATA_PRODUCER' });
|
|
359
|
+
}
|
|
360
|
+
const id = this._nextId('dataConsumer');
|
|
361
|
+
const dc = {
|
|
362
|
+
id,
|
|
363
|
+
dataConsumerId: id,
|
|
364
|
+
transportId: t.id,
|
|
365
|
+
dataProducerId,
|
|
366
|
+
label: dp.label,
|
|
367
|
+
protocol: dp.protocol,
|
|
368
|
+
ordered: opts && opts.ordered !== undefined ? !!opts.ordered : dp.ordered,
|
|
369
|
+
closed: false,
|
|
370
|
+
};
|
|
371
|
+
this._dataConsumers.set(id, dc);
|
|
372
|
+
this._emit('data-consumer-new', { dataConsumerId: id, transportId: t.id, dataProducerId });
|
|
373
|
+
return dc;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// -- Observers --
|
|
377
|
+
|
|
378
|
+
async observeAudioLevels(routerId, opts)
|
|
379
|
+
{
|
|
380
|
+
const r = this._routers.get(routerId);
|
|
381
|
+
if (!r || r.closed)
|
|
382
|
+
{
|
|
383
|
+
throw new WebRTCError('observeAudioLevels: unknown router', { code: 'WEBRTC_SFU_NO_ROUTER' });
|
|
384
|
+
}
|
|
385
|
+
const id = this._nextId('audioObserver');
|
|
386
|
+
const handle = {
|
|
387
|
+
id,
|
|
388
|
+
kind: 'audio-level',
|
|
389
|
+
routerId,
|
|
390
|
+
opts: opts || {},
|
|
391
|
+
closed: false,
|
|
392
|
+
close: async () =>
|
|
393
|
+
{
|
|
394
|
+
if (handle.closed) return;
|
|
395
|
+
handle.closed = true;
|
|
396
|
+
this._observers.delete(id);
|
|
397
|
+
this._emit('observer-close', { observerId: id, kind: 'audio-level' });
|
|
398
|
+
},
|
|
399
|
+
// Test seam: feed synthetic samples through the adapter's event bus.
|
|
400
|
+
emit: (levels) =>
|
|
401
|
+
{
|
|
402
|
+
if (handle.closed) return;
|
|
403
|
+
this._emit('audio-level', { observerId: id, routerId, levels });
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
this._observers.set(id, handle);
|
|
407
|
+
this._emit('observer-new', { observerId: id, kind: 'audio-level', routerId });
|
|
408
|
+
return handle;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async observeActiveSpeaker(routerId, opts)
|
|
412
|
+
{
|
|
413
|
+
const r = this._routers.get(routerId);
|
|
414
|
+
if (!r || r.closed)
|
|
415
|
+
{
|
|
416
|
+
throw new WebRTCError('observeActiveSpeaker: unknown router', { code: 'WEBRTC_SFU_NO_ROUTER' });
|
|
417
|
+
}
|
|
418
|
+
const id = this._nextId('speakerObserver');
|
|
419
|
+
const handle = {
|
|
420
|
+
id,
|
|
421
|
+
kind: 'active-speaker',
|
|
422
|
+
routerId,
|
|
423
|
+
opts: opts || {},
|
|
424
|
+
closed: false,
|
|
425
|
+
close: async () =>
|
|
426
|
+
{
|
|
427
|
+
if (handle.closed) return;
|
|
428
|
+
handle.closed = true;
|
|
429
|
+
this._observers.delete(id);
|
|
430
|
+
this._emit('observer-close', { observerId: id, kind: 'active-speaker' });
|
|
431
|
+
},
|
|
432
|
+
// Test seam: announce a synthetic dominant speaker.
|
|
433
|
+
emit: (producerId) =>
|
|
434
|
+
{
|
|
435
|
+
if (handle.closed) return;
|
|
436
|
+
this._emit('active-speaker', { observerId: id, routerId, producerId });
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
this._observers.set(id, handle);
|
|
440
|
+
this._emit('observer-new', { observerId: id, kind: 'active-speaker', routerId });
|
|
441
|
+
return handle;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// -- Cascade --
|
|
445
|
+
|
|
446
|
+
async pipeToRouter(opts)
|
|
447
|
+
{
|
|
448
|
+
const o = opts || {};
|
|
449
|
+
const prod = o.producerId && this._producers.get(o.producerId);
|
|
450
|
+
if (!prod || prod.closed)
|
|
451
|
+
{
|
|
452
|
+
throw new WebRTCError('pipeToRouter: unknown producer', { code: 'WEBRTC_SFU_NO_PRODUCER' });
|
|
453
|
+
}
|
|
454
|
+
const local = o.localRouterId && this._routers.get(o.localRouterId);
|
|
455
|
+
if (!local)
|
|
456
|
+
{
|
|
457
|
+
throw new WebRTCError('pipeToRouter: unknown local router', { code: 'WEBRTC_SFU_NO_ROUTER' });
|
|
458
|
+
}
|
|
459
|
+
if (!o.remoteRouter || (!o.remoteRouter.id && !o.remoteRouter.routerId))
|
|
460
|
+
{
|
|
461
|
+
throw new WebRTCError('pipeToRouter: opts.remoteRouter is required', { code: 'WEBRTC_SFU_INVALID_PIPE' });
|
|
462
|
+
}
|
|
463
|
+
const remoteRouterId = o.remoteRouter.id || o.remoteRouter.routerId;
|
|
464
|
+
const id = this._nextId('pipe');
|
|
465
|
+
const pipeProducerId = this._nextId('pipeProducer');
|
|
466
|
+
const pipeConsumerId = this._nextId('pipeConsumer');
|
|
467
|
+
const handle = {
|
|
468
|
+
id,
|
|
469
|
+
pipeId: id,
|
|
470
|
+
producerId: o.producerId,
|
|
471
|
+
localRouterId: o.localRouterId,
|
|
472
|
+
remoteRouterId,
|
|
473
|
+
pipeProducerId,
|
|
474
|
+
pipeConsumerId,
|
|
475
|
+
};
|
|
476
|
+
this._pipes.set(id, handle);
|
|
477
|
+
this._emit('pipe-open', handle);
|
|
478
|
+
return handle;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// -- Targeted stats --
|
|
482
|
+
|
|
483
|
+
async getProducerStats(producerId)
|
|
484
|
+
{
|
|
485
|
+
const p = this._producers.get(producerId);
|
|
486
|
+
if (!p)
|
|
487
|
+
{
|
|
488
|
+
throw new WebRTCError('getProducerStats: unknown producer', { code: 'WEBRTC_SFU_NO_PRODUCER' });
|
|
489
|
+
}
|
|
490
|
+
return [{ type: 'inbound-rtp', producerId, kind: p.kind, paused: p.paused, timestamp: Date.now() }];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async getConsumerStats(consumerId)
|
|
494
|
+
{
|
|
495
|
+
const c = this._consumers.get(consumerId);
|
|
496
|
+
if (!c)
|
|
497
|
+
{
|
|
498
|
+
throw new WebRTCError('getConsumerStats: unknown consumer', { code: 'WEBRTC_SFU_NO_CONSUMER' });
|
|
499
|
+
}
|
|
500
|
+
return [{
|
|
501
|
+
type: 'outbound-rtp', consumerId, producerId: c.producerId,
|
|
502
|
+
kind: c.kind, paused: c.paused, priority: c.priority,
|
|
503
|
+
preferredLayers: c.preferredLayers, timestamp: Date.now(),
|
|
504
|
+
}];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async getTransportStats(transportId)
|
|
508
|
+
{
|
|
509
|
+
const t = this._transports.get(transportId);
|
|
510
|
+
if (!t)
|
|
511
|
+
{
|
|
512
|
+
throw new WebRTCError('getTransportStats: unknown transport', { code: 'WEBRTC_SFU_NO_TRANSPORT' });
|
|
513
|
+
}
|
|
514
|
+
return [{
|
|
515
|
+
type: 'transport', transportId, routerId: t.routerId,
|
|
516
|
+
producers: t.producers.size, consumers: t.consumers.size,
|
|
517
|
+
bitrates: t.bitrates || null, timestamp: Date.now(),
|
|
518
|
+
}];
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async enableTraceEvent(routerId, types)
|
|
522
|
+
{
|
|
523
|
+
const r = this._routers.get(routerId);
|
|
524
|
+
if (!r || r.closed)
|
|
525
|
+
{
|
|
526
|
+
throw new WebRTCError('enableTraceEvent: unknown router', { code: 'WEBRTC_SFU_NO_ROUTER' });
|
|
527
|
+
}
|
|
528
|
+
const set = new Set(Array.isArray(types) ? types : []);
|
|
529
|
+
this._traceTypes.set(routerId, set);
|
|
530
|
+
this._emit('trace-enabled', { routerId, types: [...set] });
|
|
531
|
+
}
|
|
219
532
|
}
|
|
220
533
|
|
|
221
534
|
module.exports = { MemorySfuAdapter };
|
package/lib/webrtc/signaling.js
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
* offer / answer / ICE traffic. Transport-agnostic — bind to `app.ws()`
|
|
6
6
|
* in production, an `EventEmitter` shim in tests.
|
|
7
7
|
*
|
|
8
|
+
* SDP validation is cross-browser by design: session-level
|
|
9
|
+
* `a=fingerprint` / `a=ice-ufrag` / `a=ice-pwd` lines are accepted as a
|
|
10
|
+
* fallback for media sections that omit their own copy (RFC 8839 §5.4,
|
|
11
|
+
* RFC 8122 §5). This is required for Firefox interop — Firefox emits
|
|
12
|
+
* `a=fingerprint` only at session level.
|
|
13
|
+
*
|
|
8
14
|
* @example | Bind a hub to an `app.ws()` route with all production knobs
|
|
9
15
|
* const app = createApp();
|
|
10
16
|
* const hub = new SignalingHub({
|
|
@@ -44,6 +50,7 @@ const { parseCandidate } = require('./ice');
|
|
|
44
50
|
const { Peer, PEER_STATE } = require('./peer');
|
|
45
51
|
const { Room } = require('./room');
|
|
46
52
|
const { verifyJoinToken } = require('./joinToken');
|
|
53
|
+
const { loadSfuAdapter } = require('./sfu');
|
|
47
54
|
|
|
48
55
|
// --- Constants ---
|
|
49
56
|
|
|
@@ -62,6 +69,17 @@ const DEFAULT_MAX_PROTOCOL_ERRORS = 5;
|
|
|
62
69
|
/** Default rolling window (sec) for the per-IP attach rate limit. */
|
|
63
70
|
const IP_ATTACH_WINDOW_SEC = 60;
|
|
64
71
|
|
|
72
|
+
/** Allowed values for the room topology hint. */
|
|
73
|
+
const VALID_TOPOLOGIES = new Set(['mesh', 'sfu', 'mcu', 'auto']);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Default peer count at which an `auto` room is promoted from full mesh
|
|
77
|
+
* to SFU. Past this count an N-peer mesh costs O(N²) uplink and quickly
|
|
78
|
+
* saturates mobile radios; standard guidance (bloggeek.me, Jitsi, Daily)
|
|
79
|
+
* is to break the mesh between 4 and 6 participants.
|
|
80
|
+
*/
|
|
81
|
+
const DEFAULT_MAX_MESH_PEERS = 4;
|
|
82
|
+
|
|
65
83
|
/** Set of message `type`s the hub will dispatch. */
|
|
66
84
|
const VALID_TYPES = new Set([
|
|
67
85
|
'join', 'leave', 'offer', 'answer', 'ice',
|
|
@@ -88,6 +106,11 @@ function _countCandidatesInSdp(sdp)
|
|
|
88
106
|
* sections (RFC 8841 m=application ... UDP/DTLS/SCTP). When BUNDLE is in use
|
|
89
107
|
* (every browser since ~2018), only the first m-section is required to carry
|
|
90
108
|
* iceUfrag/icePwd; other bundled sections inherit them via a=group:BUNDLE.
|
|
109
|
+
*
|
|
110
|
+
* Session-level `a=fingerprint`, `a=ice-ufrag`, and `a=ice-pwd` lines are
|
|
111
|
+
* honoured: per RFC 8839 §5.4 and RFC 8122 §5 they apply to every media
|
|
112
|
+
* section that omits its own copy. Firefox emits `a=fingerprint` only at
|
|
113
|
+
* session level, so this fallback is required for cross-browser interop.
|
|
91
114
|
*/
|
|
92
115
|
function _validateSdpStructure(sdp)
|
|
93
116
|
{
|
|
@@ -102,13 +125,22 @@ function _validateSdpStructure(sdp)
|
|
|
102
125
|
|
|
103
126
|
// BUNDLE: collect bundled mids so per-section ice credentials are optional
|
|
104
127
|
// on every section except the first (the BUNDLE owner).
|
|
128
|
+
// Also collect session-level fingerprint / ice-ufrag / ice-pwd, which per
|
|
129
|
+
// RFC 8839 §5.4 and RFC 8122 §5 apply to every media section that omits
|
|
130
|
+
// its own copy (Firefox emits fingerprint only at session level).
|
|
105
131
|
const bundleMids = new Set();
|
|
132
|
+
let sessFingerprint = null, sessIceUfrag = null, sessIcePwd = null;
|
|
106
133
|
for (const a of desc.attributes || [])
|
|
107
134
|
{
|
|
108
|
-
if (a.key
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
135
|
+
if (a.key === 'group')
|
|
136
|
+
{
|
|
137
|
+
const parts = String(a.value || '').split(/\s+/);
|
|
138
|
+
if (parts[0] === 'BUNDLE')
|
|
139
|
+
for (let i = 1; i < parts.length; i++) bundleMids.add(parts[i]);
|
|
140
|
+
}
|
|
141
|
+
else if (a.key === 'fingerprint' && a.value) sessFingerprint = a.value;
|
|
142
|
+
else if (a.key === 'ice-ufrag' && a.value) sessIceUfrag = a.value;
|
|
143
|
+
else if (a.key === 'ice-pwd' && a.value) sessIcePwd = a.value;
|
|
112
144
|
}
|
|
113
145
|
|
|
114
146
|
let firstBundleMid = null;
|
|
@@ -121,14 +153,18 @@ function _validateSdpStructure(sdp)
|
|
|
121
153
|
const isSctp = /^(UDP|TCP)\/DTLS\/SCTP$/i.test(m.proto);
|
|
122
154
|
if (!isRtp && !isSctp) return 'INVALID_SDP';
|
|
123
155
|
|
|
124
|
-
|
|
156
|
+
const fp = m.fingerprint || sessFingerprint;
|
|
157
|
+
const ufrag = m.iceUfrag || sessIceUfrag;
|
|
158
|
+
const pwd = m.icePwd || sessIcePwd;
|
|
159
|
+
|
|
160
|
+
if (!fp) return 'INVALID_SDP';
|
|
125
161
|
|
|
126
162
|
// ice-ufrag / ice-pwd: required on the BUNDLE owner; optional on
|
|
127
163
|
// every other bundled section (inherited per RFC 8843 §9.2).
|
|
128
164
|
const bundled = m.mid && bundleMids.has(m.mid);
|
|
129
165
|
if (bundled && firstBundleMid === null) firstBundleMid = m.mid;
|
|
130
166
|
const isBundleOwner = !bundled || m.mid === firstBundleMid;
|
|
131
|
-
if (isBundleOwner && (!
|
|
167
|
+
if (isBundleOwner && (!ufrag || !pwd)) return 'INVALID_SDP';
|
|
132
168
|
}
|
|
133
169
|
return null;
|
|
134
170
|
}
|
|
@@ -139,6 +175,10 @@ function _validateSdpStructure(sdp)
|
|
|
139
175
|
* Central WebRTC signaling broker. Owns rooms, attaches peers, validates
|
|
140
176
|
* JSEP traffic, and emits `join` / `leave` / `error` lifecycle events.
|
|
141
177
|
*
|
|
178
|
+
* Interop: SDP validation accepts session-level `a=fingerprint`,
|
|
179
|
+
* `a=ice-ufrag`, and `a=ice-pwd` as a fallback when a media section omits
|
|
180
|
+
* them (RFC 8839 §5.4 / RFC 8122 §5). Required for Firefox.
|
|
181
|
+
*
|
|
142
182
|
* @class
|
|
143
183
|
* @section Signaling
|
|
144
184
|
*
|
|
@@ -168,6 +208,19 @@ class SignalingHub extends EventEmitter
|
|
|
168
208
|
* @param {string|Buffer} [opts.joinTokenSecret] - If set, every `join` must include a valid
|
|
169
209
|
* JWT signed with this secret and audience `room:<name>`.
|
|
170
210
|
* @param {boolean} [opts.autoCreateRooms=true] - If false, joins targeting an unknown room are rejected.
|
|
211
|
+
* @param {'mesh'|'sfu'|'mcu'|'auto'} [opts.topology='auto'] - Default room topology. `auto` starts every room
|
|
212
|
+
* as full mesh and promotes to `sfu` once the peer count
|
|
213
|
+
* exceeds `maxMeshPeers`. Pure signaling is unchanged;
|
|
214
|
+
* the hint is broadcast to peers and surfaced via
|
|
215
|
+
* `hub.stats()` so clients can switch transports.
|
|
216
|
+
* @param {number} [opts.maxMeshPeers=4] - Peer count at which an `auto` room is promoted from
|
|
217
|
+
* mesh to SFU (and at which a `mesh` room emits
|
|
218
|
+
* `peer:limit:reached` for capacity alarms).
|
|
219
|
+
* @param {SfuAdapter|string} [opts.sfu] - Optional SFU adapter or spec string (memory|mediasoup|livekit|<pkg>).
|
|
220
|
+
* Mounts a `hub.media` facade backed by this adapter so
|
|
221
|
+
* application code can drive routers/transports/producers
|
|
222
|
+
* through the hub without importing the adapter directly.
|
|
223
|
+
* @param {object} [opts.sfuOpts] - Adapter constructor options, forwarded when `opts.sfu` is a string.
|
|
171
224
|
*/
|
|
172
225
|
constructor(opts = {})
|
|
173
226
|
{
|
|
@@ -199,6 +252,21 @@ class SignalingHub extends EventEmitter
|
|
|
199
252
|
/** @type {boolean} */
|
|
200
253
|
this.autoCreateRooms = opts.autoCreateRooms !== false;
|
|
201
254
|
|
|
255
|
+
/** @type {'mesh'|'sfu'|'mcu'|'auto'} */
|
|
256
|
+
if (opts.topology != null && !VALID_TOPOLOGIES.has(opts.topology))
|
|
257
|
+
{
|
|
258
|
+
throw new SignalingError(
|
|
259
|
+
`invalid topology "${opts.topology}" (expected mesh|sfu|mcu|auto)`,
|
|
260
|
+
{ code: 'WEBRTC_INVALID_TOPOLOGY' },
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
this.defaultTopology = opts.topology || 'auto';
|
|
264
|
+
|
|
265
|
+
/** @type {number} */
|
|
266
|
+
this.maxMeshPeers = Number.isFinite(opts.maxMeshPeers) && opts.maxMeshPeers > 0
|
|
267
|
+
? Math.floor(opts.maxMeshPeers)
|
|
268
|
+
: DEFAULT_MAX_MESH_PEERS;
|
|
269
|
+
|
|
202
270
|
/** @type {Map<string, Room>} */
|
|
203
271
|
this._rooms = new Map();
|
|
204
272
|
|
|
@@ -210,6 +278,23 @@ class SignalingHub extends EventEmitter
|
|
|
210
278
|
|
|
211
279
|
/** @type {Map<string, number[]>} ip -> attach timestamps in the rolling window. */
|
|
212
280
|
this._ipAttachLog = new Map();
|
|
281
|
+
|
|
282
|
+
// -- Optional media plane (SFU adapter facade) --
|
|
283
|
+
/** @type {import('./sfu').SfuAdapter|null} */
|
|
284
|
+
this.sfu = null;
|
|
285
|
+
if (opts.sfu)
|
|
286
|
+
{
|
|
287
|
+
this.sfu = (typeof opts.sfu === 'string' || (opts.sfu && typeof opts.sfu === 'object' && typeof opts.sfu.createRouter !== 'function'))
|
|
288
|
+
? loadSfuAdapter(opts.sfu, opts.sfuOpts)
|
|
289
|
+
: loadSfuAdapter(opts.sfu);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Media-plane facade. Always present so application code can write
|
|
294
|
+
* `hub.media.createRouter(...)` regardless of whether an adapter is
|
|
295
|
+
* mounted; calls without an adapter throw `WEBRTC_SFU_NOT_CONFIGURED`.
|
|
296
|
+
*/
|
|
297
|
+
this.media = _buildMediaFacade(this);
|
|
213
298
|
}
|
|
214
299
|
|
|
215
300
|
// -- Public surface --
|
|
@@ -230,6 +315,10 @@ class SignalingHub extends EventEmitter
|
|
|
230
315
|
if (!r)
|
|
231
316
|
{
|
|
232
317
|
r = new Room(name, { hub: this });
|
|
318
|
+
// Apply the hub's default topology preference; `auto` rooms start
|
|
319
|
+
// as mesh and are promoted in _onRoomMembershipChange().
|
|
320
|
+
r.topologyMode = this.defaultTopology;
|
|
321
|
+
r.topology = this.defaultTopology === 'auto' ? 'mesh' : this.defaultTopology;
|
|
233
322
|
this._rooms.set(name, r);
|
|
234
323
|
}
|
|
235
324
|
return r;
|
|
@@ -458,7 +547,12 @@ class SignalingHub extends EventEmitter
|
|
|
458
547
|
}
|
|
459
548
|
|
|
460
549
|
room._add(peer);
|
|
461
|
-
peer.send('joined', {
|
|
550
|
+
peer.send('joined', {
|
|
551
|
+
room: room.name,
|
|
552
|
+
peerId: peer.id,
|
|
553
|
+
peers: room.peers().map(p => p.id),
|
|
554
|
+
topology: room.topology,
|
|
555
|
+
});
|
|
462
556
|
room.broadcast('peer-joined', { id: peer.id }, peer.id);
|
|
463
557
|
this.emit('join', { peer, room });
|
|
464
558
|
}
|
|
@@ -614,6 +708,114 @@ class SignalingHub extends EventEmitter
|
|
|
614
708
|
peer.close(1008, 'too-many-errors');
|
|
615
709
|
}
|
|
616
710
|
}
|
|
711
|
+
|
|
712
|
+
// -- Topology / capacity --
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Internal callback fired by `Room#_add` / `Room#_remove`. Drives
|
|
716
|
+
* auto-promotion (`mesh` → `sfu`) and capacity alarms for fixed-mesh
|
|
717
|
+
* rooms. Pure signaling: media transports do not move; the hub only
|
|
718
|
+
* advertises the new topology to peers so client SDKs can switch.
|
|
719
|
+
* @private
|
|
720
|
+
*/
|
|
721
|
+
_onRoomMembershipChange(room, action, peer)
|
|
722
|
+
{
|
|
723
|
+
const size = room.size;
|
|
724
|
+
if (action === 'add' && size === this.maxMeshPeers + 1)
|
|
725
|
+
{
|
|
726
|
+
// Crossed the mesh limit.
|
|
727
|
+
this.emit('peer:limit:reached', { room, size, limit: this.maxMeshPeers });
|
|
728
|
+
if (room.topologyMode === 'auto' && room.topology === 'mesh')
|
|
729
|
+
{
|
|
730
|
+
room.setTopology('sfu');
|
|
731
|
+
this.emit('topology:promoted', { room, from: 'mesh', to: 'sfu', size });
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
else if (action === 'remove' && size === this.maxMeshPeers
|
|
735
|
+
&& room.topologyMode === 'auto' && room.topology === 'sfu')
|
|
736
|
+
{
|
|
737
|
+
// Demote back to mesh if the room drops to / below the limit.
|
|
738
|
+
room.setTopology('mesh');
|
|
739
|
+
this.emit('topology:demoted', { room, from: 'sfu', to: 'mesh', size });
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Snapshot of hub state for dashboards and health checks. Includes
|
|
745
|
+
* the configured topology defaults, current peer/room counts, and a
|
|
746
|
+
* coarse media-plane summary when an SFU adapter is mounted.
|
|
747
|
+
* @returns {Promise<{topology:string, maxMeshPeers:number, peers:number, rooms:object[], mediaPlane:object|null}>}
|
|
748
|
+
*/
|
|
749
|
+
async stats()
|
|
750
|
+
{
|
|
751
|
+
const rooms = [];
|
|
752
|
+
for (const r of this._rooms.values())
|
|
753
|
+
{
|
|
754
|
+
rooms.push({
|
|
755
|
+
name: r.name,
|
|
756
|
+
size: r.size,
|
|
757
|
+
topology: r.topology,
|
|
758
|
+
topologyMode: r.topologyMode,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
let mediaPlane = null;
|
|
762
|
+
if (this.sfu)
|
|
763
|
+
{
|
|
764
|
+
try { mediaPlane = await this.sfu.stats(); }
|
|
765
|
+
catch (err) { mediaPlane = { error: err && err.message }; }
|
|
766
|
+
}
|
|
767
|
+
return {
|
|
768
|
+
topology: this.defaultTopology,
|
|
769
|
+
maxMeshPeers: this.maxMeshPeers,
|
|
770
|
+
peers: this._peers.size,
|
|
771
|
+
rooms,
|
|
772
|
+
mediaPlane,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// -- Media-plane facade --
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* @private
|
|
781
|
+
* Build the `hub.media` proxy. Every adapter method on `SfuAdapter` is
|
|
782
|
+
* exposed; calls without a mounted adapter throw
|
|
783
|
+
* `WEBRTC_SFU_NOT_CONFIGURED` so misconfiguration is loud.
|
|
784
|
+
*/
|
|
785
|
+
function _buildMediaFacade(hub)
|
|
786
|
+
{
|
|
787
|
+
const proxiedMethods = [
|
|
788
|
+
'createRouter', 'createTransport', 'produce', 'consume',
|
|
789
|
+
'pauseProducer', 'resumeProducer', 'closeRouter', 'stats',
|
|
790
|
+
'setConsumerPreferredLayers', 'setConsumerPriority', 'requestKeyFrame',
|
|
791
|
+
'pauseConsumer', 'resumeConsumer', 'setTransportBitrates',
|
|
792
|
+
'produceData', 'consumeData',
|
|
793
|
+
'observeAudioLevels', 'observeActiveSpeaker', 'pipeToRouter',
|
|
794
|
+
'getProducerStats', 'getConsumerStats', 'getTransportStats',
|
|
795
|
+
'enableTraceEvent',
|
|
796
|
+
];
|
|
797
|
+
const facade = {
|
|
798
|
+
get adapter() { return hub.sfu; },
|
|
799
|
+
get configured() { return !!hub.sfu; },
|
|
800
|
+
onEvent(fn)
|
|
801
|
+
{
|
|
802
|
+
if (!hub.sfu)
|
|
803
|
+
throw new SignalingError('hub.media is not configured; pass `sfu` to SignalingHub or call useSfu()',
|
|
804
|
+
{ code: 'WEBRTC_SFU_NOT_CONFIGURED' });
|
|
805
|
+
return hub.sfu.onEvent(fn);
|
|
806
|
+
},
|
|
807
|
+
};
|
|
808
|
+
for (const name of proxiedMethods)
|
|
809
|
+
{
|
|
810
|
+
facade[name] = async (...args) =>
|
|
811
|
+
{
|
|
812
|
+
if (!hub.sfu)
|
|
813
|
+
throw new SignalingError('hub.media is not configured; pass `sfu` to SignalingHub or call useSfu()',
|
|
814
|
+
{ code: 'WEBRTC_SFU_NOT_CONFIGURED' });
|
|
815
|
+
return hub.sfu[name](...args);
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
return facade;
|
|
617
819
|
}
|
|
618
820
|
|
|
619
821
|
module.exports = { SignalingHub, Room, Peer, PEER_STATE };
|