@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.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @module webrtc/mcu
3
+ * @description Optional MCU (multipoint control unit) layer that sits on
4
+ * top of an {@link SfuAdapter}. Where an SFU forwards each publisher's
5
+ * stream untouched, an MCU mixes them down to a smaller number of
6
+ * composite tracks (one audio mix + one tiled video) so receivers only
7
+ * have to decode O(1) streams instead of O(N). The right topology for
8
+ * SIP gateways, low-end clients, regulated recording, and ultra-large
9
+ * rooms where consumer decode cost is the bottleneck.
10
+ *
11
+ * zero-server ships no native mixer — JS-side Opus/H264 transcoding is
12
+ * CPU-prohibitive. The MCU layer is a contract: adapters can spawn
13
+ * ffmpeg (the included {@link FfmpegMcuAdapter}), shell out to a
14
+ * GPU-backed mixer, or call into a managed service. The
15
+ * {@link MemoryMcuAdapter} is bookkeeping only for tests and capacity
16
+ * planning.
17
+ *
18
+ * @example | Bookkeeping-only mixer (tests, capacity planning)
19
+ * const mcu = new MemoryMcuAdapter({ sfu });
20
+ * const { mixedProducerId } = await mcu.mix('lobby', {
21
+ * producerIds: ['p1', 'p2', 'p3'],
22
+ * kind: 'audio',
23
+ * });
24
+ *
25
+ * @example | Real audio mixing via ffmpeg
26
+ * const mcu = new FfmpegMcuAdapter({ sfu });
27
+ * const mix = await mcu.mix('lobby', {
28
+ * producerIds: ['p1', 'p2'],
29
+ * inputs: [{ sdp: '/tmp/in1.sdp' }, { sdp: '/tmp/in2.sdp' }],
30
+ * outputs: [{ url: 'rtp://127.0.0.1:5004' }],
31
+ * kind: 'audio',
32
+ * });
33
+ */
34
+
35
+ 'use strict';
36
+
37
+ const { WebRTCError } = require('../../errors');
38
+
39
+ /** @typedef {'grid'|'presenter'|'presenter-strip'|'dominant'|'pip'} McuLayout */
40
+
41
+ // --- McuAdapter base ---
42
+
43
+ /**
44
+ * Abstract MCU base class. Adapter authors override `mix` / `unmix` /
45
+ * `setLayout` to back the composite with a real mixer. All methods default
46
+ * to throwing `WEBRTC_MCU_NOT_IMPLEMENTED` so missing capability surfaces
47
+ * immediately rather than silently no-oping.
48
+ *
49
+ * @class
50
+ * @section MCU
51
+ */
52
+ class McuAdapter
53
+ {
54
+ /**
55
+ * @constructor
56
+ * @param {object} [opts]
57
+ * @param {object} [opts.sfu] - Bound SfuAdapter, used by subclasses that need to ingest/produce.
58
+ * @param {string} [opts.name] - Adapter name, defaults to the constructor name.
59
+ */
60
+ constructor(opts)
61
+ {
62
+ const o = opts || {};
63
+ this.sfu = o.sfu || null;
64
+ this.name = o.name || this.constructor.name;
65
+ }
66
+
67
+ // -- Mixing --
68
+
69
+ /**
70
+ * Start a new composite.
71
+ *
72
+ * @param {string} _roomId
73
+ * @param {object} _opts - `{ producerIds, kind:'audio'|'video'|'av', layout?, output? }`
74
+ * @returns {Promise<{mixedProducerId:string, kind:string, layout:McuLayout, sources:string[]}>}
75
+ * @section Mixing
76
+ */
77
+ // eslint-disable-next-line no-unused-vars
78
+ async mix(_roomId, _opts) { throw new WebRTCError(`${this.name}.mix not implemented`, { code: 'WEBRTC_MCU_NOT_IMPLEMENTED' }); }
79
+
80
+ /**
81
+ * Tear down a composite.
82
+ * @param {string} _mixedProducerId
83
+ * @section Mixing
84
+ */
85
+ // eslint-disable-next-line no-unused-vars
86
+ async unmix(_mixedProducerId) { throw new WebRTCError(`${this.name}.unmix not implemented`, { code: 'WEBRTC_MCU_NOT_IMPLEMENTED' }); }
87
+
88
+ /**
89
+ * Change video layout on the fly.
90
+ * @param {string} _mixedProducerId
91
+ * @param {McuLayout|object} _layout
92
+ * @section Mixing
93
+ */
94
+ // eslint-disable-next-line no-unused-vars
95
+ async setLayout(_mixedProducerId, _layout) { throw new WebRTCError(`${this.name}.setLayout not implemented`, { code: 'WEBRTC_MCU_NOT_IMPLEMENTED' }); }
96
+
97
+ /**
98
+ * Add a producer to an existing mix.
99
+ * @param {string} _mixedProducerId
100
+ * @param {string} _producerId
101
+ * @section Mixing
102
+ */
103
+ // eslint-disable-next-line no-unused-vars
104
+ async addSource(_mixedProducerId, _producerId) { throw new WebRTCError(`${this.name}.addSource not implemented`, { code: 'WEBRTC_MCU_NOT_IMPLEMENTED' }); }
105
+
106
+ /**
107
+ * Remove a producer from a mix.
108
+ * @param {string} _mixedProducerId
109
+ * @param {string} _producerId
110
+ * @section Mixing
111
+ */
112
+ // eslint-disable-next-line no-unused-vars
113
+ async removeSource(_mixedProducerId, _producerId) { throw new WebRTCError(`${this.name}.removeSource not implemented`, { code: 'WEBRTC_MCU_NOT_IMPLEMENTED' }); }
114
+
115
+ // -- Inspection --
116
+
117
+ /**
118
+ * Snapshot of active mixes.
119
+ * @returns {{mixes: Array<{id:string, sources:string[], layout:McuLayout, kind:string}>}}
120
+ * @section Inspection
121
+ */
122
+ stats() { return { mixes: [] }; }
123
+
124
+ /**
125
+ * Tear down every active mix.
126
+ * @section Lifecycle
127
+ */
128
+ async close() { /* override in subclasses */ }
129
+ }
130
+
131
+ // --- MemoryMcuAdapter ---
132
+
133
+ /**
134
+ * Bookkeeping-only MCU. Doesn't touch media — useful for tests and for
135
+ * apps that want to model the MCU topology cost without paying for real
136
+ * mixing. Produces synthetic mixed-producer ids of the form
137
+ * `mcu:<roomId>:<n>`.
138
+ *
139
+ * @class
140
+ * @section MCU
141
+ */
142
+ class MemoryMcuAdapter extends McuAdapter
143
+ {
144
+ constructor(opts)
145
+ {
146
+ super(opts);
147
+ this._next = 0;
148
+ /** @type {Map<string, {id:string, room:string, kind:string, layout:McuLayout, sources:Set<string>, output:object|null}>} */
149
+ this._mixes = new Map();
150
+ }
151
+
152
+ _id(roomId) { return `mcu:${roomId}:${++this._next}`; }
153
+
154
+ async mix(roomId, opts)
155
+ {
156
+ const o = opts || {};
157
+ if (!roomId) throw new WebRTCError('mix: roomId required', { code: 'WEBRTC_MCU_BAD_ARGS' });
158
+ const kind = o.kind || 'audio';
159
+ const layout = o.layout || (kind === 'audio' ? 'audio-only' : 'grid');
160
+ const sources = new Set(Array.isArray(o.producerIds) ? o.producerIds : []);
161
+ const id = this._id(roomId);
162
+ this._mixes.set(id, { id, room: roomId, kind, layout, sources, output: o.output || null });
163
+ return { mixedProducerId: id, kind, layout, sources: [...sources] };
164
+ }
165
+
166
+ async unmix(mixedProducerId)
167
+ {
168
+ const m = this._mixes.get(mixedProducerId);
169
+ if (!m) return false;
170
+ this._mixes.delete(mixedProducerId);
171
+ return true;
172
+ }
173
+
174
+ async setLayout(mixedProducerId, layout)
175
+ {
176
+ const m = this._mixes.get(mixedProducerId);
177
+ if (!m) throw new WebRTCError('setLayout: unknown mix', { code: 'WEBRTC_MCU_NO_MIX' });
178
+ m.layout = typeof layout === 'string' ? layout : (layout && layout.name) || m.layout;
179
+ return m.layout;
180
+ }
181
+
182
+ async addSource(mixedProducerId, producerId)
183
+ {
184
+ const m = this._mixes.get(mixedProducerId);
185
+ if (!m) throw new WebRTCError('addSource: unknown mix', { code: 'WEBRTC_MCU_NO_MIX' });
186
+ m.sources.add(producerId);
187
+ return m.sources.size;
188
+ }
189
+
190
+ async removeSource(mixedProducerId, producerId)
191
+ {
192
+ const m = this._mixes.get(mixedProducerId);
193
+ if (!m) throw new WebRTCError('removeSource: unknown mix', { code: 'WEBRTC_MCU_NO_MIX' });
194
+ m.sources.delete(producerId);
195
+ return m.sources.size;
196
+ }
197
+
198
+ stats()
199
+ {
200
+ const mixes = [];
201
+ for (const m of this._mixes.values())
202
+ {
203
+ mixes.push({ id: m.id, room: m.room, sources: [...m.sources], layout: m.layout, kind: m.kind });
204
+ }
205
+ return { mixes };
206
+ }
207
+
208
+ async close() { this._mixes.clear(); }
209
+ }
210
+
211
+ module.exports = { McuAdapter, MemoryMcuAdapter };
@@ -52,6 +52,21 @@ function _registerMetrics(registry)
52
52
  help: 'Detected ICE restarts (ufrag rotation) per room.',
