@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 +1 -1
- package/index.js +8 -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 +7 -7
- package/types/webrtc.d.ts +348 -0
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 **
|
|
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
|
+
};
|