@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.
Files changed (94) hide show
  1. package/README.md +54 -53
  2. package/index.js +116 -4
  3. package/lib/app.js +22 -22
  4. package/lib/auth/authorize.js +11 -11
  5. package/lib/auth/enrollment.js +5 -5
  6. package/lib/auth/jwt.js +9 -9
  7. package/lib/auth/oauth.js +1 -1
  8. package/lib/auth/session.js +5 -5
  9. package/lib/auth/trustedDevice.js +2 -2
  10. package/lib/auth/twoFactor.js +11 -11
  11. package/lib/auth/webauthn.js +6 -6
  12. package/lib/body/json.js +1 -1
  13. package/lib/body/raw.js +1 -1
  14. package/lib/body/rawBuffer.js +1 -1
  15. package/lib/body/text.js +1 -1
  16. package/lib/body/urlencoded.js +3 -3
  17. package/lib/cli.js +43 -28
  18. package/lib/cluster.js +3 -3
  19. package/lib/debug.js +10 -10
  20. package/lib/env/index.js +11 -11
  21. package/lib/errors.js +131 -16
  22. package/lib/fetch/index.js +1 -1
  23. package/lib/grpc/call.js +14 -14
  24. package/lib/grpc/client.js +4 -4
  25. package/lib/grpc/codec.js +7 -7
  26. package/lib/grpc/credentials.js +2 -2
  27. package/lib/grpc/frame.js +2 -2
  28. package/lib/grpc/health.js +3 -3
  29. package/lib/grpc/index.js +3 -3
  30. package/lib/grpc/metadata.js +3 -3
  31. package/lib/grpc/proto.js +5 -5
  32. package/lib/grpc/reflection.js +2 -2
  33. package/lib/grpc/server.js +3 -3
  34. package/lib/grpc/status.js +2 -2
  35. package/lib/grpc/watch.js +1 -1
  36. package/lib/http/request.js +13 -13
  37. package/lib/http/response.js +2 -2
  38. package/lib/lifecycle.js +5 -5
  39. package/lib/middleware/compress.js +4 -4
  40. package/lib/observe/health.js +1 -1
  41. package/lib/observe/index.js +1 -1
  42. package/lib/observe/logger.js +3 -3
  43. package/lib/observe/metrics.js +4 -4
  44. package/lib/observe/tracing.js +4 -4
  45. package/lib/orm/adapters/json.js +1 -1
  46. package/lib/orm/adapters/memory.js +2 -2
  47. package/lib/orm/adapters/mongo.js +2 -2
  48. package/lib/orm/adapters/mysql.js +2 -2
  49. package/lib/orm/adapters/postgres.js +2 -2
  50. package/lib/orm/adapters/sqlite.js +3 -3
  51. package/lib/orm/audit.js +1 -1
  52. package/lib/orm/index.js +7 -7
  53. package/lib/orm/migrate.js +1 -1
  54. package/lib/orm/model.js +15 -15
  55. package/lib/orm/procedures.js +1 -1
  56. package/lib/orm/profiler.js +1 -1
  57. package/lib/orm/query.js +9 -9
  58. package/lib/orm/schema.js +1 -1
  59. package/lib/orm/seed/data/person.js +1 -1
  60. package/lib/orm/seed/fake.js +10 -10
  61. package/lib/orm/seed/index.js +4 -4
  62. package/lib/orm/seed/rng.js +1 -1
  63. package/lib/orm/snapshot.js +3 -3
  64. package/lib/orm/tenancy.js +6 -6
  65. package/lib/orm/views.js +1 -1
  66. package/lib/router/index.js +9 -9
  67. package/lib/webrtc/bot.js +405 -0
  68. package/lib/webrtc/cli.js +182 -0
  69. package/lib/webrtc/cluster.js +338 -0
  70. package/lib/webrtc/e2ee.js +274 -0
  71. package/lib/webrtc/ice.js +363 -0
  72. package/lib/webrtc/index.js +212 -0
  73. package/lib/webrtc/joinToken.js +171 -0
  74. package/lib/webrtc/observe.js +260 -0
  75. package/lib/webrtc/peer.js +143 -0
  76. package/lib/webrtc/room.js +184 -0
  77. package/lib/webrtc/sdp.js +503 -0
  78. package/lib/webrtc/sfu/index.js +251 -0
  79. package/lib/webrtc/sfu/livekit.js +304 -0
  80. package/lib/webrtc/sfu/mediasoup.js +357 -0
  81. package/lib/webrtc/sfu/memory.js +221 -0
  82. package/lib/webrtc/signaling.js +590 -0
  83. package/lib/webrtc/stun.js +484 -0
  84. package/lib/webrtc/turn/codec.js +370 -0
  85. package/lib/webrtc/turn/credentials.js +156 -0
  86. package/lib/webrtc/turn/server.js +648 -0
  87. package/package.json +2 -2
  88. package/types/body.d.ts +82 -14
  89. package/types/cli.d.ts +40 -2
  90. package/types/index.d.ts +19 -6
  91. package/types/middleware.d.ts +18 -72
  92. package/types/orm.d.ts +4 -13
  93. package/types/request.d.ts +3 -3
  94. 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 };