@zero-server/sdk 0.9.7 → 0.9.9
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 +11 -10
- package/lib/cli.js +25 -25
- package/lib/orm/snapshot.js +1 -1
- package/lib/webrtc/bot.js +56 -12
- package/lib/webrtc/cli.js +6 -6
- package/lib/webrtc/cluster.js +5 -17
- package/lib/webrtc/e2ee.js +5 -13
- package/lib/webrtc/ice.js +3 -10
- package/lib/webrtc/index.js +99 -19
- package/lib/webrtc/joinToken.js +62 -7
- package/lib/webrtc/observe.js +38 -7
- package/lib/webrtc/peer.js +35 -8
- package/lib/webrtc/room.js +20 -7
- package/lib/webrtc/sdp.js +4 -9
- package/lib/webrtc/sfu/index.js +61 -11
- package/lib/webrtc/sfu/livekit.js +26 -23
- package/lib/webrtc/sfu/mediasoup.js +48 -8
- package/lib/webrtc/sfu/memory.js +26 -9
- package/lib/webrtc/signaling.js +86 -13
- package/lib/webrtc/stun.js +4 -12
- package/lib/webrtc/turn/credentials.js +33 -18
- package/lib/webrtc/turn/server.js +32 -17
- package/package.json +1 -1
- package/types/body.d.ts +82 -14
- package/types/cli.d.ts +40 -2
- package/types/index.d.ts +3 -2
- package/types/middleware.d.ts +17 -71
- package/types/orm.d.ts +1 -10
package/lib/webrtc/observe.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/observe
|
|
3
|
-
* @description Optional metrics + tracing wiring for a
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* OpenTelemetry-compatible spans.
|
|
3
|
+
* @description Optional metrics + tracing wiring for a `SignalingHub`.
|
|
4
|
+
* `bindObservability(hub, { metrics, tracer })` exports the standard
|
|
5
|
+
* `zs_webrtc_*` Prometheus series plus OTel-compatible spans. Both
|
|
6
|
+
* adapters are duck-typed and opt-in.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
'use strict';
|
|
@@ -215,9 +214,41 @@ function _bindTracing(hub, tracer)
|
|
|
215
214
|
* @param {Tracer} [opts.tracer] - Tracer for span emission.
|
|
216
215
|
* @returns {SignalingHub} The same hub, for chaining.
|
|
217
216
|
*
|
|
218
|
-
* @example |
|
|
217
|
+
* @example | Wire metrics + tracing from Zero Server's observe scope
|
|
218
|
+
* const { createApp } = require('@zero-server/sdk');
|
|
219
|
+
* const { Tracer } = require('@zero-server/sdk/observe');
|
|
220
|
+
* const { SignalingHub, bindObservability } = require('@zero-server/webrtc');
|
|
221
|
+
*
|
|
222
|
+
* const app = createApp();
|
|
223
|
+
* const hub = new SignalingHub({ joinTokenSecret: process.env.JWT });
|
|
224
|
+
*
|
|
225
|
+
* bindObservability(hub, {
|
|
226
|
+
* metrics: app.metrics, // exposes /metrics for Prometheus scraping
|
|
227
|
+
* tracer: new Tracer(), // OTel-shaped tracer
|
|
228
|
+
* });
|
|
229
|
+
*
|
|
230
|
+
* app.ws('/rtc', (ws, req) => hub.attach(ws, { user: req.user, ip: req.ip }));
|
|
231
|
+
*
|
|
232
|
+
* @example | Metrics only (no tracer)
|
|
219
233
|
* const hub = new SignalingHub();
|
|
220
|
-
* bindObservability(hub, { metrics: app.metrics
|
|
234
|
+
* bindObservability(hub, { metrics: app.metrics });
|
|
235
|
+
* // Scrape /metrics:
|
|
236
|
+
* // zs_webrtc_peers_active{room="lobby"} 3
|
|
237
|
+
* // zs_webrtc_rooms_active 1
|
|
238
|
+
* // zs_webrtc_signaling_messages_total{type="offer",direction="in",result="ok"} 17
|
|
239
|
+
* // zs_webrtc_offer_duration_ms_bucket{room="lobby",le="250"} 5
|
|
240
|
+
*
|
|
241
|
+
* @example | Custom prom-client registry adapter
|
|
242
|
+
* const client = require('prom-client');
|
|
243
|
+
* const reg = new client.Registry();
|
|
244
|
+
* const adapter = {
|
|
245
|
+
* counter: ({ name, help, labels }) => new client.Counter({ name, help, labelNames: labels, registers: [reg] }),
|
|
246
|
+
* gauge: ({ name, help, labels }) => new client.Gauge ({ name, help, labelNames: labels, registers: [reg] }),
|
|
247
|
+
* histogram: ({ name, help, labels, buckets }) =>
|
|
248
|
+
* new client.Histogram({ name, help, labelNames: labels, buckets, registers: [reg] }),
|
|
249
|
+
* };
|
|
250
|
+
* bindObservability(hub, { metrics: adapter });
|
|
251
|
+
* app.get('/metrics', async (_req, res) => res.type(reg.contentType).send(await reg.metrics()));
|
|
221
252
|
*/
|
|
222
253
|
function bindObservability(hub, opts = {})
|
|
223
254
|
{
|
package/lib/webrtc/peer.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/peer
|
|
3
|
-
* @description Per-connection state machine for a
|
|
3
|
+
* @description Per-connection state machine for a signaling peer. Wraps a
|
|
4
|
+
* `{ send, on, close }` transport (typically `app.ws()`'s
|
|
5
|
+
* `WebSocketConnection`) and exposes JSEP message types as events.
|
|
6
|
+
* Constructed by `hub.attach(transport, info)` — not directly.
|
|
4
7
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* @example | Inspect every newly-attached peer
|
|
9
|
+
* hub.on('join', ({ peer, room }) => {
|
|
10
|
+
* console.log(
|
|
11
|
+
* 'peer', peer.id,
|
|
12
|
+
* 'user=', peer.user && peer.user.id,
|
|
13
|
+
* 'ip=', peer.ip,
|
|
14
|
+
* 'joined', room.name,
|
|
15
|
+
* 'roster size=', room.size,
|
|
16
|
+
* );
|
|
17
|
+
* });
|
|
9
18
|
*/
|
|
10
19
|
|
|
11
20
|
'use strict';
|
|
@@ -25,14 +34,32 @@ const PEER_STATE = Object.freeze({
|
|
|
25
34
|
/**
|
|
26
35
|
* One signaling peer attached to a `SignalingHub` via some transport.
|
|
27
36
|
*
|
|
37
|
+
* `Peer` is created by `hub.attach(transport, info)` and lives until the
|
|
38
|
+
* underlying transport closes (or the hub kicks the peer for protocol
|
|
39
|
+
* abuse). All event listeners are owned by the hub; application code
|
|
40
|
+
* subscribes to hub-level events such as `join`, `leave`, and the
|
|
41
|
+
* room-level `peer-joined` / `peer-left`.
|
|
42
|
+
*
|
|
28
43
|
* @class
|
|
29
44
|
* @section Peers
|
|
30
45
|
*
|
|
31
|
-
* @example
|
|
46
|
+
* @example | Push a per-peer welcome and tap mute events
|
|
32
47
|
* hub.on('join', ({ peer, room }) => {
|
|
33
|
-
* peer.send('welcome', { roomSize: room.size });
|
|
34
|
-
* peer.on('mute', ev => audit('mute', peer.id, ev.kind));
|
|
48
|
+
* peer.send('welcome', { roomSize: room.size, you: peer.id });
|
|
49
|
+
* peer.on?.('mute', ev => audit('mute', peer.id, ev.kind));
|
|
35
50
|
* });
|
|
51
|
+
*
|
|
52
|
+
* @example | Forcefully disconnect a peer that fails an auth refresh
|
|
53
|
+
* async function rotateAuth(peer) {
|
|
54
|
+
* const ok = await refreshSession(peer.user);
|
|
55
|
+
* if (!ok) peer.close(4401, 'auth-expired');
|
|
56
|
+
* }
|
|
57
|
+
*
|
|
58
|
+
* @example | Send a typed error frame instead of throwing inside a handler
|
|
59
|
+
* if (msg.bytes.length > MAX_BYTES) {
|
|
60
|
+
* peer.sendError('PAYLOAD_TOO_LARGE', 'frame > 64 KiB');
|
|
61
|
+
* return;
|
|
62
|
+
* }
|
|
36
63
|
*/
|
|
37
64
|
class Peer
|
|
38
65
|
{
|
package/lib/webrtc/room.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/room
|
|
3
|
-
* @description Room / channel abstraction for the
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the cluster adapter (PR 8) will fan room state out via pub/sub.
|
|
3
|
+
* @description Room / channel abstraction for the signaling hub. Holds a
|
|
4
|
+
* set of peers plus a fluent chain of policy gates (`require()`,
|
|
5
|
+
* `canPublish()`, `canSubscribe()`). Constructed lazily via
|
|
6
|
+
* `hub.room(name)`; pair with `useCluster()` for multi-node membership.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
'use strict';
|
|
@@ -20,11 +19,25 @@ const { SignalingError } = require('../errors');
|
|
|
20
19
|
* @class
|
|
21
20
|
* @section Rooms
|
|
22
21
|
*
|
|
23
|
-
* @example
|
|
22
|
+
* @example | Open a public lobby that anyone with a token may join
|
|
24
23
|
* hub.room('lobby').open();
|
|
24
|
+
*
|
|
25
|
+
* @example | Gate a room with role + per-publisher policies
|
|
25
26
|
* hub.room('boardroom')
|
|
26
27
|
* .require(peer => peer.user && peer.user.role === 'exec')
|
|
27
|
-
* .canPublish(peer => peer.user.isHost)
|
|
28
|
+
* .canPublish(peer => peer.user.isHost)
|
|
29
|
+
* .canSubscribe(peer => peer.user.verified === true)
|
|
30
|
+
* .open();
|
|
31
|
+
*
|
|
32
|
+
* @example | Programmatic room metrics
|
|
33
|
+
* const r = hub.room('webinar-42');
|
|
34
|
+
* setInterval(() => log.info({ room: r.name, size: r.size }), 10_000);
|
|
35
|
+
*
|
|
36
|
+
* @example | Broadcast a system announcement from outside the message handler
|
|
37
|
+
* hub.room('lobby').broadcast('notice', { text: 'Server restart in 60s' });
|
|
38
|
+
*
|
|
39
|
+
* @example | Boot the room and disconnect everyone gracefully
|
|
40
|
+
* hub.room('lobby').close('shutdown');
|
|
28
41
|
*/
|
|
29
42
|
class Room
|
|
30
43
|
{
|
package/lib/webrtc/sdp.js
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/sdp
|
|
3
|
-
* @description Zero-dependency RFC 8866
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* The parser deliberately does NOT validate semantics that belong
|
|
9
|
-
* to a SignalingHub policy layer (codec allowlists, mDNS blocking,
|
|
10
|
-
* etc.) - it only structures the document. Policy lives in
|
|
11
|
-
* `lib/webrtc/signaling.js`.
|
|
3
|
+
* @description Zero-dependency RFC 8866 SDP parser and serializer with
|
|
4
|
+
* WebRTC-specific attribute extraction per RFC 8829 (ice-ufrag, ice-pwd,
|
|
5
|
+
* fingerprint, setup, mid, rtcp-mux, rtpmap, fmtp, ssrc, etc.). Pure
|
|
6
|
+
* structure — policy lives in the signaling layer.
|
|
12
7
|
*
|
|
13
8
|
* @see https://datatracker.ietf.org/doc/html/rfc8866
|
|
14
9
|
* @see https://datatracker.ietf.org/doc/html/rfc8829
|
package/lib/webrtc/sfu/index.js
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/sfu
|
|
3
|
-
* @description
|
|
3
|
+
* @description Pluggable Selective Forwarding Unit (SFU) adapter interface.
|
|
4
|
+
* The signaling hub stays media-agnostic; the adapter owns routers,
|
|
5
|
+
* transports, producers, and consumers. Ships with `MemorySfuAdapter`,
|
|
6
|
+
* `MediasoupSfuAdapter`, and `LiveKitSfuAdapter`. Subclass `SfuAdapter`
|
|
7
|
+
* for custom backends.
|
|
4
8
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* @example | Select an adapter at boot via env
|
|
10
|
+
* const { loadSfuAdapter } = require('@zero-server/webrtc');
|
|
11
|
+
* const sfu = loadSfuAdapter(process.env.SFU_BACKEND || 'memory', {
|
|
12
|
+
* // adapter-specific options forwarded verbatim
|
|
13
|
+
* workerSettings: { logLevel: 'warn' },
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* sfu.onEvent((event, payload) => log.debug({ event, payload }, 'sfu'));
|
|
17
|
+
*
|
|
18
|
+
* @example | Wire an SFU into the signaling hub per room
|
|
19
|
+
* const router = await sfu.createRouter({ room: 'lobby' });
|
|
20
|
+
*
|
|
21
|
+
* hub.on('join', async ({ peer, room }) => {
|
|
22
|
+
* if (room.name !== 'lobby') return;
|
|
23
|
+
* const transport = await sfu.createTransport(router, peer);
|
|
24
|
+
* peer.send('sfu-transport', { iceParameters: transport.iceParameters });
|
|
25
|
+
* });
|
|
10
26
|
*/
|
|
11
27
|
'use strict';
|
|
12
28
|
|
|
@@ -20,11 +36,30 @@ const { WebRTCError } = require('../../errors');
|
|
|
20
36
|
* The interface is intentionally tiny so a backend can be written in a
|
|
21
37
|
* single file:
|
|
22
38
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
39
|
+
* @example | Skeleton adapter
|
|
40
|
+
* class MyAdapter extends SfuAdapter {
|
|
41
|
+
* async createRouter(opts) { return { id: cuid(), opts }; }
|
|
42
|
+
* async createTransport(router, peer) { return { id: cuid(), router, peer }; }
|
|
43
|
+
* async produce(transport, kind, rtp) {
|
|
44
|
+
* const id = cuid();
|
|
45
|
+
* this._emit('producer-new', { id, kind, transportId: transport.id });
|
|
46
|
+
* return { id, kind };
|
|
47
|
+
* }
|
|
48
|
+
* async consume(transport, producerId, _rtpCaps) {
|
|
49
|
+
* return { id: cuid(), producerId, transportId: transport.id };
|
|
50
|
+
* }
|
|
51
|
+
* async pauseProducer(id) { this._emit('producer-pause', { id }); }
|
|
52
|
+
* async resumeProducer(id) { this._emit('producer-resume', { id }); }
|
|
53
|
+
* async closeRouter(id) { this._emit('router-close', { id }); }
|
|
54
|
+
* async stats(_scope) { return { ts: Date.now() }; }
|
|
55
|
+
* }
|
|
56
|
+
*
|
|
57
|
+
* @example | Subscribe to adapter events
|
|
58
|
+
* const off = sfu.onEvent((event, payload) => {
|
|
59
|
+
* if (event === 'producer-new') metrics.producers.inc();
|
|
60
|
+
* if (event === 'producer-pause') log.info({ id: payload.id }, 'paused');
|
|
61
|
+
* });
|
|
62
|
+
* // later: off();
|
|
28
63
|
*/
|
|
29
64
|
class SfuAdapter
|
|
30
65
|
{
|
|
@@ -117,6 +152,21 @@ class SfuAdapter
|
|
|
117
152
|
* - 'memory' | 'mediasoup' | 'livekit' | adapter package id.
|
|
118
153
|
* @param {object} [opts] - constructor options forwarded to the adapter.
|
|
119
154
|
* @returns {SfuAdapter}
|
|
155
|
+
*
|
|
156
|
+
* @example | Built-in adapter by name
|
|
157
|
+
* const sfu = loadSfuAdapter('mediasoup', {
|
|
158
|
+
* numWorkers: 4,
|
|
159
|
+
* workerSettings: { rtcMinPort: 40000, rtcMaxPort: 49999 },
|
|
160
|
+
* });
|
|
161
|
+
*
|
|
162
|
+
* @example | Pre-constructed instance (e.g. shared singleton in tests)
|
|
163
|
+
* const fake = new MemorySfuAdapter();
|
|
164
|
+
* const sfu = loadSfuAdapter(fake);
|
|
165
|
+
* expect(sfu).toBe(fake);
|
|
166
|
+
*
|
|
167
|
+
* @example | Third-party adapter package
|
|
168
|
+
* // npm i @acme/zero-server-sfu-janus
|
|
169
|
+
* const sfu = loadSfuAdapter('@acme/zero-server-sfu-janus', { wsUrl: 'ws://janus/ws' });
|
|
120
170
|
*/
|
|
121
171
|
function loadSfuAdapter(spec, opts)
|
|
122
172
|
{
|
|
@@ -1,31 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/sfu/livekit
|
|
3
|
-
* @description LiveKit-backed SFU adapter (peerDependency on
|
|
3
|
+
* @description LiveKit-backed SFU adapter (peerDependency on
|
|
4
|
+
* `livekit-server-sdk`). Maps the `SfuAdapter` contract onto LiveKit's
|
|
5
|
+
* remote media plane: `createTransport` mints an AccessToken, mute/close
|
|
6
|
+
* delegate to `RoomServiceClient`, and produce/consume are local
|
|
7
|
+
* bookkeeping while LiveKit handles media client-side.
|
|
4
8
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
+
* @example | Cloud LiveKit project
|
|
10
|
+
* // npm install livekit-server-sdk
|
|
11
|
+
* const { LiveKitSfuAdapter } = require('@zero-server/webrtc');
|
|
12
|
+
* const sfu = new LiveKitSfuAdapter({
|
|
13
|
+
* host: 'https://my-project.livekit.cloud',
|
|
14
|
+
* apiKey: process.env.LIVEKIT_API_KEY,
|
|
15
|
+
* apiSecret: process.env.LIVEKIT_API_SECRET,
|
|
16
|
+
* tokenTtl: '30m',
|
|
17
|
+
* });
|
|
9
18
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* when the producer was registered
|
|
18
|
-
* with a `{room, identity, trackSid}`
|
|
19
|
-
* hint; otherwise emits the event
|
|
20
|
-
* without touching the server
|
|
21
|
-
* - closeRouter(routerId) -> RoomServiceClient.deleteRoom(...)
|
|
22
|
-
* - stats() -> RoomServiceClient.listRooms() /
|
|
23
|
-
* listParticipants(...) plus local
|
|
24
|
-
* counters
|
|
19
|
+
* @example | Self-hosted LiveKit + handing the JWT to a browser
|
|
20
|
+
* const router = await sfu.createRouter({ room: 'standup' });
|
|
21
|
+
* const transport = await sfu.createTransport(router, {
|
|
22
|
+
* id: peer.id,
|
|
23
|
+
* user: { id: peer.user.id, name: peer.user.name },
|
|
24
|
+
* });
|
|
25
|
+
* peer.send('livekit', { url: transport.url, token: transport.token });
|
|
25
26
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* @example | Mute a noisy publisher (REST passthrough)
|
|
28
|
+
* await sfu.pauseProducer(producer.id);
|
|
29
|
+
* // adapter records the producerId; if it was registered with a
|
|
30
|
+
* // { room, identity, trackSid } hint it issues
|
|
31
|
+
* // RoomServiceClient.mutePublishedTrack() against LiveKit.
|
|
29
32
|
*/
|
|
30
33
|
'use strict';
|
|
31
34
|
|
|
@@ -1,15 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/sfu/mediasoup
|
|
3
|
-
* @description mediasoup-backed SFU adapter (peerDependency on
|
|
3
|
+
* @description mediasoup-backed SFU adapter (peerDependency on
|
|
4
|
+
* `mediasoup`). Wraps a `Worker` plus one `Router` per `createRouter()`
|
|
5
|
+
* call and delegates produce/consume/pause/resume/close/stats to the
|
|
6
|
+
* native objects. Adapter events are uniform via `onEvent`.
|
|
4
7
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the native mediasoup objects.
|
|
8
|
+
* @example | Production setup with custom RTP port range and announced IP
|
|
9
|
+
* // npm install mediasoup
|
|
10
|
+
* const { MediasoupSfuAdapter } = require('@zero-server/webrtc');
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* const sfu = new MediasoupSfuAdapter({
|
|
13
|
+
* workerSettings: {
|
|
14
|
+
* logLevel: 'warn',
|
|
15
|
+
* rtcMinPort: 40000,
|
|
16
|
+
* rtcMaxPort: 49999,
|
|
17
|
+
* },
|
|
18
|
+
* webRtcTransportOptions: {
|
|
19
|
+
* listenIps: [{ ip: '0.0.0.0', announcedIp: process.env.PUBLIC_IP }],
|
|
20
|
+
* enableUdp: true,
|
|
21
|
+
* enableTcp: true,
|
|
22
|
+
* preferUdp: true,
|
|
23
|
+
* initialAvailableOutgoingBitrate: 800_000,
|
|
24
|
+
* },
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* @example | One router per room, lazy on first join
|
|
28
|
+
* const routersByRoom = new Map();
|
|
29
|
+
*
|
|
30
|
+
* async function getRouter(roomName) {
|
|
31
|
+
* let r = routersByRoom.get(roomName);
|
|
32
|
+
* if (!r) {
|
|
33
|
+
* r = await sfu.createRouter({ room: roomName });
|
|
34
|
+
* routersByRoom.set(roomName, r);
|
|
35
|
+
* }
|
|
36
|
+
* return r;
|
|
37
|
+
* }
|
|
38
|
+
*
|
|
39
|
+
* hub.on('join', async ({ peer, room }) => {
|
|
40
|
+
* const router = await getRouter(room.name);
|
|
41
|
+
* const transport = await sfu.createTransport(router, peer);
|
|
42
|
+
* peer.send('sfu-ready', { transportId: transport.id });
|
|
43
|
+
* });
|
|
44
|
+
*
|
|
45
|
+
* @example | Inject a stub for unit tests
|
|
46
|
+
* const stubMediasoup = {
|
|
47
|
+
* createWorker: async () => ({
|
|
48
|
+
* createRouter: async () => fakeRouter,
|
|
49
|
+
* close: () => {},
|
|
50
|
+
* }),
|
|
51
|
+
* };
|
|
52
|
+
* const sfu = new MediasoupSfuAdapter({ mediasoup: stubMediasoup });
|
|
13
53
|
*/
|
|
14
54
|
'use strict';
|
|
15
55
|
|
package/lib/webrtc/sfu/memory.js
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/sfu/memory
|
|
3
|
-
* @description In-process "memory" SFU adapter.
|
|
3
|
+
* @description In-process "memory" SFU adapter. Bookkeeping-only
|
|
4
|
+
* passthrough that records producers/consumers and emits adapter events
|
|
5
|
+
* without forwarding media packets. Ideal for unit tests, CI, and local
|
|
6
|
+
* dev — use a native adapter for real media plane work.
|
|
4
7
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Perfect for unit tests, ≤ 4-peer audio-only rooms, and local dev
|
|
9
|
-
* where the cost of running mediasoup or LiveKit is unjustified.
|
|
8
|
+
* @example | Use the memory adapter inside a vitest suite
|
|
9
|
+
* const { MemorySfuAdapter } = require('@zero-server/webrtc');
|
|
10
|
+
* const sfu = new MemorySfuAdapter();
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* const events = [];
|
|
13
|
+
* sfu.onEvent((event, payload) => events.push({ event, payload }));
|
|
14
|
+
*
|
|
15
|
+
* const router = await sfu.createRouter({ room: 'lobby' });
|
|
16
|
+
* const transport = await sfu.createTransport(router, { id: 'peer-1' });
|
|
17
|
+
* const producer = await sfu.produce(transport, 'audio', { codec: 'opus' });
|
|
18
|
+
* const consumer = await sfu.consume(transport, producer.id, {});
|
|
19
|
+
*
|
|
20
|
+
* expect(events).toEqual([
|
|
21
|
+
* { event: 'router-new', payload: { routerId: router.id } },
|
|
22
|
+
* { event: 'transport-new', payload: expect.any(Object) },
|
|
23
|
+
* { event: 'producer-new', payload: expect.objectContaining({ kind: 'audio' }) },
|
|
24
|
+
* { event: 'consumer-new', payload: expect.objectContaining({ producerId: producer.id }) },
|
|
25
|
+
* ]);
|
|
26
|
+
*
|
|
27
|
+
* @example | Drive it through `loadSfuAdapter`
|
|
28
|
+
* const sfu = loadSfuAdapter('memory');
|
|
29
|
+
* const stats = await sfu.stats();
|
|
30
|
+
* console.log(stats); // { routers, transports, producers, consumers }
|
|
14
31
|
*/
|
|
15
32
|
'use strict';
|
|
16
33
|
|
package/lib/webrtc/signaling.js
CHANGED
|
@@ -1,13 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/signaling
|
|
3
|
-
* @description WebRTC signaling hub
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* @description WebRTC signaling hub. Central WS broker that owns the room
|
|
4
|
+
* registry, attaches peers, validates JSEP messages, and routes
|
|
5
|
+
* offer / answer / ICE traffic. Transport-agnostic — bind to `app.ws()`
|
|
6
|
+
* in production, an `EventEmitter` shim in tests.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* @example | Bind a hub to an `app.ws()` route with all production knobs
|
|
9
|
+
* const app = createApp();
|
|
10
|
+
* const hub = new SignalingHub({
|
|
11
|
+
* joinTokenSecret: process.env.WEBRTC_JWT_SECRET,
|
|
12
|
+
* maxSdpSize: 64 * 1024,
|
|
13
|
+
* maxCandidatesPerOffer: 20,
|
|
14
|
+
* peerMessageRate: 30,
|
|
15
|
+
* maxProtocolErrors: 5,
|
|
16
|
+
* ipAttachRate: 60,
|
|
17
|
+
* originAllowlist: ['https://meet.acme.com'],
|
|
18
|
+
* autoCreateRooms: false,
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* hub.room('lobby').open();
|
|
22
|
+
*
|
|
23
|
+
* app.ws('/rtc', (ws, req) => {
|
|
24
|
+
* hub.attach(ws, {
|
|
25
|
+
* user: req.user,
|
|
26
|
+
* ip: req.ip,
|
|
27
|
+
* origin: req.headers.origin,
|
|
28
|
+
* });
|
|
29
|
+
* });
|
|
30
|
+
*
|
|
31
|
+
* @example | Observe lifecycle events
|
|
32
|
+
* hub.on('join', ({ peer, room }) => log.info({ peer: peer.id, room: room.name }, 'joined'));
|
|
33
|
+
* hub.on('leave', ({ peer, room }) => log.info({ peer: peer.id, room: room.name }, 'left'));
|
|
34
|
+
* hub.on('wireError', ({ peer, code }) => log.warn({ peer: peer.id, code }, 'wire-error'));
|
|
11
35
|
*/
|
|
12
36
|
|
|
13
37
|
'use strict';
|
|
@@ -59,6 +83,11 @@ function _countCandidatesInSdp(sdp)
|
|
|
59
83
|
* Validate that an SDP has the required RFC 8829 attributes on every media
|
|
60
84
|
* section and uses DTLS-SRTP transport. Returns an error code string, or
|
|
61
85
|
* `null` if the SDP is acceptable.
|
|
86
|
+
*
|
|
87
|
+
* Accepts both RTP/SAVPF audio/video sections (RFC 8829) and SCTP data-channel
|
|
88
|
+
* sections (RFC 8841 m=application ... UDP/DTLS/SCTP). When BUNDLE is in use
|
|
89
|
+
* (every browser since ~2018), only the first m-section is required to carry
|
|
90
|
+
* iceUfrag/icePwd; other bundled sections inherit them via a=group:BUNDLE.
|
|
62
91
|
*/
|
|
63
92
|
function _validateSdpStructure(sdp)
|
|
64
93
|
{
|
|
@@ -70,12 +99,36 @@ function _validateSdpStructure(sdp)
|
|
|
70
99
|
return 'INVALID_SDP';
|
|
71
100
|
}
|
|
72
101
|
if (!desc.media || desc.media.length === 0) return 'INVALID_SDP';
|
|
102
|
+
|
|
103
|
+
// BUNDLE: collect bundled mids so per-section ice credentials are optional
|
|
104
|
+
// on every section except the first (the BUNDLE owner).
|
|
105
|
+
const bundleMids = new Set();
|
|
106
|
+
for (const a of desc.attributes || [])
|
|
107
|
+
{
|
|
108
|
+
if (a.key !== 'group') continue;
|
|
109
|
+
const parts = String(a.value || '').split(/\s+/);
|
|
110
|
+
if (parts[0] !== 'BUNDLE') continue;
|
|
111
|
+
for (let i = 1; i < parts.length; i++) bundleMids.add(parts[i]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let firstBundleMid = null;
|
|
73
115
|
for (const m of desc.media)
|
|
74
116
|
{
|
|
75
|
-
if (typeof m.proto !== 'string'
|
|
76
|
-
|
|
77
|
-
|
|
117
|
+
if (typeof m.proto !== 'string') return 'INVALID_SDP';
|
|
118
|
+
|
|
119
|
+
// RTP audio/video (RFC 8829) OR SCTP data channels (RFC 8841).
|
|
120
|
+
const isRtp = /^UDP\/TLS\/RTP\/SAVPF?$/i.test(m.proto);
|
|
121
|
+
const isSctp = /^(UDP|TCP)\/DTLS\/SCTP$/i.test(m.proto);
|
|
122
|
+
if (!isRtp && !isSctp) return 'INVALID_SDP';
|
|
123
|
+
|
|
78
124
|
if (!m.fingerprint) return 'INVALID_SDP';
|
|
125
|
+
|
|
126
|
+
// ice-ufrag / ice-pwd: required on the BUNDLE owner; optional on
|
|
127
|
+
// every other bundled section (inherited per RFC 8843 §9.2).
|
|
128
|
+
const bundled = m.mid && bundleMids.has(m.mid);
|
|
129
|
+
if (bundled && firstBundleMid === null) firstBundleMid = m.mid;
|
|
130
|
+
const isBundleOwner = !bundled || m.mid === firstBundleMid;
|
|
131
|
+
if (isBundleOwner && (!m.iceUfrag || !m.icePwd)) return 'INVALID_SDP';
|
|
79
132
|
}
|
|
80
133
|
return null;
|
|
81
134
|
}
|
|
@@ -187,13 +240,33 @@ class SignalingHub extends EventEmitter
|
|
|
187
240
|
|
|
188
241
|
/**
|
|
189
242
|
* Attach a transport (WS connection or mock) as a new signaling peer.
|
|
190
|
-
* Wires up message and close handlers
|
|
243
|
+
* Wires up message and close handlers, performs origin / IP-rate
|
|
244
|
+
* pre-checks, sends a `hello` frame with the new peer id, and returns
|
|
245
|
+
* the `Peer`. The returned peer is also registered in `hub.size`.
|
|
246
|
+
*
|
|
247
|
+
* Called from your `app.ws()` upgrade handler in production:
|
|
248
|
+
*
|
|
249
|
+
* ```js
|
|
250
|
+
* app.ws('/rtc', (ws, req) => hub.attach(ws, {
|
|
251
|
+
* user: req.user,
|
|
252
|
+
* ip: req.ip,
|
|
253
|
+
* origin: req.headers.origin,
|
|
254
|
+
* }));
|
|
255
|
+
* ```
|
|
191
256
|
*
|
|
192
257
|
* @param {object} transport - `{ send, on, close }`-shaped object.
|
|
193
258
|
* @param {object} [info]
|
|
194
|
-
* @param {*} [info.user]
|
|
195
|
-
* @param {string} [info.ip]
|
|
259
|
+
* @param {*} [info.user] - Authenticated user (forwarded to room gates).
|
|
260
|
+
* @param {string} [info.ip] - Remote IP for audit + `ipAttachRate`.
|
|
261
|
+
* @param {string} [info.origin] - Origin header for `originAllowlist`.
|
|
196
262
|
* @returns {Peer}
|
|
263
|
+
*
|
|
264
|
+
* @example | Test harness: attach a mock transport
|
|
265
|
+
* const mock = new EventEmitter();
|
|
266
|
+
* mock.send = (frame) => mock.emit('out', frame);
|
|
267
|
+
* mock.close = () => mock.emit('close');
|
|
268
|
+
* const peer = hub.attach(mock, { user: { id: 'u1' }, ip: '127.0.0.1' });
|
|
269
|
+
* mock.emit('message', JSON.stringify({ type: 'join', room: 'lobby' }));
|
|
197
270
|
*/
|
|
198
271
|
attach(transport, info = {})
|
|
199
272
|
{
|
package/lib/webrtc/stun.js
CHANGED
|
@@ -1,19 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/stun
|
|
3
|
-
* @description Zero-dependency RFC 8489 STUN
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* deprecated MAPPED-ADDRESS attribute used by some legacy servers).
|
|
8
|
-
*
|
|
9
|
-
* Only the parts of RFC 8489 we need for NAT discovery are
|
|
10
|
-
* implemented: BINDING method, REQUEST and SUCCESS classes,
|
|
11
|
-
* XOR-MAPPED-ADDRESS / MAPPED-ADDRESS attributes for IPv4 + IPv6.
|
|
12
|
-
* Authenticated TURN allocations are handled in
|
|
13
|
-
* `lib/webrtc/turn/`.
|
|
3
|
+
* @description Zero-dependency RFC 8489 STUN client. Sends a Binding Request
|
|
4
|
+
* over UDP, parses the Binding Response, and returns the server-reflexive
|
|
5
|
+
* address from XOR-MAPPED-ADDRESS (or legacy MAPPED-ADDRESS). NAT-discovery
|
|
6
|
+
* subset only; TURN allocations live in `lib/webrtc/turn/`.
|
|
14
7
|
*
|
|
15
8
|
* @see https://datatracker.ietf.org/doc/html/rfc8489
|
|
16
|
-
* @see https://datatracker.ietf.org/doc/html/rfc5769 (test vectors)
|
|
17
9
|
*/
|
|
18
10
|
|
|
19
11
|
'use strict';
|