@zero-server/webrtc 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 CHANGED
@@ -24,7 +24,7 @@ const { createWebRTC } = require('@zero-server/webrtc')
24
24
 
25
25
  ## Public surface
26
26
 
27
- This package provides **47** public exports as a standalone runtime bundle. See the [scope page](https://github.com/tonywied17/zero-server/blob/main/docs/scopes/webrtc.md#public-surface) for the full list.
27
+ This package provides **55** public exports as a standalone runtime bundle. See the [scope page](https://github.com/tonywied17/zero-server/blob/main/docs/scopes/webrtc.md#public-surface) for the full list.
28
28
 
29
29
  ## Documentation
30
30
 
package/index.js CHANGED
@@ -44,6 +44,14 @@ module.exports = {
44
44
  useCluster: lib.useCluster,
45
45
  ClusterCoordinator: lib.ClusterCoordinator,
46
46
  MemoryClusterAdapter: lib.MemoryClusterAdapter,
47
+ useCascade: lib.useCascade,
48
+ CascadeCoordinator: lib.CascadeCoordinator,
49
+ CH_CASCADE: lib.CH_CASCADE,
50
+ McuAdapter: lib.McuAdapter,
51
+ MemoryMcuAdapter: lib.MemoryMcuAdapter,
52
+ FfmpegMcuAdapter: lib.FfmpegMcuAdapter,
53
+ RecordingManager: lib.RecordingManager,
54
+ IngressManager: lib.IngressManager,
47
55
  runWebRTCCommand: lib.runWebRTCCommand,
48
56
  WebRTCError: lib.WebRTCError,
49
57
  SignalingError: lib.SignalingError,
@@ -0,0 +1,577 @@
1
+ /**
2
+ * @module webrtc/cascade
3
+ * @description Cross-node SFU cascade orchestrator. Sits on top of a
4
+ * {@link useCluster}-backed {@link SignalingHub} and a local
5
+ * {@link SfuAdapter} so a single room can span multiple hub nodes' media
6
+ * planes. Each node owns a local Router (a "bridge") for its subset of
7
+ * peers; producers are piped to every peer bridge so consumers on any
8
+ * node see every publisher.
9
+ *
10
+ * See [`docs/scopes/webrtc-scaling.md`](../../docs/scopes/webrtc-scaling.md)
11
+ * for the wire protocol, capacity model, and migration matrix.
12
+ *
13
+ * @example | Two nodes, one virtual room
14
+ * const a = new SignalingHub({ sfu: new MemorySfuAdapter() });
15
+ * const b = new SignalingHub({ sfu: new MemorySfuAdapter() });
16
+ * const bus = new MemoryClusterAdapter();
17
+ * useCluster(a, bus, { nodeId: 'a' });
18
+ * useCluster(b, bus, { nodeId: 'b' });
19
+ * useCascade(a, { nodeId: 'a' });
20
+ * useCascade(b, { nodeId: 'b' });
21
+ *
22
+ * @example | Register a bridge as rooms get created
23
+ * const cascade = useCascade(hub);
24
+ * hub.on('room-created', async ({ name }) => {
25
+ * const router = await hub.sfu.createRouter();
26
+ * cascade.registerLocalBridge(name, router);
27
+ * });
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ const { WebRTCError } = require('../errors');
33
+
34
+ // --- Constants ---
35
+
36
+ const CH_CASCADE = 'zs:rtc:cascade';
37
+
38
+ // --- CascadeCoordinator ---
39
+
40
+ /**
41
+ * Per-hub cross-node SFU cascade coordinator. Created by {@link useCascade}
42
+ * and parked on `hub._cascade`. Owns the local bridge registry, mirrors
43
+ * remote bridges learned over the cluster bus, and opens `pipeToRouter`
44
+ * handles for every remote producer so local consumers can subscribe.
45
+ *
46
+ * @class
47
+ * @section Cluster
48
+ */
49
+ class CascadeCoordinator
50
+ {
51
+ /**
52
+ * @param {import('./signaling').SignalingHub} hub
53
+ * @param {object} [opts]
54
+ * @param {string} [opts.nodeId] - Falls back to the cluster nodeId or a random id.
55
+ * @param {object} [opts.sfu] - SfuAdapter; defaults to `hub.sfu` (the one the hub was built with).
56
+ * @param {object} [opts.listenInfo] - PipeTransport listen info advertised to peer bridges.
57
+ * @param {boolean} [opts.enableSrtp=true]
58
+ */
59
+ constructor(hub, opts)
60
+ {
61
+ const o = opts || {};
62
+ if (!hub || !hub._cluster)
63
+ {
64
+ throw new WebRTCError(
65
+ 'useCascade requires a SignalingHub that has been wired to a cluster via useCluster()',
66
+ { code: 'WEBRTC_CASCADE_NO_CLUSTER' },
67
+ );
68
+ }
69
+ this.hub = hub;
70
+ this.sfu = o.sfu || hub.sfu;
71
+ if (!this.sfu)
72
+ {
73
+ throw new WebRTCError(
74
+ 'useCascade requires an SfuAdapter (pass opts.sfu or build the hub with { sfu })',
75
+ { code: 'WEBRTC_CASCADE_NO_SFU' },
76
+ );
77
+ }
78
+ this.nodeId = o.nodeId || hub._cluster.nodeId;
79
+ this.listenInfo = o.listenInfo || { protocol: 'udp', ip: '0.0.0.0' };
80
+ this.enableSrtp = o.enableSrtp !== false;
81
+ this._closed = false;
82
+
83
+ /**
84
+ * Local bridges, keyed by room name. Each entry caches the local
85
+ * router we created for that room plus the set of producers that
86
+ * have been announced over the bus.
87
+ * @type {Map<string, {routerId:string, router:object, producers:Map<string, object>, remoteBridges:Map<string, {nodeId:string, routerId:string, listenInfo:object}>, pipes:Map<string, object>}>}
88
+ */
89
+ this._bridges = new Map();
90
+
91
+ /**
92
+ * Remote producer directory, keyed by producerId.
93
+ * `{ producerId, room, nodeId, routerId, kind, rtpParameters, localConsumeCount }`
94
+ * @type {Map<string, object>}
95
+ */
96
+ this._remoteProducers = new Map();
97
+
98
+ this._wireBus();
99
+ this._wireSfu();
100
+
101
+ // Announce ourselves so peer nodes replay their bridge state.
102
+ this._publish({ kind: 'hello', nodeId: this.nodeId });
103
+ }
104
+
105
+ /** @private */
106
+ _wireBus()
107
+ {
108
+ const off = this.hub._cluster.adapter.subscribe(CH_CASCADE, (m) => this._onBus(m));
109
+ this._unsub = typeof off === 'function' ? off : null;
110
+ }
111
+
112
+ /** @private */
113
+ _wireSfu()
114
+ {
115
+ this._sfuOff = this.sfu.onEvent((event, payload) =>
116
+ {
117
+ if (this._closed) return;
118
+ if (event === 'producer-new')
119
+ this._onLocalProducerNew(payload);
120
+ else if (event === 'producer-close')
121
+ this._onLocalProducerClose(payload);
122
+ });
123
+ }
124
+
125
+ /** @private */
126
+ _publish(msg)
127
+ {
128
+ try
129
+ {
130
+ const res = this.hub._cluster.adapter.publish(CH_CASCADE, msg);
131
+ if (res && typeof res.catch === 'function')
132
+ res.catch((err) => this.hub.emit('cascadeError', err));
133
+ }
134
+ catch (err) { this.hub.emit('cascadeError', err); }
135
+ }
136
+
137
+ /**
138
+ * Register the local bridge for `room`. Idempotent: subsequent calls
139
+ * return the cached entry. `router` is the handle returned by
140
+ * `sfu.createRouter()`; producers created on that router will be
141
+ * fanned out to every peer bridge.
142
+ *
143
+ * @param {string} roomName
144
+ * @param {object} router
145
+ * @returns {object} bridge state
146
+ * @section Bridges
147
+ */
148
+ registerLocalBridge(roomName, router)
149
+ {
150
+ if (this._closed) throw new WebRTCError('CascadeCoordinator is closed', { code: 'WEBRTC_CASCADE_CLOSED' });
151
+ if (!roomName) throw new WebRTCError('registerLocalBridge: roomName required', { code: 'WEBRTC_CASCADE_BAD_ARGS' });
152
+ if (!router || !(router.id || router.routerId))
153
+ throw new WebRTCError('registerLocalBridge: router with .id required', { code: 'WEBRTC_CASCADE_BAD_ARGS' });
154
+ const routerId = router.id || router.routerId;
155
+ const existing = this._bridges.get(roomName);
156
+ if (existing && existing.routerId === routerId) return existing;
157
+ const entry = {
158
+ routerId,
159
+ router,
160
+ producers: new Map(),
161
+ remoteBridges: new Map(),
162
+ pipes: new Map(),
163
+ };
164
+ this._bridges.set(roomName, entry);
165
+ this._publish({
166
+ kind: 'bridge:open',
167
+ room: roomName,
168
+ nodeId: this.nodeId,
169
+ routerId,
170
+ listenInfo: this.listenInfo,
171
+ });
172
+ this.hub.emit('cascade:bridge-open', { room: roomName, nodeId: this.nodeId, routerId });
173
+ return entry;
174
+ }
175
+
176
+ /**
177
+ * Tear down the local bridge for `room`. Closes every pipe opened to
178
+ * peer bridges and announces `bridge:close` so peer nodes drop their
179
+ * mirrored state.
180
+ *
181
+ * @param {string} roomName
182
+ * @section Bridges
183
+ */
184
+ closeLocalBridge(roomName)
185
+ {
186
+ const entry = this._bridges.get(roomName);
187
+ if (!entry) return;
188
+ this._bridges.delete(roomName);
189
+ this._publish({ kind: 'bridge:close', room: roomName, nodeId: this.nodeId });
190
+ this.hub.emit('cascade:bridge-close', { room: roomName, nodeId: this.nodeId });
191
+ }
192
+
193
+ /**
194
+ * Announce that a local producer has been created on the bridge for
195
+ * `room` so peer bridges open a `pipeToRouter` consuming it. Called
196
+ * automatically when the SFU emits `producer-new` and the producer's
197
+ * transport belongs to a known bridge router.
198
+ *
199
+ * @param {string} roomName
200
+ * @param {object} producer - `{ id|producerId, kind, rtpParameters }`
201
+ * @section Producers
202
+ */
203
+ announceProducer(roomName, producer)
204
+ {
205
+ const entry = this._bridges.get(roomName);
206
+ if (!entry) return;
207
+ const producerId = producer.id || producer.producerId;
208
+ if (!producerId || entry.producers.has(producerId)) return;
209
+ entry.producers.set(producerId, producer);
210
+ this._publish({
211
+ kind: 'producer:new',
212
+ room: roomName,
213
+ nodeId: this.nodeId,
214
+ routerId: entry.routerId,
215
+ producerId,
216
+ kind_: producer.kind,
217
+ rtpParameters: producer.rtpParameters || null,
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Tear down fanout for a local producer.
223
+ *
224
+ * @param {string} roomName
225
+ * @param {string} producerId
226
+ * @section Producers
227
+ */
228
+ retractProducer(roomName, producerId)
229
+ {
230
+ const entry = this._bridges.get(roomName);
231
+ if (!entry || !entry.producers.delete(producerId)) return;
232
+ this._publish({
233
+ kind: 'producer:close',
234
+ room: roomName,
235
+ nodeId: this.nodeId,
236
+ producerId,
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Resolve a producer id to its remote origin, if any. Returns null
242
+ * when the producer is local or unknown.
243
+ *
244
+ * @param {string} producerId
245
+ * @returns {{producerId:string, room:string, nodeId:string, routerId:string, kind:string}|null}
246
+ * @section Producers
247
+ */
248
+ locateRemoteProducer(producerId)
249
+ {
250
+ return this._remoteProducers.get(producerId) || null;
251
+ }
252
+
253
+ /**
254
+ * Snapshot of the cascade state for observability.
255
+ *
256
+ * @returns {{nodeId:string, bridges:Array<object>, remoteProducers:number}}
257
+ * @section Inspection
258
+ */
259
+ stats()
260
+ {
261
+ const bridges = [];
262
+ for (const [room, entry] of this._bridges)
263
+ {
264
+ bridges.push({
265
+ room,
266
+ routerId: entry.routerId,
267
+ localProducers: entry.producers.size,
268
+ peers: entry.remoteBridges.size,
269
+ pipes: entry.pipes.size,
270
+ });
271
+ }
272
+ return {
273
+ nodeId: this.nodeId,
274
+ bridges,
275
+ remoteProducers: this._remoteProducers.size,
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Tear down every bridge and stop processing bus messages.
281
+ *
282
+ * @section Lifecycle
283
+ */
284
+ close()
285
+ {
286
+ if (this._closed) return;
287
+ this._closed = true;
288
+ for (const room of [...this._bridges.keys()])
289
+ {
290
+ try { this.closeLocalBridge(room); } catch { /* swallow */ }
291
+ }
292
+ if (typeof this._unsub === 'function') { try { this._unsub(); } catch { /* swallow */ } }
293
+ if (typeof this._sfuOff === 'function') { try { this._sfuOff(); } catch { /* swallow */ } }
294
+ this._unsub = null;
295
+ this._sfuOff = null;
296
+ this._remoteProducers.clear();
297
+ if (this.hub._cascade === this) this.hub._cascade = null;
298
+ }
299
+
300
+ // ----- SFU event handlers -----
301
+
302
+ /** @private */
303
+ _onLocalProducerNew(payload)
304
+ {
305
+ if (!payload || !payload.producerId) return;
306
+ // We need to know which room this producer's transport/router belongs to.
307
+ // Walk the registered bridges looking for the producer's routerId.
308
+ for (const [room, entry] of this._bridges)
309
+ {
310
+ if (this._routerOwnsProducer(entry, payload.producerId))
311
+ {
312
+ this.announceProducer(room, {
313
+ id: payload.producerId,
314
+ kind: payload.kind,
315
+ rtpParameters: payload.rtpParameters || null,
316
+ });
317
+ return;
318
+ }
319
+ // Fallback: producer-new on this bridge's transport (memory/mediasoup expose transportId on the event)
320
+ if (payload.transportId && this._isTransportOnRouter(entry.routerId, payload.transportId))
321
+ {
322
+ this.announceProducer(room, {
323
+ id: payload.producerId,
324
+ kind: payload.kind,
325
+ rtpParameters: payload.rtpParameters || null,
326
+ });
327
+ return;
328
+ }
329
+ }
330
+ }
331
+
332
+ /** @private */
333
+ _onLocalProducerClose(payload)
334
+ {
335
+ if (!payload || !payload.producerId) return;
336
+ for (const [room, entry] of this._bridges)
337
+ {
338
+ if (entry.producers.has(payload.producerId))
339
+ {
340
+ this.retractProducer(room, payload.producerId);
341
+ return;
342
+ }
343
+ }
344
+ }
345
+
346
+ /** @private */
347
+ _routerOwnsProducer(entry, producerId)
348
+ {
349
+ // memory + mediasoup adapters both keep a `_producers` map keyed by id.
350
+ const p = this.sfu._producers && this.sfu._producers.get && this.sfu._producers.get(producerId);
351
+ if (!p) return false;
352
+ // memory adapter stores routerId on the producer record; mediasoup uses transportId.
353
+ if (p.routerId && p.routerId === entry.routerId) return true;
354
+ if (p.transportId) return this._isTransportOnRouter(entry.routerId, p.transportId);
355
+ return false;
356
+ }
357
+
358
+ /** @private */
359
+ _isTransportOnRouter(routerId, transportId)
360
+ {
361
+ // mediasoup adapter exposes `_routerOf:Map<transportId, routerId>`; memory adapter
362
+ // stores routerId on the transport entry of `_transports`.
363
+ if (this.sfu._routerOf && this.sfu._routerOf.get)
364
+ return this.sfu._routerOf.get(transportId) === routerId;
365
+ const t = this.sfu._transports && this.sfu._transports.get && this.sfu._transports.get(transportId);
366
+ return !!(t && (t.routerId === routerId || (t.router && t.router.id === routerId)));
367
+ }
368
+
369
+ // ----- Bus handlers -----
370
+
371
+ /** @private */
372
+ _onBus(msg)
373
+ {
374
+ if (!msg || this._closed) return;
375
+ if (msg.nodeId === this.nodeId) return;
376
+ switch (msg.kind)
377
+ {
378
+ case 'hello': return this._onHello(msg);
379
+ case 'bridge:open': return this._onBridgeOpen(msg);
380
+ case 'bridge:accept': return this._onBridgeAccept(msg);
381
+ case 'bridge:close': return this._onBridgeClose(msg);
382
+ case 'producer:new': return this._onRemoteProducerNew(msg);
383
+ case 'producer:close': return this._onRemoteProducerClose(msg);
384
+ default: /* unknown — ignore */
385
+ }
386
+ }
387
+
388
+ /** @private */
389
+ _onHello(_msg)
390
+ {
391
+ // Replay every bridge we own so the newcomer learns about us.
392
+ for (const [room, entry] of this._bridges)
393
+ {
394
+ this._publish({
395
+ kind: 'bridge:open',
396
+ room,
397
+ nodeId: this.nodeId,
398
+ routerId: entry.routerId,
399
+ listenInfo: this.listenInfo,
400
+ });
401
+ for (const [producerId, prod] of entry.producers)
402
+ {
403
+ this._publish({
404
+ kind: 'producer:new',
405
+ room,
406
+ nodeId: this.nodeId,
407
+ routerId: entry.routerId,
408
+ producerId,
409
+ kind_: prod.kind,
410
+ rtpParameters: prod.rtpParameters || null,
411
+ });
412
+ }
413
+ }
414
+ }
415
+
416
+ /** @private */
417
+ _onBridgeOpen(msg)
418
+ {
419
+ const local = this._bridges.get(msg.room);
420
+ if (!local) return; // we don't host this room
421
+ local.remoteBridges.set(msg.nodeId, {
422
+ nodeId: msg.nodeId,
423
+ routerId: msg.routerId,
424
+ listenInfo: msg.listenInfo,
425
+ });
426
+ this._publish({
427
+ kind: 'bridge:accept',
428
+ room: msg.room,
429
+ nodeId: this.nodeId,
430
+ routerId: local.routerId,
431
+ listenInfo: this.listenInfo,
432
+ replyTo: msg.nodeId,
433
+ });
434
+ // Replay our local producers for this room so the peer's directory
435
+ // catches up immediately rather than waiting for the next produce.
436
+ for (const [producerId, prod] of local.producers)
437
+ {
438
+ this._publish({
439
+ kind: 'producer:new',
440
+ room: msg.room,
441
+ nodeId: this.nodeId,
442
+ routerId: local.routerId,
443
+ producerId,
444
+ kind_: prod.kind,
445
+ rtpParameters: prod.rtpParameters || null,
446
+ });
447
+ }
448
+ this.hub.emit('cascade:peer-bridge', { room: msg.room, nodeId: msg.nodeId, routerId: msg.routerId });
449
+ }
450
+
451
+ /** @private */
452
+ _onBridgeAccept(msg)
453
+ {
454
+ if (msg.replyTo && msg.replyTo !== this.nodeId) return;
455
+ const local = this._bridges.get(msg.room);
456
+ if (!local) return;
457
+ local.remoteBridges.set(msg.nodeId, {
458
+ nodeId: msg.nodeId,
459
+ routerId: msg.routerId,
460
+ listenInfo: msg.listenInfo,
461
+ });
462
+ this.hub.emit('cascade:peer-bridge', { room: msg.room, nodeId: msg.nodeId, routerId: msg.routerId });
463
+ }
464
+
465
+ /** @private */
466
+ _onBridgeClose(msg)
467
+ {
468
+ const local = this._bridges.get(msg.room);
469
+ if (!local) return;
470
+ local.remoteBridges.delete(msg.nodeId);
471
+ // Drop every pipe that was opened against this remote node.
472
+ for (const [pipeKey, handle] of local.pipes)
473
+ {
474
+ if (handle._remoteNodeId === msg.nodeId)
475
+ local.pipes.delete(pipeKey);
476
+ }
477
+ // Forget remote producers that originated on the dead node.
478
+ for (const [pid, rec] of this._remoteProducers)
479
+ {
480
+ if (rec.nodeId === msg.nodeId && rec.room === msg.room)
481
+ this._remoteProducers.delete(pid);
482
+ }
483
+ this.hub.emit('cascade:peer-bridge-close', { room: msg.room, nodeId: msg.nodeId });
484
+ }
485
+
486
+ /** @private */
487
+ async _onRemoteProducerNew(msg)
488
+ {
489
+ const local = this._bridges.get(msg.room);
490
+ if (!local) return;
491
+ const remoteBridge = local.remoteBridges.get(msg.nodeId);
492
+ if (!remoteBridge) return;
493
+ if (this._remoteProducers.has(msg.producerId)) return;
494
+
495
+ const record = {
496
+ producerId: msg.producerId,
497
+ room: msg.room,
498
+ nodeId: msg.nodeId,
499
+ routerId: msg.routerId,
500
+ kind: msg.kind_,
501
+ rtpParameters: msg.rtpParameters || null,
502
+ };
503
+ this._remoteProducers.set(msg.producerId, record);
504
+ this.hub.emit('cascade:producer-available', {
505
+ room: msg.room,
506
+ producerId: msg.producerId,
507
+ fromNode: msg.nodeId,
508
+ kind: msg.kind_,
509
+ });
510
+
511
+ // Best-effort pipeToRouter. In real adapters this is the place
512
+ // where the underlying SFU opens its PipeTransport handshake.
513
+ // For adapters that can't pipe a remote producer (e.g. the
514
+ // memory adapter — it validates the producer exists locally),
515
+ // the directory entry still records the remote producer so apps
516
+ // can react to availability and the next pipe attempt (e.g.
517
+ // when both bridges live on the same host) will succeed.
518
+ try
519
+ {
520
+ const handle = await this.sfu.pipeToRouter({
521
+ producerId: msg.producerId,
522
+ localRouterId: local.routerId,
523
+ remoteRouter: { id: msg.routerId },
524
+ listenInfo: remoteBridge.listenInfo,
525
+ enableSrtp: this.enableSrtp,
526
+ });
527
+ handle._remoteNodeId = msg.nodeId;
528
+ local.pipes.set(msg.producerId, handle);
529
+ this.hub.emit('cascade:producer-piped', { room: msg.room, producerId: msg.producerId, fromNode: msg.nodeId });
530
+ }
531
+ catch (err)
532
+ {
533
+ this.hub.emit('cascade:producer-pipe-failed', {
534
+ room: msg.room,
535
+ producerId: msg.producerId,
536
+ fromNode: msg.nodeId,
537
+ error: err && err.message,
538
+ code: err && err.code,
539
+ });
540
+ }
541
+ }
542
+
543
+ /** @private */
544
+ _onRemoteProducerClose(msg)
545
+ {
546
+ const local = this._bridges.get(msg.room);
547
+ this._remoteProducers.delete(msg.producerId);
548
+ if (!local) return;
549
+ const handle = local.pipes.get(msg.producerId);
550
+ if (handle) local.pipes.delete(msg.producerId);
551
+ this.hub.emit('cascade:producer-piped-close', { room: msg.room, producerId: msg.producerId });
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Attach a {@link CascadeCoordinator} to a hub that already has a cluster
557
+ * adapter bound via {@link useCluster}. Stores it at `hub._cascade` and
558
+ * returns the coordinator so callers can `registerLocalBridge(room, router)`
559
+ * as rooms get created.
560
+ *
561
+ * @param {import('./signaling').SignalingHub} hub
562
+ * @param {object} [opts]
563
+ * @returns {CascadeCoordinator}
564
+ * @section Cluster
565
+ */
566
+ function useCascade(hub, opts)
567
+ {
568
+ const coord = new CascadeCoordinator(hub, opts);
569
+ hub._cascade = coord;
570
+ return coord;
571
+ }
572
+
573
+ module.exports = {
574
+ useCascade,
575
+ CascadeCoordinator,
576
+ CH_CASCADE,
577
+ };