53
53
  labels: ['room'],
54
54
  }),
55
+ peersPerMeshRoom: registry.gauge({
56
+ name: 'zs_webrtc_peers_per_mesh_room',
57
+ help: 'Current peer count for rooms still in full-mesh topology.',
58
+ labels: ['room'],
59
+ }),
60
+ meshOverflow: registry.counter({
61
+ name: 'zs_webrtc_mesh_overflow_total',
62
+ help: 'Rooms that exceeded `maxMeshPeers` while still in mesh topology (signals N² uplink risk).',
63
+ labels: ['room'],
64
+ }),
65
+ topologyPromotions: registry.counter({
66
+ name: 'zs_webrtc_topology_promotions_total',
67
+ help: 'Auto-promotions from one topology to another (e.g. mesh→sfu).',
68
+ labels: ['room', 'from', 'to'],
69
+ }),
55
70
  };
56
71
  }
57
72
 
@@ -73,6 +88,8 @@ function _bindMetrics(hub, m)
73
88
  m.peersActive.inc({ room: room.name });
74
89
  // Room newly non-empty?
75
90
  if (before === 0) m.roomsActive.inc();
91
+ if (room.topology === 'mesh')
92
+ m.peersPerMeshRoom.set({ room: room.name }, room.size);
76
93
  });
