@zero-server/sdk 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.
@@ -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
- return { kind: 'router', routerId: scope, transports: r.transports.size };
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 };
@@ -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 !== 'group') continue;
109
- const parts = String(a.value || '').split(/\s+/);
110
- if (parts[0] !== 'BUNDLE') continue;
111
- for (let i = 1; i < parts.length; i++) bundleMids.add(parts[i]);
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
- if (!m.fingerprint) return 'INVALID_SDP';
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 && (!m.iceUfrag || !m.icePwd)) return 'INVALID_SDP';
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', { room: room.name, peerId: peer.id, peers: room.peers().map(p => p.id) });
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zero-server/sdk",
3
- "version": "0.9.9",
3
+ "version": "1.0.0",
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": {