@zero-server/webrtc 0.9.7

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +37 -0
  3. package/index.d.ts +2 -0
  4. package/index.js +53 -0
  5. package/lib/auth/index.js +1 -0
  6. package/lib/debug.js +372 -0
  7. package/lib/errors.js +1 -0
  8. package/lib/middleware/index.js +1 -0
  9. package/lib/observe/index.js +1 -0
  10. package/lib/webrtc/bot.js +361 -0
  11. package/lib/webrtc/cli.js +182 -0
  12. package/lib/webrtc/cluster.js +350 -0
  13. package/lib/webrtc/e2ee.js +282 -0
  14. package/lib/webrtc/ice.js +370 -0
  15. package/lib/webrtc/index.js +132 -0
  16. package/lib/webrtc/joinToken.js +116 -0
  17. package/lib/webrtc/observe.js +229 -0
  18. package/lib/webrtc/peer.js +116 -0
  19. package/lib/webrtc/room.js +171 -0
  20. package/lib/webrtc/sdp.js +508 -0
  21. package/lib/webrtc/sfu/index.js +201 -0
  22. package/lib/webrtc/sfu/livekit.js +301 -0
  23. package/lib/webrtc/sfu/mediasoup.js +317 -0
  24. package/lib/webrtc/sfu/memory.js +204 -0
  25. package/lib/webrtc/signaling.js +546 -0
  26. package/lib/webrtc/stun.js +492 -0
  27. package/lib/webrtc/turn/codec.js +370 -0
  28. package/lib/webrtc/turn/credentials.js +141 -0
  29. package/lib/webrtc/turn/server.js +633 -0
  30. package/lib/ws/index.js +1 -0
  31. package/package.json +62 -0
  32. package/types/app.d.ts +223 -0
  33. package/types/auth.d.ts +520 -0
  34. package/types/body.d.ts +14 -0
  35. package/types/cli.d.ts +2 -0
  36. package/types/cluster.d.ts +75 -0
  37. package/types/env.d.ts +80 -0
  38. package/types/errors.d.ts +316 -0
  39. package/types/fetch.d.ts +43 -0
  40. package/types/grpc.d.ts +432 -0
  41. package/types/index.d.ts +396 -0
  42. package/types/lifecycle.d.ts +60 -0
  43. package/types/middleware.d.ts +320 -0
  44. package/types/observe.d.ts +304 -0
  45. package/types/orm.d.ts +1887 -0
  46. package/types/request.d.ts +109 -0
  47. package/types/response.d.ts +157 -0
  48. package/types/router.d.ts +78 -0
  49. package/types/sse.d.ts +78 -0
  50. package/types/webrtc.d.ts +501 -0
  51. package/types/websocket.d.ts +126 -0
