@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.
@@ -72,6 +72,23 @@ class Room
72
72
 
73
73
  /** @type {boolean} `true` once `.open()` has been called. */
74
74
  this.isOpen = false;
75
+
76
+ /**
77
+ * Currently advertised topology for this room. `mesh` until the
78
+ * hub promotes it (in `auto` mode) or until the application calls
79
+ * `setTopology()`. Broadcast to peers via `room-topology` so client
80
+ * SDKs can switch between p2p / SFU transports without a rejoin.
81
+ * @type {'mesh'|'sfu'|'mcu'}
82
+ */
83
+ this.topology = 'mesh';
84
+
85
+ /**
86
+ * Original topology preference inherited from the hub at room
87
+ * creation time. `auto` means the hub may promote/demote between
88
+ * `mesh` and `sfu` based on `maxMeshPeers`.
89
+ * @type {'mesh'|'sfu'|'mcu'|'auto'}
90
+ */
91
+ this.topologyMode = 'auto';
75
92
  }
76
93
 
77
94
  // -- Configuration (fluent) --
@@ -139,6 +156,8 @@ class Room
139
156
  {
140
157
  this._peers.add(peer);
141
158
  peer.room = this;
159
+ if (this.hub && typeof this.hub._onRoomMembershipChange === 'function')
160
+ this.hub._onRoomMembershipChange(this, 'add', peer);
142
161
  }
143
162
 
144
163
  /** Internal - hub uses this; do not call from application code. */
@@ -147,6 +166,29 @@ class Room
147
166
  if (!this._peers.has(peer)) return;
148
167
  this._peers.delete(peer);
149
168
  if (peer.room === this) peer.room = null;
169
+ if (this.hub && typeof this.hub._onRoomMembershipChange === 'function')
170
+ this.hub._onRoomMembershipChange(this, 'remove', peer);
171
+ }
172
+
173
+ /**
174
+ * Force the room's topology and broadcast the change to every peer.
175
+ * Pure signaling: the hub does not redirect media; client SDKs are
176
+ * responsible for switching transports when the new topology is
177
+ * received via `room-topology`.
178
+ * @param {'mesh'|'sfu'|'mcu'} topology
179
+ */
180
+ setTopology(topology)
181
+ {
182
+ if (topology !== 'mesh' && topology !== 'sfu' && topology !== 'mcu')
183
+ throw new SignalingError('Room.setTopology: topology must be mesh|sfu|mcu');
184
+ if (this.topology === topology) return this;
185
+ const previous = this.topology;
186
+ this.topology = topology;
187
+ for (const p of this._peers)
188
+ p.send('room-topology', { room: this.name, topology, previous });
189
+ if (this.hub)
190
+ this.hub.emit('topology:changed', { room: this, topology, previous });
191
+ return this;
150
192
  }
151
193
 
152
194
  // -- Fan-out --
@@ -116,6 +116,135 @@ class SfuAdapter
116
116
  throw new WebRTCError('SfuAdapter.stats() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
117
117
  }
118
118
 
