@zero-server/webrtc 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,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';
@@ -1,19 +1,10 @@
1
1
  /**
2
2
  * @file lib/webrtc/turn/credentials.js
3
3
  * @module @zero-server/webrtc/turn/credentials
4
- * @description RFC 7635 ephemeral TURN credentials.
5
- *
6
- * Generates time-limited username / credential pairs that any
7
- * RFC 7635-compatible TURN server (notably `coturn` with
8
- * `use-auth-secret` + `static-auth-secret=<S>`) will accept.
9
- *
10
- * Wire format (RFC 7635 §6.2):
11
- * username = "<unix-expiry>:<userId>"
12
- * credential = base64( HMAC-SHA1( <secret>, username ) )
13
- *
14
- * The returned object is shaped like an `RTCIceServer` entry so it can
15
- * be embedded straight into the ICE-server list a signaling endpoint
16
- * serves to browsers.
4
+ * @description RFC 7635 ephemeral TURN credentials. Generates short-TTL
5
+ * username/credential pairs accepted by any compliant TURN server
6
+ * (notably `coturn` with `use-auth-secret`). Returns an `RTCIceServer`-
7
+ * shaped object ready to hand to browsers.
17
8
  */
18
9
 
19
10
  'use strict';
@@ -53,14 +44,38 @@ const VALID_SCHEMES = ['turn:', 'turns:', 'stun:', 'stuns:'];
53
44
  * @returns {TurnCredentials}
54
45
  * @throws {TurnError} On missing / invalid input.
55
46
  *
56
- * @example
47
+ * @example | Hand a fresh credential to a browser before it joins a room
48
+ * // coturn.conf:
49
+ * // use-auth-secret
50
+ * // static-auth-secret=<TURN_SHARED_SECRET>
51
+ * // realm=turn.example.com
52
+ * app.get('/rtc/turn', (req, res) => {
53
+ * const creds = issueTurnCredentials({
54
+ * secret: process.env.TURN_SHARED_SECRET,
55
+ * userId: req.user.id,
56
+ * ttl: '20m',
57
+ * servers: ['turn:turn.example.com:3478?transport=udp'],
58
+ * });
59
+ * res.json(creds); // { urls, username, credential, ttl }
60
+ * });
61
+ *
62
+ * @example | Multiple URLs (UDP, TCP, TLS) for failover
57
63
  * const creds = issueTurnCredentials({
58
64
  * secret: process.env.TURN_SHARED_SECRET,
59
- * userId: req.user.id,
60
- * ttl: '20m',
61
- * servers: ['turn:turn.example.com:3478?transport=udp'],
65
+ * userId: 'bot-42',
66
+ * ttl: 3600,
67
+ * servers: [
68
+ * 'turn:turn.example.com:3478?transport=udp',
69
+ * 'turn:turn.example.com:3478?transport=tcp',
70
+ * 'turns:turn.example.com:5349',
71
+ * 'stun:turn.example.com:3478',
72
+ * ],
73
+ * });
74
+ *
75
+ * @example | Use directly in an RTCPeerConnection (browser side)
76
+ * const pc = new RTCPeerConnection({
77
+ * iceServers: [creds], // { urls, username, credential } is RTCIceServer-shaped
62
78
  * });
63
- * res.json(creds);
64
79
  *
65
80
  * @section ICE & TURN
66
81
  */
