@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.
- 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 +7 -7
- 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/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';
|
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
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:
|
|
60
|
-
* ttl:
|
|
61
|
-
* servers: [
|
|
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)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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:
|
|
21
|
-
* realm:
|
|
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:
|
|
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.
|
|
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.
|
|
49
|
-
"@zero-server/auth": "0.9.
|
|
50
|
-
"@zero-server/observe": "0.9.
|
|
51
|
-
"@zero-server/errors": "0.9.
|
|
52
|
-
"@zero-server/middleware": "0.9.
|
|
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.
|
|
55
|
+
"@zero-server/sdk": ">=0.9.9"
|
|
56
56
|
},
|
|
57
57
|
"peerDependenciesMeta": {
|
|
58
58
|
"@zero-server/sdk": {
|