@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.
@@ -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 };
@@ -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', { 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
+ });
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.9.10",
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": {