@@ -1,36 +1,51 @@
1
1
  /**
2
2
  * @module webrtc/turn/server
3
- * @description Zero-dependency embedded TURN server (RFC 5766) backing the
4
- * `TurnServer` public API. Implements the long-term-credential
5
- * auth flow paired with `issueTurnCredentials` ephemeral
6
- * accounts, UDP allocations, permissions, Send / Data
7
- * indications, channel bindings, lifetimes, and per-user
8
- * quotas.
3
+ * @description Zero-dependency embedded TURN server (RFC 5766). Pairs with
4
+ * `issueTurnCredentials` for long-term-credential auth and implements
5
+ * UDP allocations, permissions, Send/Data indications, channel bindings,
6
+ * lifetimes, and per-user quotas. TCP/TLS listeners are reserved and
7
+ * currently throw `TURN_TRANSPORT_UNSUPPORTED`.
9
8
  *
10
- * The hot path is intentionally compact: all framing lives in
11
- * `lib/webrtc/turn/codec.js` and the server only owns state +
12
- * relay-socket multiplexing. TCP and TLS listeners are reserved in the
13
- * constructor surface and will be implemented in a later PR; calling
14
- * `start()` against either currently throws `TURN_TRANSPORT_UNSUPPORTED`.
15
- *
16
- * @example
9
+ * @example | Boot an embedded TURN server alongside Zero Server
17
10
  * const { TurnServer, issueTurnCredentials } = require('@zero-server/webrtc');
18
11
  *
19
12
  * const turn = new TurnServer({
20
- * secret: process.env.TURN_SECRET,
21
- * realm: 'rtc.example.com',
13
+ * secret: process.env.TURN_SECRET,
14
+ * realm: 'rtc.example.com',
15
+ * relayHost: process.env.PUBLIC_IP || '0.0.0.0',
22
16
  * listeners: [{ proto: 'udp', port: 3478 }],
23
- * quotas: { maxAllocationsPerUser: 4, maxBytesPerMinute: 50_000_000 },
17
+ * quotas: { maxAllocationsPerUser: 4, maxBytesPerMinute: 50_000_000 },
24
18
  * });
25
19
  * await turn.start();
26
20
  *
21
+ * turn.on('allocate', ({ user, relay }) => log.info({ user, relay }, 'turn allocate'));
22
+ * turn.on('permission', ({ user, peer }) => log.debug({ user, peer }, 'turn permission'));
23
+ * turn.on('relay', ({ user, bytes }) => metrics.turnBytes.inc(bytes));
24
+ *
25
+ * process.on('SIGTERM', () => turn.close());
26
+ *
27
+ * @example | Issue creds + return them with the room join payload
28
+ * const { TurnServer, issueTurnCredentials } = require('@zero-server/webrtc');
27
29
  * const creds = issueTurnCredentials({
28
30
  * secret: process.env.TURN_SECRET,
29
31
  * userId: req.user.id,
30
32
  * servers: ['turn:rtc.example.com:3478'],
31
33
  * ttl: '20m',
32
34
  * });
33
- * res.json(creds);
35
+ * res.json({ token, iceServers: [creds] });
36
+ *
37
+ * @example | Multi-listener (UDP + future TCP) with tight per-user quotas
38
+ * const turn = new TurnServer({
39
+ * secret: process.env.TURN_SECRET,
40
+ * realm: 'rtc.example.com',
41
+ * listeners: [{ proto: 'udp', port: 3478, host: '0.0.0.0' }],
42
+ * quotas: {
43
+ * maxAllocationsPerUser: 2,
44
+ * maxBytesPerMinute: 5_000_000, // 5 MB/min per user
45
+ * },
46
+ * defaultLifetime: 300,
47
+ * maxLifetime: 1800,
48
+ * });
34
49
  */
35
50
 
36
51
  'use strict';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zero-server/webrtc",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "WebRTC signaling hub, TURN credentials, STUN client, SFU adapter interface.",
5
5
  "keywords": [
6
6
  "zero-server",
@@ -45,14 +45,14 @@
45
45
  },
46
46
  "sideEffects": false,
47
47
  "dependencies": {
48
- "@zero-server/realtime": "0.9.7",
49
- "@zero-server/auth": "0.9.7",
50
- "@zero-server/observe": "0.9.7",
51
- "@zero-server/errors": "0.9.7",
52
- "@zero-server/middleware": "0.9.7"
48
+ "@zero-server/realtime": "0.9.9",
49
+ "@zero-server/auth": "0.9.9",
50
+ "@zero-server/observe": "0.9.9",
51
+ "@zero-server/errors": "0.9.9",
52
+ "@zero-server/middleware": "0.9.9"
53
53
  },
54
54
  "peerDependencies": {
55
- "@zero-server/sdk": ">=0.9.7"
55
+ "@zero-server/sdk": ">=0.9.9"
56
56
  },
57
57
  "peerDependenciesMeta": {
58
58
  "@zero-server/sdk": {