119
+ // -- Consumer-side BWE / quality knobs --
120
+
121
+ /**
122
+ * Switch a consumer to the given simulcast spatial / temporal layer.
123
+ * @param {string} consumerId
124
+ * @param {{spatialLayer:number, temporalLayer?:number}} layers
125
+ */
126
+ async setConsumerPreferredLayers(_consumerId, _layers)
127
+ {
128
+ throw new WebRTCError('SfuAdapter.setConsumerPreferredLayers() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
129
+ }
130
+
131
+ /**
132
+ * Set consumer priority (1-255, higher = more bandwidth budget under
133
+ * congestion).
134
+ */
135
+ async setConsumerPriority(_consumerId, _priority)
136
+ {
137
+ throw new WebRTCError('SfuAdapter.setConsumerPriority() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
138
+ }
139
+
140
+ /** Ask the SFU to forward a PLI/FIR to the producer the consumer is bound to. */
141
+ async requestKeyFrame(_consumerId)
142
+ {
143
+ throw new WebRTCError('SfuAdapter.requestKeyFrame() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
144
+ }
145
+
146
+ /** Pause an individual consumer (stop forwarding to a single subscriber). */
147
+ async pauseConsumer(_consumerId)
148
+ {
149
+ throw new WebRTCError('SfuAdapter.pauseConsumer() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
150
+ }
151
+
152
+ /** Resume a previously paused consumer. */
153
+ async resumeConsumer(_consumerId)
154
+ {
155
+ throw new WebRTCError('SfuAdapter.resumeConsumer() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
156
+ }
157
+
158
+ // -- Transport-level BWE caps --
159
+
160
+ /**
161
+ * Apply incoming/outgoing bitrate hints to a transport.
162
+ * @param {string} transportId
163
+ * @param {{initial?:number, min?:number, max?:number, maxIncoming?:number, maxOutgoing?:number}} opts
164
+ */
165
+ async setTransportBitrates(_transportId, _opts)
166
+ {
167
+ throw new WebRTCError('SfuAdapter.setTransportBitrates() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
168
+ }
169
+
170
+ // -- Data channels --
171
+
172
+ /** Create an SCTP data producer on a transport. */
173
+ async produceData(_transport, _opts)
174
+ {
175
+ throw new WebRTCError('SfuAdapter.produceData() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
176
+ }
177
+
178
+ /** Create an SCTP data consumer bound to `dataProducerId`. */
179
+ async consumeData(_transport, _dataProducerId, _opts)
180
+ {
181
+ throw new WebRTCError('SfuAdapter.consumeData() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
182
+ }
183
+
184
+ // -- Observers --
185
+
186
+ /**
187
+ * Start an audio-level observer on a router. Emits `audio-level` adapter
188
+ * events. Returns `{ id, close() }`.
189
+ * @param {string} routerId
190
+ * @param {{interval?:number, threshold?:number, maxEntries?:number}} [opts]
191
+ */
192
+ async observeAudioLevels(_routerId, _opts)
193
+ {
194
+ throw new WebRTCError('SfuAdapter.observeAudioLevels() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
195
+ }
196
+
197
+ /**
198
+ * Start an active-speaker observer. Emits `active-speaker` adapter
199
+ * events. Returns `{ id, close() }`.
200
+ */
201
+ async observeActiveSpeaker(_routerId, _opts)
202
+ {
203
+ throw new WebRTCError('SfuAdapter.observeActiveSpeaker() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
204
+ }
205
+
206
+ // -- Cross-router cascade (SFU mesh) --
207
+
208
+ /**
209
+ * Pipe a producer from this adapter's local router into another router
210
+ * (possibly on a remote SFU node). Returns
211
+ * `{ pipeProducerId, pipeConsumerId, localRouterId, remoteRouterId }`.
212
+ * @param {{producerId:string, localRouterId:string, remoteRouter:object}} opts
213
+ */
214
+ async pipeToRouter(_opts)
215
+ {
216
+ throw new WebRTCError('SfuAdapter.pipeToRouter() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
217
+ }
218
+
219
+ // -- Targeted stats (Phase 2 surface; coarse stats() remains for back-compat) --
220
+
221
+ /** Return native stats for a single producer. */
222
+ async getProducerStats(_producerId)
223
+ {
224
+ throw new WebRTCError('SfuAdapter.getProducerStats() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
225
+ }
226
+
227
+ /** Return native stats for a single consumer. */
228
+ async getConsumerStats(_consumerId)
229
+ {
230
+ throw new WebRTCError('SfuAdapter.getConsumerStats() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
231
+ }
232
+
233
+ /** Return native stats for a single transport. */
234
+ async getTransportStats(_transportId)
235
+ {
236
+ throw new WebRTCError('SfuAdapter.getTransportStats() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
237
+ }
238
+
239
+ /**
240
+ * Toggle low-level trace event emission on a router (mediasoup `trace`
241
+ * events: 'probation', 'bwe', 'rtp', 'keyframe', etc.).
242
+ */
243
+ async enableTraceEvent(_routerId, _types)
244
+ {
245
+ throw new WebRTCError('SfuAdapter.enableTraceEvent() not implemented', { code: 'WEBRTC_SFU_NOT_IMPLEMENTED' });
246
+ }
247
+
119
248
  /**
120
249
  * Register a handler invoked as `(event, payload)` for adapter-level
121
250
  * events ('producer-new', 'producer-pause', 'consumer-new',
@@ -283,6 +283,310 @@ class LiveKitSfuAdapter extends SfuAdapter
283
283
  rooms,
284
284
  };
285
285
  }
286
+
287
+ // ----- Room / participant REST passthroughs -----
288
+
289
+ /**
290
+ * Fetch one room from LiveKit by name. Returns the matching entry
291
+ * from `client.listRooms([name])` or `null`.
292
+ */
293
+ async getRoomInfo(routerId)
294
+ {
295
+ if (typeof this._client.listRooms !== 'function') return null;
296
+ try
297
+ {
298
+ const list = await this._client.listRooms([routerId]);
299
+ if (Array.isArray(list) && list.length) return list[0];
300
+ return null;
301
+ }
302
+ catch (_) { return null; }
303
+ }
304
+
305
+ async listParticipants(routerId)
306
+ {
307
+ if (typeof this._client.listParticipants !== 'function') return [];
308
+ return this._client.listParticipants(routerId);
309
+ }
310
+
311
+ async removeParticipant(routerId, identity)
312
+ {
313
+ if (typeof this._client.removeParticipant !== 'function')
314
+ {
315
+ throw new WebRTCError('removeParticipant: LiveKit client missing removeParticipant()', { code: 'WEBRTC_SFU_NOT_SUPPORTED' });
316
+ }
317
+ await this._client.removeParticipant(routerId, identity);
318
+ this._emit('peer-removed', { routerId, identity });
319
+ }
320
+
321
+ async updateRoomMetadata(routerId, metadata)
322
+ {
323
+ if (typeof this._client.updateRoomMetadata !== 'function')
324
+ {
325
+ throw new WebRTCError('updateRoomMetadata: LiveKit client missing updateRoomMetadata()', { code: 'WEBRTC_SFU_NOT_SUPPORTED' });
326
+ }
327
+ await this._client.updateRoomMetadata(routerId, typeof metadata === 'string' ? metadata : JSON.stringify(metadata));
328
+ this._emit('router-metadata', { routerId, metadata });
329
+ }
330
+
331
+ async sendData(routerId, payload, opts)
332
+ {
333
+ if (typeof this._client.sendData !== 'function')
334
+ {
335
+ throw new WebRTCError('sendData: LiveKit client missing sendData()', { code: 'WEBRTC_SFU_NOT_SUPPORTED' });
336
+ }
337
+ const o = opts || {};
338
+ const data = Buffer.isBuffer(payload) ? payload
339
+ : (payload instanceof Uint8Array ? Buffer.from(payload)
340
+ : Buffer.from(typeof payload === 'string' ? payload : JSON.stringify(payload)));
341
+ await this._client.sendData(routerId, data, o.kind || 0, o.destinationIdentities || o.destinations || undefined);
342
+ }
343
+
344
+ // ----- Egress (recording / RTMP / HLS) -----
345
+
346
+ _egressClient()
347
+ {
348
+ if (this._egress) return this._egress;
349
+ if (!this._livekit.EgressClient)
350
+ {
351
+ try { this._livekit = { ...this._livekit, ...require('livekit-server-sdk') }; }
352
+ catch (_) { /* fall through */ }
353
+ }
354
+ const C = this._livekit.EgressClient;
355
+ if (!C)
356
+ {
357
+ throw new WebRTCError(
358
+ "egress requires 'livekit-server-sdk' with EgressClient support",
359
+ { code: 'WEBRTC_SFU_NOT_INSTALLED' },
360
+ );
361
+ }
362
+ this._egress = new C(this._url, this._apiKey, this._apiSecret);
363
+ return this._egress;
364
+ }
365
+
366
+ async startRoomCompositeEgress(routerId, opts)
367
+ {
368
+ const e = this._egressClient();
369
+ const o = opts || {};
370
+ const res = await e.startRoomCompositeEgress(routerId, o.output || o, o.options);
371
+ this._emit('egress-start', { kind: 'room-composite', routerId, egressId: res && res.egressId });
372
+ return res;
373
+ }
374
+
375
+ async startTrackEgress(routerId, trackId, opts)
376
+ {
377
+ const e = this._egressClient();
378
+ const o = opts || {};
379
+ const res = await e.startTrackEgress(routerId, o.output || o, trackId);
380
+ this._emit('egress-start', { kind: 'track', routerId, trackId, egressId: res && res.egressId });
381
+ return res;
382
+ }
383
+
384
+ async stopEgress(egressId)
385
+ {
386
+ const e = this._egressClient();
387
+ const res = await e.stopEgress(egressId);
388
+ this._emit('egress-stop', { egressId });
389
+ return res;
390
+ }
391
+
392
+ async listEgress(opts)
393
+ {
394
+ const e = this._egressClient();
395
+ if (typeof e.listEgress !== 'function') return [];
396
+ return e.listEgress(opts || {});
397
+ }
398
+
399
+ // ----- Ingress (WHIP / RTMP / URL pull) -----
400
+
401
+ _ingressClient()
402
+ {
403
+ if (this._ingress) return this._ingress;
404
+ const C = this._livekit.IngressClient;
405
+ if (!C)
406
+ {
407
+ throw new WebRTCError(
408
+ "ingress requires 'livekit-server-sdk' with IngressClient support",
409
+ { code: 'WEBRTC_SFU_NOT_INSTALLED' },
410
+ );
411
+ }
412
+ this._ingress = new C(this._url, this._apiKey, this._apiSecret);
413
+ return this._ingress;
414
+ }
415
+
416
+ async createIngress(opts)
417
+ {
418
+ const i = this._ingressClient();
419
+ const o = opts || {};
420
+ const inputType = o.inputType || o.type || 'RTMP_INPUT';
421
+ const res = await i.createIngress(inputType, o);
422
+ this._emit('ingress-start', { ingressId: res && res.ingressId, inputType, room: o.roomName });
423
+ return res;
424
+ }
425
+
426
+ async deleteIngress(ingressId)
427
+ {
428
+ const i = this._ingressClient();
429
+ const res = await i.deleteIngress(ingressId);
430
+ this._emit('ingress-stop', { ingressId });
431
+ return res;
432
+ }
433
+
434
+ // ----- SfuAdapter Phase-2 surface (LiveKit owns media, so most are
435
+ // client-side and surface as cooperative no-ops or REST mute hints) -----
436
+
437
+ async setConsumerPreferredLayers(consumerId, _layers)
438
+ {
439
+ if (!this._consumers.has(consumerId))
440
+ throw new WebRTCError('setConsumerPreferredLayers: unknown consumer', { code: 'WEBRTC_SFU_NO_CONSUMER' });
441
+ // LiveKit drives layer selection client-side (UpdateSubscription
442
+ // / SetSubscriptionPermissions); no server REST call is required.
443
+ this._emit('consumer-layers-change', { consumerId, layers: _layers || null });
444
+ }
445
+
446
+ async setConsumerPriority(consumerId, priority)
447
+ {
448
+ if (!this._consumers.has(consumerId))
449
+ throw new WebRTCError('setConsumerPriority: unknown consumer', { code: 'WEBRTC_SFU_NO_CONSUMER' });
450
+ this._emit('consumer-priority', { consumerId, priority });
451
+ }
452
+
453
+ async requestKeyFrame(consumerId)
454
+ {
455
+ if (!this._consumers.has(consumerId))
456
+ throw new WebRTCError('requestKeyFrame: unknown consumer', { code: 'WEBRTC_SFU_NO_CONSUMER' });
457
+ // LiveKit clients PLI through their own RTCP; no-op here.
458
+ }
459
+
460
+ async pauseConsumer(consumerId)
461
+ {
462
+ const c = this._consumers.get(consumerId);
463
+ if (!c)
464
+ throw new WebRTCError('pauseConsumer: unknown consumer', { code: 'WEBRTC_SFU_NO_CONSUMER' });
465
+ c.paused = true;
466
+ this._emit('consumer-pause', { consumerId });
467
+ }
468
+
469
+ async resumeConsumer(consumerId)
470
+ {
471
+ const c = this._consumers.get(consumerId);
472
+ if (!c)
473
+ throw new WebRTCError('resumeConsumer: unknown consumer', { code: 'WEBRTC_SFU_NO_CONSUMER' });
474
+ c.paused = false;
475
+ this._emit('consumer-resume', { consumerId });
476
+ }
477
+
478
+ async setTransportBitrates(transportId, bitrates)
479
+ {
480
+ if (!this._transports.has(transportId))
481
+ throw new WebRTCError('setTransportBitrates: unknown transport', { code: 'WEBRTC_SFU_NO_TRANSPORT' });
482
+ // LiveKit BWE is end-to-end inside its own client; we record the
483
+ // request so callers can read it back and emit so observers can
484
+ // count clamps.
485
+ const t = this._transports.get(transportId);
486
+ t.bitrates = bitrates || {};
487
+ this._emit('transport-bitrates', { transportId, bitrates: t.bitrates });
488
+ }
489
+
490
+ async produceData(transport, opts)
491
+ {
492
+ if (!transport || !this._transports.has(transport.id))
493
+ throw new WebRTCError('produceData: unknown transport', { code: 'WEBRTC_SFU_NO_TRANSPORT' });
494
+ const id = this._nextId('dataProducer');
495
+ const o = opts || {};
496
+ const dp = {
497
+ id, dataProducerId: id, transportId: transport.id,
498
+ label: o.label || '', protocol: o.protocol || '',
499
+ ordered: o.ordered !== false,
500
+ };
501
+ this._dataProducers = this._dataProducers || new Map();
502
+ this._dataProducers.set(id, dp);
503
+ this._emit('data-producer-new', { dataProducerId: id, transportId: transport.id, label: dp.label });
504
+ return dp;
505
+ }
506
+
507
+ async consumeData(transport, dataProducerId, opts)
508
+ {
509
+ if (!transport || !this._transports.has(transport.id))
510
+ throw new WebRTCError('consumeData: unknown transport', { code: 'WEBRTC_SFU_NO_TRANSPORT' });
511
+ const id = this._nextId('dataConsumer');
512
+ const o = opts || {};
513
+ const dc = {
514
+ id, dataConsumerId: id, transportId: transport.id, dataProducerId,
515
+ label: '', protocol: '', ordered: o.ordered !== false,
516
+ };
517
+ this._dataConsumers = this._dataConsumers || new Map();
518
+ this._dataConsumers.set(id, dc);
519
+ this._emit('data-consumer-new', { dataConsumerId: id, transportId: transport.id, dataProducerId });
520
+ return dc;
521
+ }
522
+
523
+ async observeAudioLevels(routerId, _opts)
524
+ {
525
+ if (!this._rooms.has(routerId))
526
+ throw new WebRTCError('observeAudioLevels: unknown router', { code: 'WEBRTC_SFU_NO_ROUTER' });
527
+ // LiveKit emits "active_speakers_changed" via the room webhook /
528
+ // server-events channel; expose a tiny handle that pushes those
529
+ // through our adapter `_emit` once the caller wires them.
530
+ const id = this._nextId('audioObserver');
531
+ const self = this;
532
+ const handle = {
533
+ id, kind: 'audio-level', routerId, closed: false,
534
+ close: async () => { handle.closed = true; self._emit('observer-close', { observerId: id, kind: 'audio-level' }); },
535
+ emit: (levels) => { if (!handle.closed) self._emit('audio-level', { observerId: id, routerId, levels }); },
536
+ };
537
+ this._emit('observer-new', { observerId: id, kind: 'audio-level', routerId });
538
+ return handle;
539
+ }
540
+
541
+ async observeActiveSpeaker(routerId, _opts)
542
+ {
543
+ if (!this._rooms.has(routerId))
544
+ throw new WebRTCError('observeActiveSpeaker: unknown router', { code: 'WEBRTC_SFU_NO_ROUTER' });
545
+ const id = this._nextId('speakerObserver');
546
+ const self = this;
547
+ const handle = {
548
+ id, kind: 'active-speaker', routerId, closed: false,
549
+ close: async () => { handle.closed = true; self._emit('observer-close', { observerId: id, kind: 'active-speaker' }); },
550
+ emit: (producerId) => { if (!handle.closed) self._emit('active-speaker', { observerId: id, routerId, producerId }); },
551
+ };
552
+ this._emit('observer-new', { observerId: id, kind: 'active-speaker', routerId });
553
+ return handle;
554
+ }
555
+
556
+ async pipeToRouter(_opts)
557
+ {
558
+ // LiveKit handles cross-node fanout itself across its cluster;
559
+ // application-level pipe is not part of the public REST surface.
560
+ throw new WebRTCError('pipeToRouter: not supported by LiveKit provider', { code: 'WEBRTC_SFU_NOT_SUPPORTED' });
561
+ }
562
+
563
+ async getProducerStats(producerId)
564
+ {
565
+ if (!this._producers.has(producerId))
566
+ throw new WebRTCError('getProducerStats: unknown producer', { code: 'WEBRTC_SFU_NO_PRODUCER' });
567
+ return [];
568
+ }
569
+
570
+ async getConsumerStats(consumerId)
571
+ {
572
+ if (!this._consumers.has(consumerId))
573
+ throw new WebRTCError('getConsumerStats: unknown consumer', { code: 'WEBRTC_SFU_NO_CONSUMER' });
574
+ return [];
575
+ }
576
+
577
+ async getTransportStats(transportId)
578
+ {
579
+ if (!this._transports.has(transportId))
580
+ throw new WebRTCError('getTransportStats: unknown transport', { code: 'WEBRTC_SFU_NO_TRANSPORT' });
581
+ return [];
582
+ }
583
+
584
+ async enableTraceEvent(routerId, types)
585
+ {
586
+ if (!this._rooms.has(routerId))
587
+ throw new WebRTCError('enableTraceEvent: unknown router', { code: 'WEBRTC_SFU_NO_ROUTER' });
588
+ this._emit('trace-enabled', { routerId, types: Array.isArray(types) ? types : [] });
589
+ }
286
590
  }
287
591
 
288
592
  /**