77
94
 
78
95
  hub.on('leave', ({ peer, room }) =>
@@ -80,6 +97,27 @@ function _bindMetrics(hub, m)
80
97
  m.peersActive.dec({ room: room.name });
81
98
  if (m.peersActive.get({ room: room.name }) <= 0)
82
99
  m.roomsActive.dec();
100
+ if (room.topology === 'mesh')
101
+ m.peersPerMeshRoom.set({ room: room.name }, room.size);
102
+ });
103
+
104
+ hub.on('peer:limit:reached', ({ room }) =>
105
+ {
106
+ if (room.topology === 'mesh')
107
+ m.meshOverflow.inc({ room: room.name });
108
+ });
109
+
110
+ hub.on('topology:promoted', ({ room, from, to }) =>
111
+ {
112
+ m.topologyPromotions.inc({ room: room.name, from, to });
113
+ // Promotion drains the mesh gauge.
114
+ m.peersPerMeshRoom.set({ room: room.name }, 0);
115
+ });
116
+
117
+ hub.on('topology:demoted', ({ room, from, to }) =>
118
+ {
119
+ m.topologyPromotions.inc({ room: room.name, from, to });
120
+ m.peersPerMeshRoom.set({ room: room.name }, room.size);
83
121
  });
84
122
 
85
123
  hub.on('joinFailed', ({ reason }) =>
@@ -0,0 +1,410 @@
1
+ /**
2
+ * @module webrtc/recording
3
+ * @description Adapter-agnostic recording / egress / ingress facade.
4
+ * {@link RecordingManager} auto-detects which pipeline the bound
5
+ * {@link SfuAdapter} supports (`livekit` egress, `ffmpeg` child, or
6
+ * `memory` bookkeeping) and exposes a uniform `startRecording` /
7
+ * `stopRecording` surface. {@link IngressManager} wraps the adapter's
8
+ * `createIngress` / `deleteIngress` calls for WHIP / RTMP / SIP /
9
+ * URL-pull sources.
10
+ *
11
+ * @example | Auto-pipeline recording on top of any adapter
12
+ * const rec = new RecordingManager({ adapter: hub.sfu });
13
+ * const { id, stop } = await rec.startRecording('lobby', {
14
+ * layout: 'grid',
15
+ * format: 'mp4',
16
+ * sink: { file: '/var/recordings/lobby.mp4' },
17
+ * });
18
+ * // later
19
+ * await stop();
20
+ *
21
+ * @example | Force the ffmpeg pipeline
22
+ * const rec = new RecordingManager({ adapter: hub.sfu, ffmpegPath: '/usr/bin/ffmpeg' });
23
+ * await rec.startRecording('lobby', {
24
+ * pipeline: 'ffmpeg',
25
+ * inputs: [{ sdp: '/tmp/in.sdp' }],
26
+ * sink: { file: '/tmp/out.mp4' },
27
+ * });
28
+ *
29
+ * @example | Create a WHIP ingress and route it into a room
30
+ * const ing = new IngressManager({ adapter: hub.sfu });
31
+ * const stream = await ing.createIngress({ kind: 'whip', name: 'studio', roomName: 'lobby' });
32
+ * // publish using stream.native.url
33
+ */
34
+
35
+ 'use strict';
36
+
37
+ const { WebRTCError } = require('../errors');
38
+
39
+ /** @typedef {'mp4'|'webm'|'mka'|'ogg'|'hls'} RecordingFormat */
40
+ /** @typedef {'grid'|'presenter'|'presenter-strip'|'dominant'|'speaker'|'audio-only'} RecordingLayout */
41
+
42
+ // --- RecordingManager ---
43
+
44
+ /**
45
+ * Adapter-agnostic recording facade. Pipeline auto-resolves from
46
+ * `adapter.startRoomCompositeEgress` (LiveKit) → `livekit`; fall back is
47
+ * `memory` (bookkeeping only). Pass `pipeline: 'ffmpeg'` to spawn ffmpeg
48
+ * children.
49
+ *
50
+ * @class
51
+ * @section Recording
52
+ */
53
+ class RecordingManager
54
+ {
55
+ /**
56
+ * @constructor
57
+ * @param {object} opts
58
+ * @param {object} opts.adapter - SfuAdapter (or anything exposing egress methods).
59
+ * @param {Function} [opts.spawn] - For `pipeline: 'ffmpeg'` (defaults to `child_process.spawn`).
60
+ * @param {string} [opts.ffmpegPath]
61
+ */
62
+ constructor(opts)
63
+ {
64
+ const o = opts || {};
65
+ if (!o.adapter) throw new WebRTCError('RecordingManager requires { adapter }', { code: 'WEBRTC_RECORDING_NO_ADAPTER' });
66
+ this.adapter = o.adapter;
67
+ this._spawn = o.spawn || null;
68
+ this._ffmpegPath = o.ffmpegPath || null;
69
+ this._next = 0;
70
+ /** @type {Map<string, {id:string, roomName:string, kind:string, backend:string, native:object|null, child:object|null, opts:object, status:string, startedAt:number, stoppedAt:number|null}>} */
71
+ this._records = new Map();
72
+ }
73
+
74
+ _id() { return `rec-${++this._next}-${Date.now().toString(36)}`; }
75
+
76
+ /**
77
+ * Start a new recording for `roomName`. The `pipeline` field selects
78
+ * the backend:
79
+ *
80
+ * - `'livekit'` — call `adapter.startRoomCompositeEgress`.
81
+ * - `'livekit-track'` — call `adapter.startTrackEgress`.
82
+ * - `'ffmpeg'` — spawn an ffmpeg child process (requires
83
+ * `{ ffmpegPath }` or `ffmpeg-static`; caller wires RTP).
84
+ * - `'memory'` — bookkeeping only (default when no other backend
85
+ * can be inferred).
86
+ *
87
+ * @param {string} roomName
88
+ * @param {object} [opts]
89
+ * @returns {Promise<{id:string, status:string, stop:Function, info():object}>}
90
+ * @section Recording
91
+ */
92
+ async startRecording(roomName, opts)
93
+ {
94
+ if (!roomName) throw new WebRTCError('startRecording: roomName required', { code: 'WEBRTC_RECORDING_BAD_ARGS' });
95
+ const o = opts || {};
96
+ const pipeline = o.pipeline || this._inferPipeline();
97
+ const id = this._id();
98
+ const entry = {
99
+ id, roomName,
100
+ kind: o.kind || 'composite',
101
+ backend: pipeline,
102
+ native: null,
103
+ child: null,
104
+ opts: o,
105
+ status: 'starting',
106
+ startedAt: Date.now(),
107
+ stoppedAt: null,
108
+ };
109
+ this._records.set(id, entry);
110
+
111
+ try
112
+ {
113
+ if (pipeline === 'livekit')
114
+ {
115
+ if (typeof this.adapter.startRoomCompositeEgress !== 'function')
116
+ throw new WebRTCError('adapter does not implement startRoomCompositeEgress', { code: 'WEBRTC_RECORDING_NO_BACKEND' });
117
+ entry.native = await this.adapter.startRoomCompositeEgress(roomName, o);
118
+ }
119
+ else if (pipeline === 'livekit-track')
120
+ {
121
+ if (typeof this.adapter.startTrackEgress !== 'function')
122
+ throw new WebRTCError('adapter does not implement startTrackEgress', { code: 'WEBRTC_RECORDING_NO_BACKEND' });
123
+ entry.native = await this.adapter.startTrackEgress(roomName, o);
124
+ }
125
+ else if (pipeline === 'ffmpeg')
126
+ {
127
+ entry.child = this._spawnFfmpeg(o);
128
+ }
129
+ else if (pipeline === 'memory')
130
+ {
131
+ /* nothing to do — caller models the cost */
132
+ }
133
+ else
134
+ {
135
+ throw new WebRTCError(`unknown recording pipeline: ${pipeline}`, { code: 'WEBRTC_RECORDING_BAD_PIPELINE' });
136
+ }
137
+ entry.status = 'recording';
138
+ }
139
+ catch (err)
140
+ {
141
+ entry.status = 'failed';
142
+ entry.error = err && err.message;
143
+ entry.stoppedAt = Date.now();
144
+ throw err;
145
+ }
146
+
147
+ return {
148
+ id,
149
+ status: entry.status,
150
+ stop: () => this.stopRecording(id),
151
+ info: () => this._snapshot(entry),
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Stop an active recording by id. Idempotent.
157
+ *
158
+ * @param {string} id
159
+ * @returns {Promise<boolean>} true if the recording was stopped here.
160
+ * @section Recording
161
+ */
162
+ async stopRecording(id)
163
+ {
164
+ const entry = this._records.get(id);
165
+ if (!entry) return false;
166
+ if (entry.status === 'stopped' || entry.status === 'failed') return false;
167
+ entry.status = 'stopping';
168
+ try
169
+ {
170
+ if (entry.backend === 'livekit' || entry.backend === 'livekit-track')
171
+ {
172
+ const egressId = entry.native && (entry.native.egressId || entry.native.egress_id || entry.native.id);
173
+ if (egressId && typeof this.adapter.stopEgress === 'function')
174
+ await this.adapter.stopEgress(egressId);
175
+ }
176
+ else if (entry.backend === 'ffmpeg')
177
+ {
178
+ if (entry.child && typeof entry.child.kill === 'function')
179
+ {
180
+ try { entry.child.kill('SIGTERM'); } catch { /* swallow */ }
181
+ }
182
+ }
183
+ entry.status = 'stopped';
184
+ entry.stoppedAt = Date.now();
185
+ return true;
186
+ }
187
+ catch (err)
188
+ {
189
+ entry.status = 'failed';
190
+ entry.error = err && err.message;
191
+ entry.stoppedAt = Date.now();
192
+ throw err;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * List every recording the manager knows about.
198
+ * @returns {Array<object>}
199
+ * @section Inspection
200
+ */
201
+ list()
202
+ {
203
+ const out = [];
204
+ for (const entry of this._records.values()) out.push(this._snapshot(entry));
205
+ return out;
206
+ }
207
+
208
+ /**
209
+ * Aggregate counts for observability.
210
+ * @returns {{recording:number, stopped:number, failed:number, total:number}}
211
+ * @section Inspection
212
+ */
213
+ stats()
214
+ {
215
+ let recording = 0, stopped = 0, failed = 0;
216
+ for (const e of this._records.values())
217
+ {
218
+ if (e.status === 'recording' || e.status === 'starting' || e.status === 'stopping') recording++;
219
+ else if (e.status === 'stopped') stopped++;
220
+ else if (e.status === 'failed') failed++;
221
+ }
222
+ return { recording, stopped, failed, total: this._records.size };
223
+ }
224
+
225
+ /**
226
+ * Stop every active recording. Safe to call during shutdown.
227
+ * @section Lifecycle
228
+ */
229
+ async close()
230
+ {
231
+ const active = [...this._records.values()].filter((e) => e.status === 'recording' || e.status === 'starting');
232
+ for (const e of active)
233
+ {
234
+ try { await this.stopRecording(e.id); } catch { /* swallow */ }
235
+ }
236
+ }
237
+
238
+ /** @private */
239
+ _snapshot(e)
240
+ {
241
+ return {
242
+ id: e.id,
243
+ roomName: e.roomName,
244
+ kind: e.kind,
245
+ backend: e.backend,
246
+ status: e.status,
247
+ startedAt: e.startedAt,
248
+ stoppedAt: e.stoppedAt,
249
+ pid: e.child && e.child.pid,
250
+ native: e.native ? { id: e.native.egressId || e.native.id || null } : null,
251
+ error: e.error || null,
252
+ };
253
+ }
254
+
255
+ /** @private */
256
+ _inferPipeline()
257
+ {
258
+ if (typeof this.adapter.startRoomCompositeEgress === 'function') return 'livekit';
259
+ return 'memory';
260
+ }
261
+
262
+ /** @private */
263
+ _spawnFfmpeg(opts)
264
+ {
265
+ const spawn = this._spawn || require('child_process').spawn;
266
+ let path = this._ffmpegPath;
267
+ if (!path)
268
+ {
269
+ try { path = require('ffmpeg-static'); }
270
+ catch
271
+ {
272
+ throw new WebRTCError(
273
+ 'ffmpeg pipeline requires `ffmpeg-static` (npm i ffmpeg-static) or { ffmpegPath }',
274
+ { code: 'WEBRTC_RECORDING_NO_FFMPEG' },
275
+ );
276
+ }
277
+ }
278
+ const args = Array.isArray(opts.args) ? opts.args : this._buildFfmpegArgs(opts);
279
+ return spawn(path, args, { stdio: ['ignore', 'pipe', 'pipe'] });
280
+ }
281
+
282
+ /** @private */
283
+ _buildFfmpegArgs(opts)
284
+ {
285
+ const o = opts || {};
286
+ const inputs = Array.isArray(o.inputs) ? o.inputs : [];
287
+ const sink = o.sink || {};
288
+ const format = o.format || (sink.file && sink.file.endsWith('.webm') ? 'webm' : 'mp4');
289
+ const args = ['-y', '-loglevel', 'warning'];
290
+ for (const inp of inputs)
291
+ {
292
+ if (inp.sdp) args.push('-protocol_whitelist', 'pipe,udp,rtp', '-f', 'sdp', '-i', inp.sdp);
293
+ else if (inp.url) args.push('-i', inp.url);
294
+ }
295
+ if (sink.file) args.push('-f', format, sink.file);
296
+ else if (sink.url) args.push('-f', sink.format || 'rtp', sink.url);
297
+ return args;
298
+ }
299
+ }
300
+
301
+ // --- IngressManager ---
302
+
303
+ /**
304
+ * Tracks ingresses created via the adapter (WHIP / RTMP / URL pull / SIP)
305
+ * so callers don't need their own bookkeeping. Delegates the actual
306
+ * lifecycle to `adapter.createIngress` / `adapter.deleteIngress`.
307
+ *
308
+ * @class
309
+ * @section Recording
310
+ */
311
+ class IngressManager
312
+ {
313
+ /**
314
+ * @constructor
315
+ * @param {object} opts
316
+ * @param {object} opts.adapter
317
+ */
318
+ constructor(opts)
319
+ {
320
+ const o = opts || {};
321
+ if (!o.adapter) throw new WebRTCError('IngressManager requires { adapter }', { code: 'WEBRTC_INGRESS_NO_ADAPTER' });
322
+ this.adapter = o.adapter;
323
+ this._next = 0;
324
+ /** @type {Map<string, {id:string, kind:string, roomName:string|null, native:object, opts:object, createdAt:number}>} */
325
+ this._ingresses = new Map();
326
+ }
327
+
328
+ _id() { return `ing-${++this._next}-${Date.now().toString(36)}`; }
329
+
330
+ /**
331
+ * Create an ingress (WHIP / RTMP / URL pull / SIP).
332
+ * @param {object} opts - `{ kind, roomName, name, ... }`
333
+ * @section Recording
334
+ */
335
+ async createIngress(opts)
336
+ {
337
+ const o = opts || {};
338
+ if (typeof this.adapter.createIngress !== 'function')
339
+ throw new WebRTCError('adapter does not implement createIngress', { code: 'WEBRTC_INGRESS_NO_BACKEND' });
340
+ const native = await this.adapter.createIngress(o);
341
+ const id = this._id();
342
+ const entry = {
343
+ id,
344
+ kind: o.kind || o.inputType || 'rtmp',
345
+ roomName: o.roomName || o.room || null,
346
+ native,
347
+ opts: o,
348
+ createdAt: Date.now(),
349
+ };
350
+ this._ingresses.set(id, entry);
351
+ return { id, native, info: () => this._snapshot(entry) };
352
+ }
353
+
354
+ /**
355
+ * Tear down an ingress by id. Idempotent.
356
+ * @param {string} id
357
+ * @returns {Promise<boolean>}
358
+ * @section Recording
359
+ */
360
+ async deleteIngress(id)
361
+ {
362
+ const entry = this._ingresses.get(id);
363
+ if (!entry) return false;
364
+ this._ingresses.delete(id);
365
+ if (typeof this.adapter.deleteIngress === 'function')
366
+ {
367
+ const ingressId = entry.native && (entry.native.ingressId || entry.native.id);
368
+ if (ingressId) await this.adapter.deleteIngress(ingressId);
369
+ }
370
+ return true;
371
+ }
372
+
373
+ /**
374
+ * List every known ingress.
375
+ * @returns {Array<object>}
376
+ * @section Inspection
377
+ */
378
+ list()
379
+ {
380
+ const out = [];
381
+ for (const entry of this._ingresses.values()) out.push(this._snapshot(entry));
382
+ return out;
383
+ }
384
+
385
+ /**
386
+ * Tear down every active ingress. Safe to call during shutdown.
387
+ * @section Lifecycle
388
+ */
389
+ async close()
390
+ {
391
+ for (const id of [...this._ingresses.keys()])
392
+ {
393
+ try { await this.deleteIngress(id); } catch { /* swallow */ }
394
+ }
395
+ }
396
+
397
+ /** @private */
398
+ _snapshot(e)
399
+ {
400
+ return {
401
+ id: e.id,
402
+ kind: e.kind,
403
+ roomName: e.roomName,
404
+ createdAt: e.createdAt,
405
+ native: e.native ? { id: e.native.ingressId || e.native.id || null, url: e.native.url || e.native.streamKey || null } : null,
406
+ };
407
+ }
408
+ }
409
+
410
+ module.exports = { RecordingManager, IngressManager };