@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.
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * @module webrtc/observe
3
- * @description Optional metrics + tracing wiring for a {@link SignalingHub}.
4
- * Pass a `MetricsRegistry`, a `Tracer`, or both; the binder
5
- * subscribes to the hub's lifecycle events and exports the six
6
- * standard `zs_webrtc_*` Prometheus series plus per-operation
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 | Plug into an existing app
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
  {
@@ -1,11 +1,20 @@
1
1
  /**
2
2
  * @module webrtc/peer
3
- * @description Per-connection state machine for a WebRTC signaling peer.
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
- * A `Peer` wraps a transport object (typically a `WebSocketConnection`
6
- * but any duck-typed `{ send, on, close }` works - see the unit tests
7
- * for a minimal in-memory transport) and exposes the JSEP message
8
- * types as ordinary events. The hub is responsible for routing.
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
  {
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * @module webrtc/room
3
- * @description Room / channel abstraction for the WebRTC signaling hub.
4
- *
5
- * A `Room` holds a set of `Peer`s plus a list of `require()` policy gates
6
- * that decide whether a given peer may join. Membership is in-process;
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 Session Description Protocol parser and
4
- * serializer, with WebRTC-specific attribute extraction (JSEP per
5
- * RFC 8829: ice-ufrag, ice-pwd, fingerprint, setup, mid, rtcp-mux,
6
- * direction, rtpmap, fmtp, rid, simulcast, ssrc, extmap, candidate).
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
@@ -1,12 +1,28 @@
1
1
  /**
2
2
  * @module webrtc/sfu
3
- * @description SFU adapter base interface and discovery loader.
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
- * `SfuAdapter` defines the contract every backend (memory / mediasoup /
6
- * livekit / custom) must implement. `loadSfuAdapter()` resolves either
7
- * a pre-constructed instance, a known name ('memory', 'mediasoup',
8
- * 'livekit'), or a duck-typed object into a concrete adapter, throwing
9
- * `WEBRTC_SFU_NOT_INSTALLED` when a native peerDep is missing.
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
- * class MyAdapter extends SfuAdapter {
24
- * async createRouter(opts) { ... }
25
- * async createTransport(router, peer) { ... }
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 `livekit-server-sdk`).
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
- * LiveKit's media plane is controlled remotely: rooms live on the
6
- * LiveKit server, participants connect directly with a signed JWT, and
7
- * the server SDK exposes a control-plane REST API. This adapter maps
8
- * the {@link SfuAdapter} contract onto that model:
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
- * - createRouter(opts) -> RoomServiceClient.createRoom(...)
11
- * - createTransport(router,peer) -> mints an AccessToken (the "transport"
12
- * handle is the URL + JWT the peer
13
- * uses to connect to LiveKit directly)
14
- * - produce / consume -> local bookkeeping; LiveKit handles
15
- * the actual media plane client-side
16
- * - pauseProducer / resume -> RoomServiceClient.mutePublishedTrack()
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
- * `livekit-server-sdk` is loaded lazily. Tests inject a stub via
27
- * `opts.livekit`; in production the constructor `require`s the package
28
- * and throws `WEBRTC_SFU_NOT_INSTALLED` if it is missing.
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 `mediasoup`).
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
- * Wraps a single mediasoup `Worker` and one `Router` per createRouter()
6
- * call. WebRTC transports are created with `router.createWebRtcTransport()`
7
- * and produce / consume / pause / resume / close / stats all delegate to
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
- * `mediasoup` is loaded lazily. Tests inject a stub via `opts.mediasoup`;
11
- * in production the constructor `require('mediasoup')`s the real package
12
- * and throws `WEBRTC_SFU_NOT_INSTALLED` if it is missing.
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
 
@@ -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
- * A passthrough router that never touches the network: every produce()
6
- * call records a logical producer, every consume() call records a
7
- * logical consumer, and events are emitted via {@link SfuAdapter#onEvent}.
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
- * The adapter does NOT decode or forward media packets - it models
12
- * bookkeeping only. Real packet forwarding lives in native adapters
13
- * (mediasoup, LiveKit).
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
 
@@ -1,13 +1,37 @@
1
1
  /**
2
2
  * @module webrtc/signaling
3
- * @description WebRTC signaling hub - the central WS broker that owns the
4
- * room registry, attaches peers, validates JSEP messages, and
5
- * routes offer / answer / ICE traffic between participants.
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
- * The hub itself is transport-agnostic: anything that exposes a
8
- * `{ send(string), on('message'|'close', cb), close(code?, reason?) }`
9
- * surface is acceptable. `createWebRTC(app, opts)` (PR 9 wiring layer)
10
- * binds an `app.ws()` upgrade handler to a `SignalingHub`.
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' || !/^UDP\/TLS\/RTP\/SAVPF?$/i.test(m.proto))
76
- return 'INVALID_SDP';
77
- if (!m.iceUfrag || !m.icePwd) return 'INVALID_SDP';
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; returns the `Peer`.
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] - Authenticated user.
195
- * @param {string} [info.ip] - Remote 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
  {
@@ -1,19 +1,11 @@
1
1
  /**
2
2
  * @module webrtc/stun
3
- * @description Zero-dependency RFC 8489 STUN (Session Traversal Utilities for
4
- * NAT) client. Sends a Binding Request over UDP, parses the
5
- * Binding Response, and returns the server-reflexive address
6
- * decoded from XOR-MAPPED-ADDRESS (or, as a fallback, the
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';