@zero-server/sdk 0.9.6 → 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/README.md +54 -53
- package/index.js +116 -4
- package/lib/app.js +22 -22
- package/lib/auth/authorize.js +11 -11
- package/lib/auth/enrollment.js +5 -5
- package/lib/auth/jwt.js +9 -9
- package/lib/auth/oauth.js +1 -1
- package/lib/auth/session.js +5 -5
- package/lib/auth/trustedDevice.js +2 -2
- package/lib/auth/twoFactor.js +11 -11
- package/lib/auth/webauthn.js +6 -6
- package/lib/body/json.js +1 -1
- package/lib/body/raw.js +1 -1
- package/lib/body/rawBuffer.js +1 -1
- package/lib/body/text.js +1 -1
- package/lib/body/urlencoded.js +3 -3
- package/lib/cli.js +43 -28
- package/lib/cluster.js +3 -3
- package/lib/debug.js +10 -10
- package/lib/env/index.js +11 -11
- package/lib/errors.js +131 -16
- package/lib/fetch/index.js +1 -1
- package/lib/grpc/call.js +14 -14
- package/lib/grpc/client.js +4 -4
- package/lib/grpc/codec.js +7 -7
- package/lib/grpc/credentials.js +2 -2
- package/lib/grpc/frame.js +2 -2
- package/lib/grpc/health.js +3 -3
- package/lib/grpc/index.js +3 -3
- package/lib/grpc/metadata.js +3 -3
- package/lib/grpc/proto.js +5 -5
- package/lib/grpc/reflection.js +2 -2
- package/lib/grpc/server.js +3 -3
- package/lib/grpc/status.js +2 -2
- package/lib/grpc/watch.js +1 -1
- package/lib/http/request.js +13 -13
- package/lib/http/response.js +2 -2
- package/lib/lifecycle.js +5 -5
- package/lib/middleware/compress.js +4 -4
- package/lib/observe/health.js +1 -1
- package/lib/observe/index.js +1 -1
- package/lib/observe/logger.js +3 -3
- package/lib/observe/metrics.js +4 -4
- package/lib/observe/tracing.js +4 -4
- package/lib/orm/adapters/json.js +1 -1
- package/lib/orm/adapters/memory.js +2 -2
- package/lib/orm/adapters/mongo.js +2 -2
- package/lib/orm/adapters/mysql.js +2 -2
- package/lib/orm/adapters/postgres.js +2 -2
- package/lib/orm/adapters/sqlite.js +3 -3
- package/lib/orm/audit.js +1 -1
- package/lib/orm/index.js +7 -7
- package/lib/orm/migrate.js +1 -1
- package/lib/orm/model.js +15 -15
- package/lib/orm/procedures.js +1 -1
- package/lib/orm/profiler.js +1 -1
- package/lib/orm/query.js +9 -9
- package/lib/orm/schema.js +1 -1
- package/lib/orm/seed/data/person.js +1 -1
- package/lib/orm/seed/fake.js +10 -10
- package/lib/orm/seed/index.js +4 -4
- package/lib/orm/seed/rng.js +1 -1
- package/lib/orm/snapshot.js +3 -3
- package/lib/orm/tenancy.js +6 -6
- package/lib/orm/views.js +1 -1
- package/lib/router/index.js +9 -9
- package/lib/webrtc/bot.js +405 -0
- package/lib/webrtc/cli.js +182 -0
- package/lib/webrtc/cluster.js +338 -0
- package/lib/webrtc/e2ee.js +274 -0
- package/lib/webrtc/ice.js +363 -0
- package/lib/webrtc/index.js +212 -0
- package/lib/webrtc/joinToken.js +171 -0
- package/lib/webrtc/observe.js +260 -0
- package/lib/webrtc/peer.js +143 -0
- package/lib/webrtc/room.js +184 -0
- package/lib/webrtc/sdp.js +503 -0
- package/lib/webrtc/sfu/index.js +251 -0
- package/lib/webrtc/sfu/livekit.js +304 -0
- package/lib/webrtc/sfu/mediasoup.js +357 -0
- package/lib/webrtc/sfu/memory.js +221 -0
- package/lib/webrtc/signaling.js +590 -0
- package/lib/webrtc/stun.js +484 -0
- package/lib/webrtc/turn/codec.js +370 -0
- package/lib/webrtc/turn/credentials.js +156 -0
- package/lib/webrtc/turn/server.js +648 -0
- package/package.json +2 -2
- package/types/body.d.ts +82 -14
- package/types/cli.d.ts +40 -2
- package/types/index.d.ts +19 -6
- package/types/middleware.d.ts +18 -72
- package/types/orm.d.ts +4 -13
- package/types/request.d.ts +3 -3
- package/types/webrtc.d.ts +501 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/joinToken
|
|
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
|
+
*
|
|
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 }));
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const { sign, verify } = require('../auth');
|
|
31
|
+
const { SignalingError, WebRTCError } = require('../errors');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Issue a join token for `user` to enter `room`.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {string|Buffer} opts.secret - HMAC secret (HS256) or PEM key (RS256).
|
|
38
|
+
* @param {string|object} opts.user - User identifier (string) or object containing `id`.
|
|
39
|
+
* @param {string} opts.room - Target room name.
|
|
40
|
+
* @param {number} [opts.ttl=300] - Seconds until expiry. Negative values are accepted
|
|
41
|
+
* (used by tests to mint already-expired tokens).
|
|
42
|
+
* @param {string} [opts.algorithm='HS256']
|
|
43
|
+
* @param {string|string[]} [opts.audience] - Override the default `room:<name>` audience.
|
|
44
|
+
* @param {object} [opts.claims] - Additional claims merged into the payload.
|
|
45
|
+
* @returns {string} Compact JWT.
|
|
46
|
+
*
|
|
47
|
+
* @example | Simple HS256 token with a user object
|
|
48
|
+
* const token = signJoinToken({
|
|
49
|
+
* secret: process.env.JOIN_SECRET,
|
|
50
|
+
* user: req.user, // { id: 'u_42', name: 'Ada', role: 'host' }
|
|
51
|
+
* room: 'boardroom',
|
|
52
|
+
* ttl: 300,
|
|
53
|
+
* });
|
|
54
|
+
* res.json({ wsUrl: '/rtc', token });
|
|
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
|
+
*
|
|
75
|
+
* @section Signaling
|
|
76
|
+
*/
|
|
77
|
+
function signJoinToken(opts = {})
|
|
78
|
+
{
|
|
79
|
+
if (!opts || typeof opts !== 'object')
|
|
80
|
+
throw new SignalingError('signJoinToken: opts must be an object');
|
|
81
|
+
if (!opts.secret) throw new SignalingError('signJoinToken: secret is required');
|
|
82
|
+
if (opts.user === undefined || opts.user === null)
|
|
83
|
+
throw new SignalingError('signJoinToken: user is required');
|
|
84
|
+
if (typeof opts.room !== 'string' || opts.room.length === 0)
|
|
85
|
+
throw new SignalingError('signJoinToken: room is required');
|
|
86
|
+
|
|
87
|
+
const ttl = Number.isFinite(opts.ttl) ? opts.ttl : 300;
|
|
88
|
+
const sub = typeof opts.user === 'string' ? opts.user
|
|
89
|
+
: (opts.user && (opts.user.id || opts.user.userId || opts.user.sub));
|
|
90
|
+
if (!sub) throw new SignalingError('signJoinToken: user.id is required');
|
|
91
|
+
|
|
92
|
+
const payload = Object.assign({}, opts.claims || {}, {
|
|
93
|
+
room: opts.room,
|
|
94
|
+
user: typeof opts.user === 'object' ? opts.user : { id: sub },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return sign(payload, opts.secret, {
|
|
98
|
+
algorithm: opts.algorithm || 'HS256',
|
|
99
|
+
expiresIn: ttl,
|
|
100
|
+
subject: String(sub),
|
|
101
|
+
audience: opts.audience || ('room:' + opts.room),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Verify a join token and return its payload. Throws a `WebRTCError` with
|
|
107
|
+
* `code: 'INVALID_TOKEN'` on any failure - bad signature, expired, audience
|
|
108
|
+
* mismatch, malformed, etc.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} token
|
|
111
|
+
* @param {object} opts
|
|
112
|
+
* @param {string|Buffer} opts.secret
|
|
113
|
+
* @param {string} [opts.room] - If supplied, audience must be `room:<room>`.
|
|
114
|
+
* @param {string|string[]} [opts.audience] - Explicit audience override.
|
|
115
|
+
* @param {string|string[]} [opts.algorithms=['HS256']]
|
|
116
|
+
* @param {number} [opts.clockTolerance=0]
|
|
117
|
+
* @returns {object} Verified payload.
|
|
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
|
+
*
|
|
138
|
+
* @section Signaling
|
|
139
|
+
*/
|
|
140
|
+
function verifyJoinToken(token, opts = {})
|
|
141
|
+
{
|
|
142
|
+
if (!opts || typeof opts !== 'object')
|
|
143
|
+
throw new WebRTCError('verifyJoinToken: opts must be an object', { code: 'INVALID_TOKEN' });
|
|
144
|
+
if (!opts.secret)
|
|
145
|
+
throw new WebRTCError('verifyJoinToken: secret is required', { code: 'INVALID_TOKEN' });
|
|
146
|
+
if (typeof token !== 'string' || token.length === 0)
|
|
147
|
+
throw new WebRTCError('verifyJoinToken: token must be a non-empty string', { code: 'INVALID_TOKEN' });
|
|
148
|
+
|
|
149
|
+
const audience = opts.audience || (opts.room ? 'room:' + opts.room : undefined);
|
|
150
|
+
try
|
|
151
|
+
{
|
|
152
|
+
const { payload } = verify(token, opts.secret, {
|
|
153
|
+
algorithms: opts.algorithms || ['HS256'],
|
|
154
|
+
audience,
|
|
155
|
+
clockTolerance: opts.clockTolerance || 0,
|
|
156
|
+
});
|
|
157
|
+
if (opts.room && payload.room && payload.room !== opts.room)
|
|
158
|
+
throw new WebRTCError('verifyJoinToken: room claim mismatch', { code: 'INVALID_TOKEN' });
|
|
159
|
+
return payload;
|
|
160
|
+
}
|
|
161
|
+
catch (err)
|
|
162
|
+
{
|
|
163
|
+
if (err instanceof WebRTCError) throw err;
|
|
164
|
+
throw new WebRTCError(
|
|
165
|
+
'verifyJoinToken: ' + (err && err.message ? err.message : 'invalid token'),
|
|
166
|
+
{ code: 'INVALID_TOKEN', cause: err && err.code ? err.code : undefined },
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { signJoinToken, verifyJoinToken };
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/observe
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
// --- Helpers ---
|
|
12
|
+
|
|
13
|
+
/** Extract the first `a=ice-ufrag:` value from an SDP blob. */
|
|
14
|
+
function _extractUfrag(sdp)
|
|
15
|
+
{
|
|
16
|
+
const m = /^a=ice-ufrag:([^\r\n]+)/m.exec(sdp);
|
|
17
|
+
return m ? m[1].trim() : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// --- Metrics binder ---
|
|
21
|
+
|
|
22
|
+
function _registerMetrics(registry)
|
|
23
|
+
{
|
|
24
|
+
return {
|
|
25
|
+
peersActive: registry.gauge({
|
|
26
|
+
name: 'zs_webrtc_peers_active',
|
|
27
|
+
help: 'Number of WebRTC peers currently joined per room.',
|
|
28
|
+
labels: ['room'],
|
|
29
|
+
}),
|
|
30
|
+
roomsActive: registry.gauge({
|
|
31
|
+
name: 'zs_webrtc_rooms_active',
|
|
32
|
+
help: 'Number of WebRTC rooms with at least one peer.',
|
|
33
|
+
}),
|
|
34
|
+
signalingMessages: registry.counter({
|
|
35
|
+
name: 'zs_webrtc_signaling_messages_total',
|
|
36
|
+
help: 'WebRTC signaling messages by type, direction, and outcome.',
|
|
37
|
+
labels: ['type', 'direction', 'result'],
|
|
38
|
+
}),
|
|
39
|
+
offerDuration: registry.histogram({
|
|
40
|
+
name: 'zs_webrtc_offer_duration_ms',
|
|
41
|
+
help: 'End-to-end latency between an offer and its matching answer, in ms.',
|
|
42
|
+
labels: ['room'],
|
|
43
|
+
buckets: [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000],
|
|
44
|
+
}),
|
|
45
|
+
joinFailures: registry.counter({
|
|
46
|
+
name: 'zs_webrtc_join_failures_total',
|
|
47
|
+
help: 'WebRTC room joins rejected by the hub.',
|
|
48
|
+
labels: ['reason'],
|
|
49
|
+
}),
|
|
50
|
+
iceRestart: registry.counter({
|
|
51
|
+
name: 'zs_webrtc_ice_restart_total',
|
|
52
|
+
help: 'Detected ICE restarts (ufrag rotation) per room.',
|
|
53
|
+
labels: ['room'],
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function _bindMetrics(hub, m)
|
|
59
|
+
{
|
|
60
|
+
/** Outstanding offer timestamps: `offererPeerId` -> ms epoch. */
|
|
61
|
+
const offerStart = new Map();
|
|
62
|
+
/** Last seen ufrag per peer.id (for ICE-restart detection). */
|
|
63
|
+
const lastUfrag = new Map();
|
|
64
|
+
|
|
65
|
+
hub.on('signal', ({ peer, type }) =>
|
|
66
|
+
{
|
|
67
|
+
m.signalingMessages.inc({ type, direction: 'in', result: 'ok' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
hub.on('join', ({ peer, room }) =>
|
|
71
|
+
{
|
|
72
|
+
const before = m.peersActive.get({ room: room.name });
|
|
73
|
+
m.peersActive.inc({ room: room.name });
|
|
74
|
+
// Room newly non-empty?
|
|
75
|
+
if (before === 0) m.roomsActive.inc();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
hub.on('leave', ({ peer, room }) =>
|
|
79
|
+
{
|
|
80
|
+
m.peersActive.dec({ room: room.name });
|
|
81
|
+
if (m.peersActive.get({ room: room.name }) <= 0)
|
|
82
|
+
m.roomsActive.dec();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
hub.on('joinFailed', ({ reason }) =>
|
|
86
|
+
{
|
|
87
|
+
m.joinFailures.inc({ reason: reason || 'UNKNOWN' });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
hub.on('offer', ({ peer, sdp, room }) =>
|
|
91
|
+
{
|
|
92
|
+
offerStart.set(peer.id, Date.now());
|
|
93
|
+
|
|
94
|
+
// ICE-restart detection: compare ufrag against last-known for this peer.
|
|
95
|
+
const ufrag = _extractUfrag(sdp);
|
|
96
|
+
if (ufrag)
|
|
97
|
+
{
|
|
98
|
+
const prev = lastUfrag.get(peer.id);
|
|
99
|
+
if (prev && prev !== ufrag)
|
|
100
|
+
m.iceRestart.inc({ room: room.name });
|
|
101
|
+
lastUfrag.set(peer.id, ufrag);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
hub.on('answer', ({ peer, target, room }) =>
|
|
106
|
+
{
|
|
107
|
+
// The offer was sent by the original target (now answering back to it).
|
|
108
|
+
const startedAt = offerStart.get(target.id);
|
|
109
|
+
if (startedAt !== undefined)
|
|
110
|
+
{
|
|
111
|
+
m.offerDuration.observe({ room: room.name }, Date.now() - startedAt);
|
|
112
|
+
offerStart.delete(target.id);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Tracing binder ---
|
|
118
|
+
|
|
119
|
+
function _bindTracing(hub, tracer)
|
|
120
|
+
{
|
|
121
|
+
hub.on('signal', ({ peer, type }) =>
|
|
122
|
+
{
|
|
123
|
+
const span = tracer.startSpan('webrtc.signal', {
|
|
124
|
+
kind: 'server',
|
|
125
|
+
attributes: { 'peer.id': peer.id, 'rtc.type': type },
|
|
126
|
+
});
|
|
127
|
+
span.setOk();
|
|
128
|
+
span.end();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
hub.on('join', ({ peer, room }) =>
|
|
132
|
+
{
|
|
133
|
+
const span = tracer.startSpan('webrtc.join', {
|
|
134
|
+
kind: 'server',
|
|
135
|
+
attributes: { 'peer.id': peer.id, 'room.id': room.name },
|
|
136
|
+
});
|
|
137
|
+
span.setOk();
|
|
138
|
+
span.end();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
hub.on('joinFailed', ({ peer, reason, room }) =>
|
|
142
|
+
{
|
|
143
|
+
const span = tracer.startSpan('webrtc.join', {
|
|
144
|
+
kind: 'server',
|
|
145
|
+
attributes: { 'peer.id': peer.id, 'room.id': room || '', 'rtc.error': reason },
|
|
146
|
+
});
|
|
147
|
+
span.setError(reason);
|
|
148
|
+
span.end();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
hub.on('offer', ({ peer, target, room }) =>
|
|
152
|
+
{
|
|
153
|
+
const span = tracer.startSpan('webrtc.publish', {
|
|
154
|
+
kind: 'producer',
|
|
155
|
+
attributes: { 'peer.id': peer.id, 'room.id': room.name, 'rtc.target': target.id },
|
|
156
|
+
});
|
|
157
|
+
span.setOk();
|
|
158
|
+
span.end();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
hub.on('publishFailed', ({ peer, reason, room }) =>
|
|
162
|
+
{
|
|
163
|
+
const span = tracer.startSpan('webrtc.publish', {
|
|
164
|
+
kind: 'producer',
|
|
165
|
+
attributes: { 'peer.id': peer.id, 'room.id': room || '', 'rtc.error': reason },
|
|
166
|
+
});
|
|
167
|
+
span.setError(reason);
|
|
168
|
+
span.end();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
hub.on('answer', ({ peer, target, room }) =>
|
|
172
|
+
{
|
|
173
|
+
const span = tracer.startSpan('webrtc.subscribe', {
|
|
174
|
+
kind: 'consumer',
|
|
175
|
+
attributes: { 'peer.id': peer.id, 'room.id': room.name, 'rtc.target': target.id },
|
|
176
|
+
});
|
|
177
|
+
span.setOk();
|
|
178
|
+
span.end();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
hub.on('subscribeFailed', ({ peer, reason, room }) =>
|
|
182
|
+
{
|
|
183
|
+
const span = tracer.startSpan('webrtc.subscribe', {
|
|
184
|
+
kind: 'consumer',
|
|
185
|
+
attributes: { 'peer.id': peer.id, 'room.id': room || '', 'rtc.error': reason },
|
|
186
|
+
});
|
|
187
|
+
span.setError(reason);
|
|
188
|
+
span.end();
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- Public API ---
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Wire a {@link SignalingHub} to a metrics registry and / or a tracer.
|
|
196
|
+
*
|
|
197
|
+
* Registers six standard Prometheus series under the `zs_webrtc_` prefix:
|
|
198
|
+
*
|
|
199
|
+
* - `zs_webrtc_peers_active{room}` (gauge)
|
|
200
|
+
* - `zs_webrtc_rooms_active` (gauge)
|
|
201
|
+
* - `zs_webrtc_signaling_messages_total{type,direction,result}` (counter)
|
|
202
|
+
* - `zs_webrtc_offer_duration_ms{room}` (histogram)
|
|
203
|
+
* - `zs_webrtc_join_failures_total{reason}` (counter)
|
|
204
|
+
* - `zs_webrtc_ice_restart_total{room}` (counter)
|
|
205
|
+
*
|
|
206
|
+
* Emits spans named `webrtc.join`, `webrtc.signal`, `webrtc.publish`, and
|
|
207
|
+
* `webrtc.subscribe`, each annotated with `peer.id` and `room.id`.
|
|
208
|
+
*
|
|
209
|
+
* @section Observability
|
|
210
|
+
*
|
|
211
|
+
* @param {SignalingHub} hub - The hub to instrument.
|
|
212
|
+
* @param {object} opts
|
|
213
|
+
* @param {MetricsRegistry} [opts.metrics] - Prometheus-compatible registry.
|
|
214
|
+
* @param {Tracer} [opts.tracer] - Tracer for span emission.
|
|
215
|
+
* @returns {SignalingHub} The same hub, for chaining.
|
|
216
|
+
*
|
|
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)
|
|
233
|
+
* const hub = new SignalingHub();
|
|
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()));
|
|
252
|
+
*/
|
|
253
|
+
function bindObservability(hub, opts = {})
|
|
254
|
+
{
|
|
255
|
+
if (opts.metrics) _bindMetrics(hub, _registerMetrics(opts.metrics));
|
|
256
|
+
if (opts.tracer) _bindTracing(hub, opts.tracer);
|
|
257
|
+
return hub;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = { bindObservability };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/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.
|
|
7
|
+
*
|
|
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
|
+
* });
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
let _peerCounter = 0;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* JSEP signaling state strings, matching RFC 8829 §4.1.
|
|
26
|
+
* @enum {string}
|
|
27
|
+
*/
|
|
28
|
+
const PEER_STATE = Object.freeze({
|
|
29
|
+
STABLE: 'stable',
|
|
30
|
+
HAVE_LOCAL_OFFER: 'have-local-offer',
|
|
31
|
+
HAVE_REMOTE_OFFER: 'have-remote-offer',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One signaling peer attached to a `SignalingHub` via some transport.
|
|
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
|
+
*
|
|
43
|
+
* @class
|
|
44
|
+
* @section Peers
|
|
45
|
+
*
|
|
46
|
+
* @example | Push a per-peer welcome and tap mute events
|
|
47
|
+
* hub.on('join', ({ peer, room }) => {
|
|
48
|
+
* peer.send('welcome', { roomSize: room.size, you: peer.id });
|
|
49
|
+
* peer.on?.('mute', ev => audit('mute', peer.id, ev.kind));
|
|
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
|
+
* }
|
|
63
|
+
*/
|
|
64
|
+
class Peer
|
|
65
|
+
{
|
|
66
|
+
/**
|
|
67
|
+
* @constructor
|
|
68
|
+
* @param {object} transport - Anything with `send(string)`, `on('message'|'close', cb)`, `close(code?, reason?)`.
|
|
69
|
+
* @param {object} [info] - Connection metadata.
|
|
70
|
+
* @param {*} [info.user] - Authenticated user object (if any).
|
|
71
|
+
* @param {string} [info.ip] - Remote IP for audit / rate limits.
|
|
72
|
+
*/
|
|
73
|
+
constructor(transport, info = {})
|
|
74
|
+
{
|
|
75
|
+
/** @type {string} Globally-unique peer id within a hub. */
|
|
76
|
+
this.id = 'peer_' + (++_peerCounter) + '_' + Date.now().toString(36);
|
|
77
|
+
|
|
78
|
+
/** @type {*} Authenticated user object (if any). */
|
|
79
|
+
this.user = info.user || null;
|
|
80
|
+
|
|
81
|
+
/** @type {string|null} Remote IP for audit / rate limits. */
|
|
82
|
+
this.ip = info.ip || null;
|
|
83
|
+
|
|
84
|
+
/** @type {object} Underlying transport (WS connection or mock). */
|
|
85
|
+
this.transport = transport;
|
|
86
|
+
|
|
87
|
+
/** @type {string} Current JSEP state. */
|
|
88
|
+
this.state = PEER_STATE.STABLE;
|
|
89
|
+
|
|
90
|
+
/** @type {import('./room')|null} Current room membership. */
|
|
91
|
+
this.room = null;
|
|
92
|
+
|
|
93
|
+
/** @type {number} Count of malformed frames received - rate-limit material. */
|
|
94
|
+
this.errors = 0;
|
|
95
|
+
|
|
96
|
+
/** @type {number} ms timestamp the peer was created. */
|
|
97
|
+
this.connectedAt = Date.now();
|
|
98
|
+
|
|
99
|
+
/** @type {boolean} */
|
|
100
|
+
this.closed = false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Send a JSON envelope to this peer. `type` is added to `payload` and
|
|
105
|
+
* the result is serialised once. Silently drops sends after close.
|
|
106
|
+
* @param {string} type
|
|
107
|
+
* @param {object} [payload]
|
|
108
|
+
*/
|
|
109
|
+
send(type, payload)
|
|
110
|
+
{
|
|
111
|
+
if (this.closed) return;
|
|
112
|
+
const frame = Object.assign({ type }, payload || {});
|
|
113
|
+
try { this.transport.send(JSON.stringify(frame)); }
|
|
114
|
+
catch { /* transport may already be closed; ignore */ }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Send a typed error frame. Callers SHOULD use this rather than throwing,
|
|
119
|
+
* because exceptions escape the message handler and tear down the process.
|
|
120
|
+
* @param {string} code
|
|
121
|
+
* @param {string} message
|
|
122
|
+
*/
|
|
123
|
+
sendError(code, message)
|
|
124
|
+
{
|
|
125
|
+
this.send('error', { code, message });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Close the underlying transport with a WebSocket-style code and reason.
|
|
130
|
+
* Defaults to 1000 (normal closure).
|
|
131
|
+
* @param {number} [code=1000]
|
|
132
|
+
* @param {string} [reason='']
|
|
133
|
+
*/
|
|
134
|
+
close(code = 1000, reason = '')
|
|
135
|
+
{
|
|
136
|
+
if (this.closed) return;
|
|
137
|
+
this.closed = true;
|
|
138
|
+
try { this.transport.close(code, reason); }
|
|
139
|
+
catch { /* already closed */ }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { Peer, PEER_STATE };
|