@@ -0,0 +1,229 @@
1
+ /**
2
+ * @module webrtc/observe
3
+ * @description Optional metrics + tracing wiring for a {@link SignalingHub}.
4
+ * Pass a `MetricsRegistry`, a `Tracer`, or both; the binder
5
+ * subscribes to the hub's lifecycle events and exports the six
6
+ * standard `zs_webrtc_*` Prometheus series plus per-operation
7
+ * OpenTelemetry-compatible spans.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ // --- Helpers ---
13
+
14
+ /** Extract the first `a=ice-ufrag:` value from an SDP blob. */
15
+ function _extractUfrag(sdp)
16
+ {
17
+ const m = /^a=ice-ufrag:([^\r\n]+)/m.exec(sdp);
18
+ return m ? m[1].trim() : null;
19
+ }
20
+
21
+ // --- Metrics binder ---
22
+
23
+ function _registerMetrics(registry)
24
+ {
25
+ return {
26
+ peersActive: registry.gauge({
27
+ name: 'zs_webrtc_peers_active',
28
+ help: 'Number of WebRTC peers currently joined per room.',
29
+ labels: ['room'],
30
+ }),
31
+ roomsActive: registry.gauge({
32
+ name: 'zs_webrtc_rooms_active',
33
+ help: 'Number of WebRTC rooms with at least one peer.',
34
+ }),
35
+ signalingMessages: registry.counter({
36
+ name: 'zs_webrtc_signaling_messages_total',
37
+ help: 'WebRTC signaling messages by type, direction, and outcome.',
38
+ labels: ['type', 'direction', 'result'],
39
+ }),
40
+ offerDuration: registry.histogram({
41
+ name: 'zs_webrtc_offer_duration_ms',
42
+ help: 'End-to-end latency between an offer and its matching answer, in ms.',
43
+ labels: ['room'],
44
+ buckets: [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000],
45
+ }),
46
+ joinFailures: registry.counter({
47
+ name: 'zs_webrtc_join_failures_total',
48
+ help: 'WebRTC room joins rejected by the hub.',
49
+ labels: ['reason'],
50
+ }),
51
+ iceRestart: registry.counter({
52
+ name: 'zs_webrtc_ice_restart_total',
53
+ help: 'Detected ICE restarts (ufrag rotation) per room.',
54
+ labels: ['room'],
55
+ }),
56
+ };
57
+ }
58
+
59
+ function _bindMetrics(hub, m)
60
+ {
61
+ /** Outstanding offer timestamps: `offererPeerId` -> ms epoch. */
62
+ const offerStart = new Map();
63
+ /** Last seen ufrag per peer.id (for ICE-restart detection). */
64
+ const lastUfrag = new Map();
65
+
66
+ hub.on('signal', ({ peer, type }) =>
67
+ {
68
+ m.signalingMessages.inc({ type, direction: 'in', result: 'ok' });
69
+ });
70
+
71
+ hub.on('join', ({ peer, room }) =>
72
+ {
73
+ const before = m.peersActive.get({ room: room.name });
74
+ m.peersActive.inc({ room: room.name });
75
+ // Room newly non-empty?
76
+ if (before === 0) m.roomsActive.inc();
77
+ });
78
+
79
+ hub.on('leave', ({ peer, room }) =>
80
+ {
81
+ m.peersActive.dec({ room: room.name });
82
+ if (m.peersActive.get({ room: room.name }) <= 0)
83
+ m.roomsActive.dec();
84
+ });
85
+
86
+ hub.on('joinFailed', ({ reason }) =>
87
+ {
88
+ m.joinFailures.inc({ reason: reason || 'UNKNOWN' });
89
+ });
90
+
91
+ hub.on('offer', ({ peer, sdp, room }) =>
92
+ {
93
+ offerStart.set(peer.id, Date.now());
94
+
95
+ // ICE-restart detection: compare ufrag against last-known for this peer.
96
+ const ufrag = _extractUfrag(sdp);
97
+ if (ufrag)
98
+ {
99
+ const prev = lastUfrag.get(peer.id);
100
+ if (prev && prev !== ufrag)
101
+ m.iceRestart.inc({ room: room.name });
102
+ lastUfrag.set(peer.id, ufrag);
103
+ }
104
+ });
105
+
106
+ hub.on('answer', ({ peer, target, room }) =>
107
+ {
108
+ // The offer was sent by the original target (now answering back to it).
109
+ const startedAt = offerStart.get(target.id);
110
+ if (startedAt !== undefined)
111
+ {
112
+ m.offerDuration.observe({ room: room.name }, Date.now() - startedAt);
113
+ offerStart.delete(target.id);
114
+ }
115
+ });
116
+ }
117
+
118
+ // --- Tracing binder ---
119
+
120
+ function _bindTracing(hub, tracer)
121
+ {
122
+ hub.on('signal', ({ peer, type }) =>
123
+ {
124
+ const span = tracer.startSpan('webrtc.signal', {
125
+ kind: 'server',
126
+ attributes: { 'peer.id': peer.id, 'rtc.type': type },
127
+ });
128
+ span.setOk();
129
+ span.end();
130
+ });
131
+
132
+ hub.on('join', ({ peer, room }) =>
133
+ {
134
+ const span = tracer.startSpan('webrtc.join', {
135
+ kind: 'server',
136
+ attributes: { 'peer.id': peer.id, 'room.id': room.name },
137
+ });
138
+ span.setOk();
139
+ span.end();
140
+ });
141
+
142
+ hub.on('joinFailed', ({ peer, reason, room }) =>
143
+ {
144
+ const span = tracer.startSpan('webrtc.join', {
145
+ kind: 'server',
146
+ attributes: { 'peer.id': peer.id, 'room.id': room || '', 'rtc.error': reason },
147
+ });
148
+ span.setError(reason);
149
+ span.end();
150
+ });
151
+
152
+ hub.on('offer', ({ peer, target, room }) =>
153
+ {
154
+ const span = tracer.startSpan('webrtc.publish', {
155
+ kind: 'producer',
156
+ attributes: { 'peer.id': peer.id, 'room.id': room.name, 'rtc.target': target.id },
157
+ });
158
+ span.setOk();
159
+ span.end();
160
+ });
161
+
162
+ hub.on('publishFailed', ({ peer, reason, room }) =>
163
+ {
164
+ const span = tracer.startSpan('webrtc.publish', {
165
+ kind: 'producer',
166
+ attributes: { 'peer.id': peer.id, 'room.id': room || '', 'rtc.error': reason },
167
+ });
168
+ span.setError(reason);
169
+ span.end();
170
+ });
171
+
172
+ hub.on('answer', ({ peer, target, room }) =>
173
+ {
174
+ const span = tracer.startSpan('webrtc.subscribe', {
175
+ kind: 'consumer',
176
+ attributes: { 'peer.id': peer.id, 'room.id': room.name, 'rtc.target': target.id },
177
+ });
178
+ span.setOk();
179
+ span.end();
180
+ });
181
+
182
+ hub.on('subscribeFailed', ({ peer, reason, room }) =>
183
+ {
184
+ const span = tracer.startSpan('webrtc.subscribe', {
185
+ kind: 'consumer',
186
+ attributes: { 'peer.id': peer.id, 'room.id': room || '', 'rtc.error': reason },
187
+ });
188
+ span.setError(reason);
189
+ span.end();
190
+ });
191
+ }
192
+
193
+ // --- Public API ---
194
+
195
+ /**
196
+ * Wire a {@link SignalingHub} to a metrics registry and / or a tracer.
197
+ *
198
+ * Registers six standard Prometheus series under the `zs_webrtc_` prefix:
199
+ *
200
+ * - `zs_webrtc_peers_active{room}` (gauge)
201
+ * - `zs_webrtc_rooms_active` (gauge)
202
+ * - `zs_webrtc_signaling_messages_total{type,direction,result}` (counter)
203
+ * - `zs_webrtc_offer_duration_ms{room}` (histogram)
204
+ * - `zs_webrtc_join_failures_total{reason}` (counter)
205
+ * - `zs_webrtc_ice_restart_total{room}` (counter)
206
+ *
207
+ * Emits spans named `webrtc.join`, `webrtc.signal`, `webrtc.publish`, and
208
+ * `webrtc.subscribe`, each annotated with `peer.id` and `room.id`.
209
+ *
210
+ * @section Observability
211
+ *
212
+ * @param {SignalingHub} hub - The hub to instrument.
213
+ * @param {object} opts
214
+ * @param {MetricsRegistry} [opts.metrics] - Prometheus-compatible registry.
215
+ * @param {Tracer} [opts.tracer] - Tracer for span emission.
216
+ * @returns {SignalingHub} The same hub, for chaining.
217
+ *
218
+ * @example | Plug into an existing app
219
+ * const hub = new SignalingHub();
220
+ * bindObservability(hub, { metrics: app.metrics() });
221
+ */
222
+ function bindObservability(hub, opts = {})
223
+ {
224
+ if (opts.metrics) _bindMetrics(hub, _registerMetrics(opts.metrics));
225
+ if (opts.tracer) _bindTracing(hub, opts.tracer);
226
+ return hub;
227
+ }
228
+
229
+ module.exports = { bindObservability };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @module webrtc/peer
3
+ * @description Per-connection state machine for a WebRTC signaling peer.
4
+ *
5
+ * A `Peer` wraps a transport object (typically a `WebSocketConnection`
6
+ * but any duck-typed `{ send, on, close }` works - see the unit tests
7
+ * for a minimal in-memory transport) and exposes the JSEP message
8
+ * types as ordinary events. The hub is responsible for routing.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ let _peerCounter = 0;
14
+
15
+ /**
16
+ * JSEP signaling state strings, matching RFC 8829 §4.1.
17
+ * @enum {string}
18
+ */
19
+ const PEER_STATE = Object.freeze({
20
+ STABLE: 'stable',
21
+ HAVE_LOCAL_OFFER: 'have-local-offer',
22
+ HAVE_REMOTE_OFFER: 'have-remote-offer',
23
+ });
24
+
25
+ /**
26
+ * One signaling peer attached to a `SignalingHub` via some transport.
27
+ *
28
+ * @class
29
+ * @section Peers
30
+ *
31
+ * @example
32
+ * hub.on('join', ({ peer, room }) => {
33
+ * peer.send('welcome', { roomSize: room.size });
34
+ * peer.on('mute', ev => audit('mute', peer.id, ev.kind));
35
+ * });
36
+ */
37
+ class Peer
38
+ {
39
+ /**
40
+ * @constructor
41
+ * @param {object} transport - Anything with `send(string)`, `on('message'|'close', cb)`, `close(code?, reason?)`.
42
+ * @param {object} [info] - Connection metadata.
43
+ * @param {*} [info.user] - Authenticated user object (if any).
44
+ * @param {string} [info.ip] - Remote IP for audit / rate limits.
45
+ */
46
+ constructor(transport, info = {})
47
+ {
48
+ /** @type {string} Globally-unique peer id within a hub. */
49
+ this.id = 'peer_' + (++_peerCounter) + '_' + Date.now().toString(36);
50
+
51
+ /** @type {*} Authenticated user object (if any). */
52
+ this.user = info.user || null;
53
+
54
+ /** @type {string|null} Remote IP for audit / rate limits. */
55
+ this.ip = info.ip || null;
56
+
57
+ /** @type {object} Underlying transport (WS connection or mock). */
58
+ this.transport = transport;
59
+
60
+ /** @type {string} Current JSEP state. */
61
+ this.state = PEER_STATE.STABLE;
62
+
63
+ /** @type {import('./room')|null} Current room membership. */
64
+ this.room = null;
65
+
66
+ /** @type {number} Count of malformed frames received - rate-limit material. */
67
+ this.errors = 0;
68
+
69
+ /** @type {number} ms timestamp the peer was created. */
70
+ this.connectedAt = Date.now();
71
+
72
+ /** @type {boolean} */
73
+ this.closed = false;
74
+ }
75
+
76
+ /**
77
+ * Send a JSON envelope to this peer. `type` is added to `payload` and
78
+ * the result is serialised once. Silently drops sends after close.
79
+ * @param {string} type
80
+ * @param {object} [payload]
81
+ */
82
+ send(type, payload)
83
+ {
84
+ if (this.closed) return;
85
+ const frame = Object.assign({ type }, payload || {});
86
+ try { this.transport.send(JSON.stringify(frame)); }
87
+ catch { /* transport may already be closed; ignore */ }
88
+ }
89
+
90
+ /**
91
+ * Send a typed error frame. Callers SHOULD use this rather than throwing,
92
+ * because exceptions escape the message handler and tear down the process.
93
+ * @param {string} code
94
+ * @param {string} message
95
+ */
96
+ sendError(code, message)
97
+ {
98
+ this.send('error', { code, message });
99
+ }
100
+
101
+ /**
102
+ * Close the underlying transport with a WebSocket-style code and reason.
103
+ * Defaults to 1000 (normal closure).
104
+ * @param {number} [code=1000]
105
+ * @param {string} [reason='']
106
+ */
107
+ close(code = 1000, reason = '')
108
+ {
109
+ if (this.closed) return;
110
+ this.closed = true;
111
+ try { this.transport.close(code, reason); }
112
+ catch { /* already closed */ }
113
+ }
114
+ }
115
+
116
+ module.exports = { Peer, PEER_STATE };
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @module webrtc/room
3
+ * @description Room / channel abstraction for the WebRTC signaling hub.
4
+ *
5
+ * A `Room` holds a set of `Peer`s plus a list of `require()` policy gates
6
+ * that decide whether a given peer may join. Membership is in-process;
7
+ * the cluster adapter (PR 8) will fan room state out via pub/sub.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const { SignalingError } = require('../errors');
13
+
14
+ /**
15
+ * One signaling room.
16
+ *
17
+ * Constructed lazily by `SignalingHub#room(name)`. Application code never
18
+ * calls `new Room()` directly.
19
+ *
20
+ * @class
21
+ * @section Rooms
22
+ *
23
+ * @example
24
+ * hub.room('lobby').open();
25
+ * hub.room('boardroom')
26
+ * .require(peer => peer.user && peer.user.role === 'exec')
27
+ * .canPublish(peer => peer.user.isHost);
28
+ */
29
+ class Room
30
+ {
31
+ /**
32
+ * @constructor
33
+ * @param {string} name - Room name, used as routing key.
34
+ * @param {object} [opts]
35
+ * @param {import('./signaling').SignalingHub} [opts.hub] - Owning hub.
36
+ */
37
+ constructor(name, opts = {})
38
+ {
39
+ if (typeof name !== 'string' || name.length === 0)
40
+ throw new SignalingError('Room name must be a non-empty string');
41
+
42
+ /** @type {string} */
43
+ this.name = name;
44
+
45
+ /** @type {import('./signaling').SignalingHub|null} */
46
+ this.hub = opts.hub || null;
47
+
48
+ /** @type {Set<import('./peer').Peer>} */
49
+ this._peers = new Set();
50
+
51
+ /** @type {Array<(peer:import('./peer').Peer) => boolean>} */
52
+ this._gates = [];
53
+
54
+ /** @type {((peer:import('./peer').Peer) => boolean)|null} */
55
+ this._canPublish = null;
56
+
57
+ /** @type {((peer:import('./peer').Peer) => boolean)|null} */
58
+ this._canSubscribe = null;
59
+
60
+ /** @type {boolean} `true` once `.open()` has been called. */
61
+ this.isOpen = false;
62
+ }
63
+
64
+ // -- Configuration (fluent) --
65
+
66
+ /** Mark the room as public. Returns `this` for chaining. */
67
+ open()
68
+ {
69
+ this.isOpen = true;
70
+ return this;
71
+ }
72
+
73
+ /**
74
+ * Add a policy gate. Called on every join; first falsy return rejects.
75
+ * @param {(peer:import('./peer').Peer) => boolean | Promise<boolean>} fn
76
+ * @returns {Room}
77
+ */
78
+ require(fn)
79
+ {
80
+ if (typeof fn !== 'function')
81
+ throw new SignalingError('Room.require(fn) requires a function');
82
+ this._gates.push(fn);
83
+ return this;
84
+ }
85
+
86
+ /**
87
+ * Set the publish-permission check. Hub calls this before relaying offers.
88
+ * @param {(peer:import('./peer').Peer) => boolean} fn
89
+ */
90
+ canPublish(fn) { this._canPublish = fn; return this; }
91
+
92
+ /**
93
+ * Set the subscribe-permission check. Hub calls this before relaying answers.
94
+ * @param {(peer:import('./peer').Peer) => boolean} fn
95
+ */
96
+ canSubscribe(fn) { this._canSubscribe = fn; return this; }
97
+
98
+ // -- Membership --
99
+
100
+ /** Current member count. */
101
+ get size() { return this._peers.size; }
102
+
103
+ /** @returns {import('./peer').Peer[]} */
104
+ peers() { return Array.from(this._peers); }
105
+
106
+ /** @returns {boolean} */
107
+ has(peer) { return this._peers.has(peer); }
108
+
109
+ /**
110
+ * Evaluate every `require()` gate against the candidate peer.
111
+ * @param {import('./peer').Peer} peer
112
+ * @returns {boolean}
113
+ */
114
+ canJoin(peer)
115
+ {
116
+ for (const gate of this._gates)
117
+ {
118
+ try { if (!gate(peer)) return false; }
119
+ catch { return false; }
120
+ }
121
+ return true;
122
+ }
123
+
124
+ /** Internal - hub uses this; do not call from application code. */
125
+ _add(peer)
126
+ {
127
+ this._peers.add(peer);
128
+ peer.room = this;
129
+ }
130
+
131
+ /** Internal - hub uses this; do not call from application code. */
132
+ _remove(peer)
133
+ {
134
+ if (!this._peers.has(peer)) return;
135
+ this._peers.delete(peer);
136
+ if (peer.room === this) peer.room = null;
137
+ }
138
+
139
+ // -- Fan-out --
140
+
141
+ /**
142
+ * Send a `{type, ...payload}` JSON frame to every peer in the room.
143
+ * @param {string} type
144
+ * @param {object} [payload]
145
+ * @param {string} [exceptPeerId] - Optional peer id to skip (e.g. the originator).
146
+ */
147
+ broadcast(type, payload, exceptPeerId)
148
+ {
149
+ for (const p of this._peers)
150
+ {
151
+ if (exceptPeerId && p.id === exceptPeerId) continue;
152
+ p.send(type, payload);
153
+ }
154
+ if (this.hub && this.hub._cluster)
155
+ this.hub._cluster.fanoutRoom(this.name, type, payload, exceptPeerId);
156
+ }
157
+
158
+ /** Kick every peer with code 1001 (going-away) and unregister from the hub. */
159
+ close(reason = 'room-closed')
160
+ {
161
+ for (const p of Array.from(this._peers))
162
+ {
163
+ p.send('bye', { reason });
164
+ p.close(1001, reason);
165
+ }
166
+ this._peers.clear();
167
+ if (this.hub) this.hub._removeRoom(this);
168
+ }
169
+ }
170
+
171
+ module.exports = { Room };