@zero-server/sdk 0.9.10 → 1.0.1
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 +65 -3
- package/index.js +19 -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 +175 -1
- package/package.json +1 -1
- 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
|
@@ -50,6 +50,7 @@ const { parseCandidate } = require('./ice');
|
|
|
50
50
|
const { Peer, PEER_STATE } = require('./peer');
|
|
51
51
|
const { Room } = require('./room');
|
|
52
52
|
const { verifyJoinToken } = require('./joinToken');
|
|
53
|
+
const { loadSfuAdapter } = require('./sfu');
|
|
53
54
|
|
|
54
55
|
// --- Constants ---
|
|
55
56
|
|
|
@@ -68,6 +69,17 @@ const DEFAULT_MAX_PROTOCOL_ERRORS = 5;
|
|
|
68
69
|
/** Default rolling window (sec) for the per-IP attach rate limit. */
|
|
69
70
|
const IP_ATTACH_WINDOW_SEC = 60;
|
|
70
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
|
+
|
|
71
83
|
/** Set of message `type`s the hub will dispatch. */
|
|
72
84
|
const VALID_TYPES = new Set([
|
|
73
85
|
'join', 'leave', 'offer', 'answer', 'ice',
|
|
@@ -196,6 +208,19 @@ class SignalingHub extends EventEmitter
|
|
|
196
208
|
* @param {string|Buffer} [opts.joinTokenSecret] - If set, every `join` must include a valid
|
|
197
209
|
* JWT signed with this secret and audience `room:<name>`.
|
|
198
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.
|
|
199
224
|
*/
|
|
200
225
|
constructor(opts = {})
|
|
201
226
|
{
|
|
@@ -227,6 +252,21 @@ class SignalingHub extends EventEmitter
|
|
|
227
252
|
/** @type {boolean} */
|
|
228
253
|
this.autoCreateRooms = opts.autoCreateRooms !== false;
|
|
229
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
|
+
|
|
230
270
|
/** @type {Map<string, Room>} */
|
|
231
271
|
this._rooms = new Map();
|
|
232
272
|
|
|
@@ -238,6 +278,23 @@ class SignalingHub extends EventEmitter
|
|
|
238
278
|
|
|
239
279
|
/** @type {Map<string, number[]>} ip -> attach timestamps in the rolling window. */
|
|
240
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);
|
|
241
298
|
}
|
|
242
299
|
|
|
243
300
|
// -- Public surface --
|
|
@@ -258,6 +315,10 @@ class SignalingHub extends EventEmitter
|
|
|
258
315
|
if (!r)
|
|
259
316
|
{
|
|
260
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;
|
|
261
322
|
this._rooms.set(name, r);
|
|
262
323
|
}
|
|
263
324
|
return r;
|
|
@@ -486,7 +547,12 @@ class SignalingHub extends EventEmitter
|
|
|
486
547
|
}
|
|
487
548
|
|
|
488
549
|
room._add(peer);
|
|
489
|
-
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
|
+
});
|
|
490
556
|
room.broadcast('peer-joined', { id: peer.id }, peer.id);
|
|
491
557
|
this.emit('join', { peer, room });
|
|
492
558
|
}
|
|
@@ -642,6 +708,114 @@ class SignalingHub extends EventEmitter
|
|
|
642
708
|
peer.close(1008, 'too-many-errors');
|
|
643
709
|
}
|
|
644
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;
|
|
645
819
|
}
|
|
646
820
|
|
|
647
821
|
module.exports = { SignalingHub, Room, Peer, PEER_STATE };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zero-server/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Zero-dependency backend framework for Node.js - routing, ORM, auth, WebSocket, SSE, gRPC, observability, and 20+ middleware. Distributed as a single SDK and as scoped @zero-server/* packages.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|