@zero-server/webrtc 0.9.7 → 0.9.8
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 +54 -10
- 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/bot.js
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/bot
|
|
3
3
|
* @description Server-side WebRTC peer ("bot") built on the `wrtc`
|
|
4
|
-
* peerDependency.
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
* {@link RTCPeerConnection} per remote peer (using the Node.js `wrtc`
|
|
9
|
-
* binding). It implements the standard JSEP perfect-negotiation
|
|
10
|
-
* pattern and is designed for headless workloads such as recording,
|
|
11
|
-
* transcription, AI agents, and SFU verification harnesses.
|
|
12
|
-
*
|
|
13
|
-
* The `wrtc` peerDependency is loaded lazily; in production any of
|
|
14
|
-
* `wrtc` or `@roamhq/wrtc` is acceptable. Tests inject a fake via
|
|
15
|
-
* `opts.wrtc`.
|
|
4
|
+
* peerDependency. `spawnBotPeer({ hub, room, ... })` attaches an
|
|
5
|
+
* in-process peer that joins a room and drives a real
|
|
6
|
+
* `RTCPeerConnection` per remote peer. Bidirectional — use for
|
|
7
|
+
* recording, transcription, AI participants, or SFU verification.
|
|
16
8
|
*/
|
|
17
9
|
'use strict';
|
|
18
10
|
|
|
@@ -43,6 +35,58 @@ const { WebRTCError } = require('../errors');
|
|
|
43
35
|
* ready: Promise<{ peerId: string }>,
|
|
44
36
|
* close: () => void,
|
|
45
37
|
* }}
|
|
38
|
+
*
|
|
39
|
+
* @example | Recording bot: dump every inbound audio track to a file
|
|
40
|
+
* const { spawnBotPeer } = require('@zero-server/webrtc');
|
|
41
|
+
* const { RTCAudioSink } = require('@roamhq/wrtc').nonstandard;
|
|
42
|
+
* const fs = require('node:fs');
|
|
43
|
+
*
|
|
44
|
+
* const bot = spawnBotPeer({
|
|
45
|
+
* hub,
|
|
46
|
+
* room: 'standup',
|
|
47
|
+
* user: { id: 'recorder' },
|
|
48
|
+
* iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
|
49
|
+
* onTrack: (track, _streams, fromPeerId) => {
|
|
50
|
+
* if (track.kind !== 'audio') return;
|
|
51
|
+
* const sink = new RTCAudioSink(track);
|
|
52
|
+
* const out = fs.createWriteStream(`./recordings/${fromPeerId}.pcm`);
|
|
53
|
+
* sink.ondata = ({ samples }) => out.write(Buffer.from(samples.buffer));
|
|
54
|
+
* track.onended = () => { sink.stop(); out.end(); };
|
|
55
|
+
* },
|
|
56
|
+
* });
|
|
57
|
+
*
|
|
58
|
+
* await bot.ready; // resolves with { peerId } once the hub welcomes us
|
|
59
|
+
* process.on('SIGTERM', () => bot.close());
|
|
60
|
+
*
|
|
61
|
+
* @example | AI participant: push synthesized audio back to the room
|
|
62
|
+
* const { spawnBotPeer } = require('@zero-server/webrtc');
|
|
63
|
+
* const { RTCAudioSource } = require('@roamhq/wrtc').nonstandard;
|
|
64
|
+
*
|
|
65
|
+
* const source = new RTCAudioSource();
|
|
66
|
+
* const track = source.createTrack();
|
|
67
|
+
*
|
|
68
|
+
* const bot = spawnBotPeer({
|
|
69
|
+
* hub, room: 'lounge', user: { id: 'ai-host' },
|
|
70
|
+
* onPeerJoin: (remoteId) => {
|
|
71
|
+
* const pc = bot.getPeerConnection(remoteId);
|
|
72
|
+
* if (pc) pc.addTrack(track); // perfect-negotiation will renegotiate
|
|
73
|
+
* },
|
|
74
|
+
* });
|
|
75
|
+
*
|
|
76
|
+
* // Feed synthesized PCM frames every 10ms.
|
|
77
|
+
* setInterval(() => source.onData({
|
|
78
|
+
* samples: ttsNextFrame(), // Int16Array(160)
|
|
79
|
+
* sampleRate: 16000,
|
|
80
|
+
* bitsPerSample: 16,
|
|
81
|
+
* channelCount: 1,
|
|
82
|
+
* numberOfFrames: 160,
|
|
83
|
+
* }), 10);
|
|
84
|
+
*
|
|
85
|
+
* @example | Inject a fake `wrtc` for unit tests
|
|
86
|
+
* const bot = spawnBotPeer({ hub, room: 'test', wrtc: fakeWrtc });
|
|
87
|
+
* await bot.ready;
|
|
88
|
+
* expect(hub.room('test').size).toBe(1);
|
|
89
|
+
* bot.close();
|
|
46
90
|
*/
|
|
47
91
|
function spawnBotPeer(opts)
|
|
48
92
|
{
|
package/lib/webrtc/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/cli
|
|
3
|
-
* @description CLI subcommands for the `
|
|
3
|
+
* @description CLI subcommands for the `zs webrtc:*` namespace.
|
|
4
4
|
*
|
|
5
5
|
* Pure-function entry point `runWebRTCCommand(subcmd, flags, deps)` so
|
|
6
6
|
* the dispatch can be exercised in tests without spawning a child
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
12
12
|
* // From the shell, via the top-level CLI:
|
|
13
|
-
* // npx
|
|
14
|
-
* // npx
|
|
13
|
+
* // npx zs webrtc:stun --host stun.l.google.com --port 19302
|
|
14
|
+
* // npx zs webrtc:turn-creds --secret $SECRET --user alice \
|
|
15
15
|
* // --servers turn:turn.example.com:3478
|
|
16
|
-
* // npx
|
|
17
|
-
* // npx
|
|
16
|
+
* // npx zs webrtc:join-token --secret $JT_SECRET --room lobby --sub u1
|
|
17
|
+
* // npx zs webrtc:verify-token --secret $JT_SECRET --token $TOKEN
|
|
18
18
|
*
|
|
19
19
|
* // Programmatically:
|
|
20
20
|
* const { runWebRTCCommand } = require('@zero-server/webrtc/cli');
|
|
@@ -168,7 +168,7 @@ function runVerifyToken(flags, { out })
|
|
|
168
168
|
function helpText()
|
|
169
169
|
{
|
|
170
170
|
return [
|
|
171
|
-
'
|
|
171
|
+
'zs webrtc:* - WebRTC tooling',
|
|
172
172
|
'',
|
|
173
173
|
'Subcommands:',
|
|
174
174
|
' webrtc:stun --host H [--port 3478] [--timeout 1000] [--retries 1]',
|
package/lib/webrtc/cluster.js
CHANGED
|
@@ -1,22 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/cluster
|
|
3
|
-
* @description Cluster adapter for the
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - Every local `join` / `leave` is announced cluster-wide so other
|
|
10
|
-
* nodes can resolve a `peer.id` to its owning node.
|
|
11
|
-
* - Every `room.broadcast(...)` is mirrored to peers in the same room
|
|
12
|
-
* on other nodes.
|
|
13
|
-
* - Direct frames (`offer`, `answer`, `ice`) addressed to a peer that
|
|
14
|
-
* lives on a different node are forwarded to that node's inbox.
|
|
15
|
-
*
|
|
16
|
-
* The adapter itself is intentionally tiny so production deployments
|
|
17
|
-
* can wire it up to Redis, NATS, Kafka, or any in-house bus. A
|
|
18
|
-
* `MemoryClusterAdapter` is provided for tests and single-process
|
|
19
|
-
* simulations.
|
|
3
|
+
* @description Cluster adapter for the signaling hub. `useCluster(hub,
|
|
4
|
+
* adapter)` glues a `SignalingHub` to any `{ publish, subscribe }`
|
|
5
|
+
* pub/sub bus so joins, leaves, broadcasts, and direct frames flow
|
|
6
|
+
* across nodes. Ships with `MemoryClusterAdapter`; wire to Redis, NATS,
|
|
7
|
+
* Kafka, or any in-house bus in production.
|
|
20
8
|
*
|
|
21
9
|
* @section Cluster
|
|
22
10
|
*/
|
package/lib/webrtc/e2ee.js
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/e2ee
|
|
3
|
-
* @description End-to-end-encrypted key relay
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* wire message; subscribers in the same room receive the sealed
|
|
9
|
-
* payload and decrypt locally with their private key.
|
|
10
|
-
*
|
|
11
|
-
* For deployments that use a different sealing primitive (NaCl
|
|
12
|
-
* `crypto_box_seal`, libsignal, etc.) the {@link E2eeChannel} works
|
|
13
|
-
* with any opaque `Buffer` - the {@link sealKey} / {@link openSealedKey}
|
|
14
|
-
* helpers are provided as a zero-dependency default that satisfies the
|
|
15
|
-
* HIPAA / FINRA "server is opaque" requirement.
|
|
3
|
+
* @description End-to-end-encrypted key relay for WebRTC. Publishers wrap
|
|
4
|
+
* SFrame/Insertable-Streams keys in sealed envelopes (X25519 + HKDF-SHA-256
|
|
5
|
+
* + AES-256-GCM) and broadcast via `e2ee-key`; the hub stays opaque.
|
|
6
|
+
* `E2eeChannel` accepts any opaque `Buffer`, so libsignal or NaCl-sealed
|
|
7
|
+
* payloads work out of the box.
|
|
16
8
|
*
|
|
17
9
|
* @section E2EE
|
|
18
10
|
*/
|
package/lib/webrtc/ice.js
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/ice
|
|
3
3
|
* @description Zero-dependency ICE candidate parser, serializer, and address
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Candidate grammar follows RFC 8839 §5.1 / RFC 5245 §15.1:
|
|
9
|
-
*
|
|
10
|
-
* candidate-attribute = "candidate" ":" foundation SP component-id
|
|
11
|
-
* SP transport SP priority SP connection-address
|
|
12
|
-
* SP port SP cand-type [SP rel-addr] [SP rel-port]
|
|
13
|
-
* *(SP extension-att-name SP extension-att-value)
|
|
4
|
+
* classifiers (private / loopback / link-local / mDNS) per RFC 8839,
|
|
5
|
+
* plus a `filterCandidates` helper used by `SignalingHub` to enforce
|
|
6
|
+
* privacy-preserving policies on relayed offers/answers.
|
|
14
7
|
*
|
|
15
8
|
* @see https://datatracker.ietf.org/doc/html/rfc8839
|
|
16
9
|
* @see https://datatracker.ietf.org/doc/html/rfc5245
|
package/lib/webrtc/index.js
CHANGED
|
@@ -1,14 +1,91 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module @zero-server/webrtc
|
|
3
|
-
* @description First-class WebRTC support for Zero Server.
|
|
3
|
+
* @description First-class, batteries-included WebRTC support for Zero Server.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* `@zero-server/webrtc` is a complete signaling + NAT-traversal toolkit you
|
|
6
|
+
* can drop into any Zero Server app. Everything is pure JavaScript with
|
|
7
|
+
* zero hard dependencies — bring your own media engine (`wrtc`, browsers,
|
|
8
|
+
* `mediasoup`, LiveKit, ...) and the library handles the rest.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Surface area:
|
|
11
|
+
*
|
|
12
|
+
* - **Signaling** — {@link SignalingHub}, {@link Room}, {@link Peer}. A
|
|
13
|
+
* transport-agnostic WS broker that owns the room registry, validates
|
|
14
|
+
* JSEP traffic (offer / answer / ICE / `e2ee-key`), enforces per-peer
|
|
15
|
+
* and per-IP rate limits, supports policy gates (`require()`,
|
|
16
|
+
* `canPublish()`), and emits lifecycle events.
|
|
17
|
+
* - **JSEP parsing** — {@link parseSdp}, {@link stringifySdp},
|
|
18
|
+
* {@link parseCandidate}, {@link stringifyCandidate},
|
|
19
|
+
* {@link filterCandidates}. RFC 8866 / 8839 compliant pure-JS codecs.
|
|
20
|
+
* - **STUN client** — {@link stunBinding} (RFC 5389 / 8489) for public-IP
|
|
21
|
+
* discovery, plus low-level attribute encoders.
|
|
22
|
+
* - **TURN** — {@link issueTurnCredentials} (RFC 7635 ephemeral creds) and
|
|
23
|
+
* a full embedded {@link TurnServer} that speaks STUN/TURN over UDP.
|
|
24
|
+
* - **Join tokens** — {@link signJoinToken} / {@link verifyJoinToken}: HS256
|
|
25
|
+
* JWTs scoped to `room:<name>` with publish / subscribe claims.
|
|
26
|
+
* - **End-to-end encryption** — {@link E2eeChannel}, {@link attachE2ee},
|
|
27
|
+
* {@link generateE2eeKeyPair}, {@link sealKey}, {@link openSealedKey}.
|
|
28
|
+
* SFrame-compatible key-relay primitives; the hub never sees media.
|
|
29
|
+
* - **SFU adapters** — {@link SfuAdapter} interface plus first-party
|
|
30
|
+
* {@link MemorySfuAdapter} (tests), {@link MediasoupSfuAdapter},
|
|
31
|
+
* {@link LiveKitSfuAdapter}. Pluggable via {@link loadSfuAdapter}.
|
|
32
|
+
* - **Cluster** — {@link useCluster}, {@link ClusterCoordinator}, and the
|
|
33
|
+
* {@link MemoryClusterAdapter} so multiple hub instances can share a
|
|
34
|
+
* room registry behind a load balancer.
|
|
35
|
+
* - **Server-side peer** — {@link spawnBotPeer} for headless recorders,
|
|
36
|
+
* transcribers, or AI participants using `node-wrtc`.
|
|
37
|
+
* - **Observability** — {@link bindObservability} wires Prometheus
|
|
38
|
+
* counters / histograms and structured logs into a hub.
|
|
39
|
+
* - **CLI** — {@link runWebRTCCommand} powers `zs webrtc:*` for STUN
|
|
40
|
+
* probes, TURN credential issuance, and join-token sign / verify.
|
|
41
|
+
*
|
|
42
|
+
* @example | Bind a signaling hub to a Zero Server `app.ws()` route
|
|
43
|
+
* const { createApp } = require('@zero-server/sdk');
|
|
44
|
+
* const { SignalingHub, bindObservability } = require('@zero-server/webrtc');
|
|
45
|
+
*
|
|
46
|
+
* const app = createApp();
|
|
47
|
+
* const hub = new SignalingHub({
|
|
48
|
+
* joinTokenSecret: process.env.WEBRTC_JWT_SECRET,
|
|
49
|
+
* ipAttachRate: 60, // max 60 attaches / IP / min
|
|
50
|
+
* maxSdpSize: 64 * 1024,
|
|
51
|
+
* });
|
|
52
|
+
*
|
|
53
|
+
* bindObservability(hub, { app }); // exposes /metrics for Prometheus
|
|
54
|
+
*
|
|
55
|
+
* app.ws('/rtc', (ws, req) =>
|
|
56
|
+
* {
|
|
57
|
+
* const peer = hub.attach(ws, {
|
|
58
|
+
* user: req.user,
|
|
59
|
+
* ip: req.ip,
|
|
60
|
+
* origin: req.headers.origin,
|
|
61
|
+
* });
|
|
62
|
+
* ws.on('close', () => peer.close());
|
|
63
|
+
* });
|
|
64
|
+
*
|
|
65
|
+
* app.listen(3000);
|
|
66
|
+
*
|
|
67
|
+
* @example | Issue a join token and a TURN credential to a browser
|
|
68
|
+
* const {
|
|
69
|
+
* signJoinToken, issueTurnCredentials,
|
|
70
|
+
* } = require('@zero-server/webrtc');
|
|
71
|
+
*
|
|
72
|
+
* app.get('/rtc/session/:room', (req, res) =>
|
|
73
|
+
* {
|
|
74
|
+
* const token = signJoinToken({
|
|
75
|
+
* secret: process.env.WEBRTC_JWT_SECRET,
|
|
76
|
+
* room: req.params.room,
|
|
77
|
+
* userId: req.user.id,
|
|
78
|
+
* publish: req.user.isHost,
|
|
79
|
+
* ttlSec: 60 * 30,
|
|
80
|
+
* });
|
|
81
|
+
* const turn = issueTurnCredentials({
|
|
82
|
+
* secret: process.env.TURN_SHARED_SECRET,
|
|
83
|
+
* userId: req.user.id,
|
|
84
|
+
* ttlSec: 60 * 60,
|
|
85
|
+
* uris: ['turn:turn.example.com:3478?transport=udp'],
|
|
86
|
+
* });
|
|
87
|
+
* res.json({ token, iceServers: turn.iceServers });
|
|
88
|
+
* });
|
|
12
89
|
*/
|
|
13
90
|
|
|
14
91
|
'use strict';
|
|
@@ -49,30 +126,31 @@ const { spawnBotPeer } = require('./bot');
|
|
|
49
126
|
|
|
50
127
|
/**
|
|
51
128
|
* @private
|
|
52
|
-
* Sentinel for surfaces that
|
|
53
|
-
*
|
|
129
|
+
* Sentinel for surfaces that are intentionally not exported yet. Reserved
|
|
130
|
+
* for future top-level shortcuts; current consumers should construct a
|
|
131
|
+
* `SignalingHub` directly and use `bindObservability(hub, { app })`.
|
|
54
132
|
*/
|
|
55
133
|
const notImplemented = (name) =>
|
|
56
134
|
{
|
|
57
135
|
throw new WebRTCError(
|
|
58
|
-
`${name} is not implemented yet -
|
|
136
|
+
`${name} is not implemented yet - construct \`new SignalingHub(opts)\` directly and wire it via \`app.ws()\`.`,
|
|
59
137
|
{ code: 'WEBRTC_NOT_IMPLEMENTED' },
|
|
60
138
|
);
|
|
61
139
|
};
|
|
62
140
|
|
|
63
141
|
module.exports = {
|
|
64
|
-
// Signaling
|
|
142
|
+
// Signaling
|
|
65
143
|
createWebRTC: () => notImplemented('createWebRTC'),
|
|
66
144
|
SignalingHub,
|
|
67
145
|
Room,
|
|
68
146
|
Peer,
|
|
69
147
|
PEER_STATE,
|
|
70
148
|
|
|
71
|
-
// SDP
|
|
149
|
+
// SDP / JSEP
|
|
72
150
|
parseSdp,
|
|
73
151
|
stringifySdp,
|
|
74
152
|
|
|
75
|
-
// ICE
|
|
153
|
+
// ICE candidate utilities
|
|
76
154
|
parseCandidate,
|
|
77
155
|
stringifyCandidate,
|
|
78
156
|
filterCandidates,
|
|
@@ -83,7 +161,7 @@ module.exports = {
|
|
|
83
161
|
CANDIDATE_TYPES,
|
|
84
162
|
TCP_TYPES,
|
|
85
163
|
|
|
86
|
-
//
|
|
164
|
+
// STUN client + low-level codecs
|
|
87
165
|
stunBinding,
|
|
88
166
|
encodeBindingRequest,
|
|
89
167
|
decodeMessage,
|
|
@@ -93,10 +171,12 @@ module.exports = {
|
|
|
93
171
|
STUN_METHOD,
|
|
94
172
|
STUN_CLASS,
|
|
95
173
|
STUN_ATTR,
|
|
174
|
+
|
|
175
|
+
// TURN
|
|
96
176
|
issueTurnCredentials,
|
|
97
177
|
TurnServer,
|
|
98
178
|
|
|
99
|
-
// SFU + tokens
|
|
179
|
+
// SFU adapters + tokens
|
|
100
180
|
SfuAdapter,
|
|
101
181
|
MemorySfuAdapter,
|
|
102
182
|
MediasoupSfuAdapter,
|
|
@@ -105,20 +185,20 @@ module.exports = {
|
|
|
105
185
|
signJoinToken,
|
|
106
186
|
verifyJoinToken,
|
|
107
187
|
|
|
108
|
-
// Server-side WebRTC peer (wrtc bot)
|
|
188
|
+
// Server-side WebRTC peer (node-wrtc bot)
|
|
109
189
|
spawnBotPeer,
|
|
110
190
|
|
|
111
|
-
// Observability
|
|
191
|
+
// Observability
|
|
112
192
|
bindObservability,
|
|
113
193
|
|
|
114
|
-
// E2EE key relay
|
|
194
|
+
// SFrame E2EE key relay
|
|
115
195
|
E2eeChannel,
|
|
116
196
|
attachE2ee,
|
|
117
197
|
generateE2eeKeyPair,
|
|
118
198
|
sealKey,
|
|
119
199
|
openSealedKey,
|
|
120
200
|
|
|
121
|
-
// Cluster
|
|
201
|
+
// Cluster coordination
|
|
122
202
|
useCluster,
|
|
123
203
|
ClusterCoordinator,
|
|
124
204
|
MemoryClusterAdapter,
|
package/lib/webrtc/joinToken.js
CHANGED
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module webrtc/joinToken
|
|
3
|
-
* @description Signed, short-TTL join tokens
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* @description Signed, short-TTL JWT join tokens scoped to `room:<name>`.
|
|
4
|
+
* HS256 by default, RS256 supported. Verification is constant-time and
|
|
5
|
+
* surfaces every failure mode as `WebRTCError({ code: 'INVALID_TOKEN' })`.
|
|
6
|
+
* The hub auto-enforces tokens when constructed with `joinTokenSecret`.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* @example | Browser receives a token from a regular HTTP route
|
|
9
|
+
* // server.js
|
|
10
|
+
* const { signJoinToken } = require('@zero-server/webrtc');
|
|
11
|
+
* app.get('/rtc/token/:room', (req, res) => {
|
|
12
|
+
* const token = signJoinToken({
|
|
13
|
+
* secret: process.env.WEBRTC_JWT_SECRET,
|
|
14
|
+
* user: req.user, // { id, name, role }
|
|
15
|
+
* room: req.params.room,
|
|
16
|
+
* ttl: 300, // 5 minute window
|
|
17
|
+
* claims: { publish: req.user.isHost === true },
|
|
18
|
+
* });
|
|
19
|
+
* res.json({ wsUrl: '/rtc', token });
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* // client.js (pseudo)
|
|
23
|
+
* const { wsUrl, token } = await fetch(`/rtc/token/${room}`).then(r => r.json());
|
|
24
|
+
* ws = new WebSocket(wsUrl);
|
|
25
|
+
* ws.onopen = () => ws.send(JSON.stringify({ type: 'join', room, token }));
|
|
9
26
|
*/
|
|
10
27
|
|
|
11
28
|
'use strict';
|
|
@@ -27,15 +44,34 @@ const { SignalingError, WebRTCError } = require('../errors');
|
|
|
27
44
|
* @param {object} [opts.claims] - Additional claims merged into the payload.
|
|
28
45
|
* @returns {string} Compact JWT.
|
|
29
46
|
*
|
|
30
|
-
* @example
|
|
47
|
+
* @example | Simple HS256 token with a user object
|
|
31
48
|
* const token = signJoinToken({
|
|
32
49
|
* secret: process.env.JOIN_SECRET,
|
|
33
|
-
* user: req.user,
|
|
50
|
+
* user: req.user, // { id: 'u_42', name: 'Ada', role: 'host' }
|
|
34
51
|
* room: 'boardroom',
|
|
35
52
|
* ttl: 300,
|
|
36
53
|
* });
|
|
37
54
|
* res.json({ wsUrl: '/rtc', token });
|
|
38
55
|
*
|
|
56
|
+
* @example | Embed publish / subscribe permissions as custom claims
|
|
57
|
+
* const token = signJoinToken({
|
|
58
|
+
* secret: process.env.JOIN_SECRET,
|
|
59
|
+
* user: { id: 'guest_' + crypto.randomUUID() },
|
|
60
|
+
* room: 'webinar-42',
|
|
61
|
+
* ttl: 60 * 30, // 30 minute viewer session
|
|
62
|
+
* claims: { publish: false, subscribe: true, tier: 'free' },
|
|
63
|
+
* });
|
|
64
|
+
*
|
|
65
|
+
* @example | RS256 with a per-tenant key id
|
|
66
|
+
* const token = signJoinToken({
|
|
67
|
+
* secret: fs.readFileSync('./keys/tenant-A.private.pem'),
|
|
68
|
+
* algorithm: 'RS256',
|
|
69
|
+
* user: req.user,
|
|
70
|
+
* room: 'tenantA:lobby',
|
|
71
|
+
* ttl: 300,
|
|
72
|
+
* claims: { kid: 'tenantA-2025-01' },
|
|
73
|
+
* });
|
|
74
|
+
*
|
|
39
75
|
* @section Signaling
|
|
40
76
|
*/
|
|
41
77
|
function signJoinToken(opts = {})
|
|
@@ -80,6 +116,25 @@ function signJoinToken(opts = {})
|
|
|
80
116
|
* @param {number} [opts.clockTolerance=0]
|
|
81
117
|
* @returns {object} Verified payload.
|
|
82
118
|
*
|
|
119
|
+
* @example | Manually verify a token (most apps never need this — the hub does it)
|
|
120
|
+
* try {
|
|
121
|
+
* const payload = verifyJoinToken(req.body.token, {
|
|
122
|
+
* secret: process.env.WEBRTC_JWT_SECRET,
|
|
123
|
+
* room: 'boardroom',
|
|
124
|
+
* });
|
|
125
|
+
* console.log('peer is', payload.user.id, 'publish =', payload.publish);
|
|
126
|
+
* } catch (err) {
|
|
127
|
+
* // err.code === 'INVALID_TOKEN'
|
|
128
|
+
* res.status(401).json({ error: err.message });
|
|
129
|
+
* }
|
|
130
|
+
*
|
|
131
|
+
* @example | Allow a 30-second clock skew between issuer and verifier
|
|
132
|
+
* const payload = verifyJoinToken(token, {
|
|
133
|
+
* secret: sharedSecret,
|
|
134
|
+
* room: 'lobby',
|
|
135
|
+
* clockTolerance: 30,
|
|
136
|
+
* });
|
|
137
|
+
*
|
|
83
138
|
* @section Signaling
|
|
84
139
|
*/
|
|
85
140
|
function verifyJoinToken(token, opts = {})
|
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
|