@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.
- package/LICENSE +21 -0
- package/README.md +37 -0
- package/index.d.ts +2 -0
- package/index.js +53 -0
- package/lib/auth/index.js +1 -0
- package/lib/debug.js +372 -0
- package/lib/errors.js +1 -0
- package/lib/middleware/index.js +1 -0
- package/lib/observe/index.js +1 -0
- package/lib/webrtc/bot.js +361 -0
- package/lib/webrtc/cli.js +182 -0
- package/lib/webrtc/cluster.js +350 -0
- package/lib/webrtc/e2ee.js +282 -0
- package/lib/webrtc/ice.js +370 -0
- package/lib/webrtc/index.js +132 -0
- package/lib/webrtc/joinToken.js +116 -0
- package/lib/webrtc/observe.js +229 -0
- package/lib/webrtc/peer.js +116 -0
- package/lib/webrtc/room.js +171 -0
- package/lib/webrtc/sdp.js +508 -0
- package/lib/webrtc/sfu/index.js +201 -0
- package/lib/webrtc/sfu/livekit.js +301 -0
- package/lib/webrtc/sfu/mediasoup.js +317 -0
- package/lib/webrtc/sfu/memory.js +204 -0
- package/lib/webrtc/signaling.js +546 -0
- package/lib/webrtc/stun.js +492 -0
- package/lib/webrtc/turn/codec.js +370 -0
- package/lib/webrtc/turn/credentials.js +141 -0
- package/lib/webrtc/turn/server.js +633 -0
- package/lib/ws/index.js +1 -0
- package/package.json +62 -0
- package/types/app.d.ts +223 -0
- package/types/auth.d.ts +520 -0
- package/types/body.d.ts +14 -0
- package/types/cli.d.ts +2 -0
- package/types/cluster.d.ts +75 -0
- package/types/env.d.ts +80 -0
- package/types/errors.d.ts +316 -0
- package/types/fetch.d.ts +43 -0
- package/types/grpc.d.ts +432 -0
- package/types/index.d.ts +396 -0
- package/types/lifecycle.d.ts +60 -0
- package/types/middleware.d.ts +320 -0
- package/types/observe.d.ts +304 -0
- package/types/orm.d.ts +1887 -0
- package/types/request.d.ts +109 -0
- package/types/response.d.ts +157 -0
- package/types/router.d.ts +78 -0
- package/types/sse.d.ts +78 -0
- package/types/webrtc.d.ts +501 -0
- package/types/websocket.d.ts +126 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/signaling
|
|
3
|
+
* @description WebRTC signaling hub - the central WS broker that owns the
|
|
4
|
+
* room registry, attaches peers, validates JSEP messages, and
|
|
5
|
+
* routes offer / answer / ICE traffic between participants.
|
|
6
|
+
*
|
|
7
|
+
* The hub itself is transport-agnostic: anything that exposes a
|
|
8
|
+
* `{ send(string), on('message'|'close', cb), close(code?, reason?) }`
|
|
9
|
+
* surface is acceptable. `createWebRTC(app, opts)` (PR 9 wiring layer)
|
|
10
|
+
* binds an `app.ws()` upgrade handler to a `SignalingHub`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const { EventEmitter } = require('node:events');
|
|
16
|
+
|
|
17
|
+
const { SignalingError, SdpError, IceError } = require('../errors');
|
|
18
|
+
const { parseSdp } = require('./sdp');
|
|
19
|
+
const { parseCandidate } = require('./ice');
|
|
20
|
+
const { Peer, PEER_STATE } = require('./peer');
|
|
21
|
+
const { Room } = require('./room');
|
|
22
|
+
const { verifyJoinToken } = require('./joinToken');
|
|
23
|
+
|
|
24
|
+
// --- Constants ---
|
|
25
|
+
|
|
26
|
+
/** Default hard cap on incoming SDP size, in bytes. */
|
|
27
|
+
const DEFAULT_MAX_SDP_BYTES = 64 * 1024;
|
|
28
|
+
|
|
29
|
+
/** Default hard cap on `a=candidate:` lines per SDP. */
|
|
30
|
+
const DEFAULT_MAX_CANDIDATES = 30;
|
|
31
|
+
|
|
32
|
+
/** Default per-peer signaling message rate, msg/sec. */
|
|
33
|
+
const DEFAULT_PEER_MSG_RATE = 30;
|
|
34
|
+
|
|
35
|
+
/** Default max protocol errors (BAD_FRAME / UNKNOWN_TYPE) before disconnect. */
|
|
36
|
+
const DEFAULT_MAX_PROTOCOL_ERRORS = 5;
|
|
37
|
+
|
|
38
|
+
/** Default rolling window (sec) for the per-IP attach rate limit. */
|
|
39
|
+
const IP_ATTACH_WINDOW_SEC = 60;
|
|
40
|
+
|
|
41
|
+
/** Set of message `type`s the hub will dispatch. */
|
|
42
|
+
const VALID_TYPES = new Set([
|
|
43
|
+
'join', 'leave', 'offer', 'answer', 'ice',
|
|
44
|
+
'mute', 'unmute', 'bye', 'e2ee-key',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
// --- Helpers ---
|
|
48
|
+
|
|
49
|
+
/** Count `a=candidate:` lines in a raw SDP blob without re-parsing. */
|
|
50
|
+
function _countCandidatesInSdp(sdp)
|
|
51
|
+
{
|
|
52
|
+
let n = 0;
|
|
53
|
+
const re = /^a=candidate:/gm;
|
|
54
|
+
while (re.exec(sdp) !== null) n++;
|
|
55
|
+
return n;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate that an SDP has the required RFC 8829 attributes on every media
|
|
60
|
+
* section and uses DTLS-SRTP transport. Returns an error code string, or
|
|
61
|
+
* `null` if the SDP is acceptable.
|
|
62
|
+
*/
|
|
63
|
+
function _validateSdpStructure(sdp)
|
|
64
|
+
{
|
|
65
|
+
let desc;
|
|
66
|
+
try { desc = parseSdp(sdp, { maxBytes: 64 * 1024 }); }
|
|
67
|
+
catch (err)
|
|
68
|
+
{
|
|
69
|
+
if (err instanceof SdpError) return 'INVALID_SDP';
|
|
70
|
+
return 'INVALID_SDP';
|
|
71
|
+
}
|
|
72
|
+
if (!desc.media || desc.media.length === 0) return 'INVALID_SDP';
|
|
73
|
+
for (const m of desc.media)
|
|
74
|
+
{
|
|
75
|
+
if (typeof m.proto !== 'string' || !/^UDP\/TLS\/RTP\/SAVPF?$/i.test(m.proto))
|
|
76
|
+
return 'INVALID_SDP';
|
|
77
|
+
if (!m.iceUfrag || !m.icePwd) return 'INVALID_SDP';
|
|
78
|
+
if (!m.fingerprint) return 'INVALID_SDP';
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- SignalingHub ---
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Central WebRTC signaling broker. Owns rooms, attaches peers, validates
|
|
87
|
+
* JSEP traffic, and emits `join` / `leave` / `error` lifecycle events.
|
|
88
|
+
*
|
|
89
|
+
* @class
|
|
90
|
+
* @section Signaling
|
|
91
|
+
*
|
|
92
|
+
* @example | Minimal in-memory use (no WS layer)
|
|
93
|
+
* const { SignalingHub } = require('@zero-server/webrtc');
|
|
94
|
+
* const hub = new SignalingHub();
|
|
95
|
+
* hub.room('lobby').open();
|
|
96
|
+
* const peer = hub.attach(myWsConnection, { user: req.user, ip: req.ip });
|
|
97
|
+
*
|
|
98
|
+
* @example | Policy-gated room
|
|
99
|
+
* hub.room('boardroom')
|
|
100
|
+
* .require(p => p.user && p.user.role === 'exec')
|
|
101
|
+
* .canPublish(p => p.user.isHost);
|
|
102
|
+
*/
|
|
103
|
+
class SignalingHub extends EventEmitter
|
|
104
|
+
{
|
|
105
|
+
/**
|
|
106
|
+
* @constructor
|
|
107
|
+
* @param {object} [opts]
|
|
108
|
+
* @param {number} [opts.maxSdpSize=65536] - Hard cap on offer / answer size (bytes).
|
|
109
|
+
* @param {number} [opts.maxCandidatesPerOffer=30] - Hard cap on `a=candidate:` lines per SDP.
|
|
110
|
+
* @param {number} [opts.peerMessageRate=30] - Per-peer signaling message rate, msg / sec.
|
|
111
|
+
* @param {number} [opts.maxProtocolErrors=5] - Disconnect after this many malformed frames.
|
|
112
|
+
* @param {number} [opts.ipAttachRate=0] - Max attaches per IP per minute. `0` disables.
|
|
113
|
+
* @param {string[]} [opts.originAllowlist] - If set, transports whose `info.origin` is not
|
|
114
|
+
* on this list are rejected at attach time.
|
|
115
|
+
* @param {string|Buffer} [opts.joinTokenSecret] - If set, every `join` must include a valid
|
|
116
|
+
* JWT signed with this secret and audience `room:<name>`.
|
|
117
|
+
* @param {boolean} [opts.autoCreateRooms=true] - If false, joins targeting an unknown room are rejected.
|
|
118
|
+
*/
|
|
119
|
+
constructor(opts = {})
|
|
120
|
+
{
|
|
121
|
+
super();
|
|
122
|
+
|
|
123
|
+
/** @type {number} */
|
|
124
|
+
this.maxSdpSize = opts.maxSdpSize ?? DEFAULT_MAX_SDP_BYTES;
|
|
125
|
+
|
|
126
|
+
/** @type {number} */
|
|
127
|
+
this.maxCandidatesPerOffer = opts.maxCandidatesPerOffer ?? DEFAULT_MAX_CANDIDATES;
|
|
128
|
+
|
|
129
|
+
/** @type {number} */
|
|
130
|
+
this.peerMessageRate = opts.peerMessageRate ?? DEFAULT_PEER_MSG_RATE;
|
|
131
|
+
|
|
132
|
+
/** @type {number} */
|
|
133
|
+
this.maxProtocolErrors = opts.maxProtocolErrors ?? DEFAULT_MAX_PROTOCOL_ERRORS;
|
|
134
|
+
|
|
135
|
+
/** @type {number} 0 disables. */
|
|
136
|
+
this.ipAttachRate = Number.isFinite(opts.ipAttachRate) ? opts.ipAttachRate : 0;
|
|
137
|
+
|
|
138
|
+
/** @type {Set<string>|null} */
|
|
139
|
+
this.originAllowlist = Array.isArray(opts.originAllowlist) && opts.originAllowlist.length > 0
|
|
140
|
+
? new Set(opts.originAllowlist)
|
|
141
|
+
: null;
|
|
142
|
+
|
|
143
|
+
/** @type {string|Buffer|null} */
|
|
144
|
+
this.joinTokenSecret = opts.joinTokenSecret || null;
|
|
145
|
+
|
|
146
|
+
/** @type {boolean} */
|
|
147
|
+
this.autoCreateRooms = opts.autoCreateRooms !== false;
|
|
148
|
+
|
|
149
|
+
/** @type {Map<string, Room>} */
|
|
150
|
+
this._rooms = new Map();
|
|
151
|
+
|
|
152
|
+
/** @type {Map<string, Peer>} */
|
|
153
|
+
this._peers = new Map();
|
|
154
|
+
|
|
155
|
+
/** @type {Map<string, number[]>} peer.id -> sliding window of message timestamps (ms). */
|
|
156
|
+
this._rate = new Map();
|
|
157
|
+
|
|
158
|
+
/** @type {Map<string, number[]>} ip -> attach timestamps in the rolling window. */
|
|
159
|
+
this._ipAttachLog = new Map();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// -- Public surface --
|
|
163
|
+
|
|
164
|
+
/** Live peer count across all rooms (and unattached). */
|
|
165
|
+
get size() { return this._peers.size; }
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get or lazily create a room.
|
|
169
|
+
* @param {string} name
|
|
170
|
+
* @returns {Room}
|
|
171
|
+
*/
|
|
172
|
+
room(name)
|
|
173
|
+
{
|
|
174
|
+
if (typeof name !== 'string' || name.length === 0)
|
|
175
|
+
throw new SignalingError('Hub.room: name must be a non-empty string');
|
|
176
|
+
let r = this._rooms.get(name);
|
|
177
|
+
if (!r)
|
|
178
|
+
{
|
|
179
|
+
r = new Room(name, { hub: this });
|
|
180
|
+
this._rooms.set(name, r);
|
|
181
|
+
}
|
|
182
|
+
return r;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @returns {Room[]} */
|
|
186
|
+
rooms() { return Array.from(this._rooms.values()); }
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Attach a transport (WS connection or mock) as a new signaling peer.
|
|
190
|
+
* Wires up message and close handlers; returns the `Peer`.
|
|
191
|
+
*
|
|
192
|
+
* @param {object} transport - `{ send, on, close }`-shaped object.
|
|
193
|
+
* @param {object} [info]
|
|
194
|
+
* @param {*} [info.user] - Authenticated user.
|
|
195
|
+
* @param {string} [info.ip] - Remote IP.
|
|
196
|
+
* @returns {Peer}
|
|
197
|
+
*/
|
|
198
|
+
attach(transport, info = {})
|
|
199
|
+
{
|
|
200
|
+
const peer = new Peer(transport, info);
|
|
201
|
+
this._peers.set(peer.id, peer);
|
|
202
|
+
|
|
203
|
+
const onMessage = (raw) => this._onMessage(peer, raw);
|
|
204
|
+
const onClose = () => this._onClose(peer);
|
|
205
|
+
|
|
206
|
+
transport.on('message', onMessage);
|
|
207
|
+
transport.on('close', onClose);
|
|
208
|
+
|
|
209
|
+
// -- Origin allowlist --
|
|
210
|
+
if (this.originAllowlist && info.origin && !this.originAllowlist.has(info.origin))
|
|
211
|
+
{
|
|
212
|
+
peer.sendError('ORIGIN_NOT_ALLOWED', 'origin not on allowlist');
|
|
213
|
+
peer.close(1008, 'origin-not-allowed');
|
|
214
|
+
return peer;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// -- Per-IP attach rate limit --
|
|
218
|
+
if (this.ipAttachRate > 0 && peer.ip && !this._allowIpAttach(peer.ip))
|
|
219
|
+
{
|
|
220
|
+
peer.sendError('IP_RATE_LIMITED', 'too many connections from this address');
|
|
221
|
+
peer.close(1008, 'ip-rate-limited');
|
|
222
|
+
return peer;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
peer.send('hello', { peerId: peer.id });
|
|
226
|
+
return peer;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Shut down every peer and clear every room. */
|
|
230
|
+
close()
|
|
231
|
+
{
|
|
232
|
+
for (const peer of Array.from(this._peers.values()))
|
|
233
|
+
{
|
|
234
|
+
peer.close(1001, 'hub-closed');
|
|
235
|
+
}
|
|
236
|
+
this._peers.clear();
|
|
237
|
+
this._rate.clear();
|
|
238
|
+
for (const room of Array.from(this._rooms.values()))
|
|
239
|
+
room._peers.clear();
|
|
240
|
+
this._rooms.clear();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Internal: room.close() calls this so the registry stays consistent. */
|
|
244
|
+
_removeRoom(room)
|
|
245
|
+
{
|
|
246
|
+
if (this._rooms.get(room.name) === room) this._rooms.delete(room.name);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// -- Wire dispatch --
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Handle one inbound frame from a peer's transport.
|
|
253
|
+
* @private
|
|
254
|
+
*/
|
|
255
|
+
_onMessage(peer, raw)
|
|
256
|
+
{
|
|
257
|
+
if (peer.closed) return;
|
|
258
|
+
|
|
259
|
+
// Rate limit
|
|
260
|
+
if (!this._allowRate(peer))
|
|
261
|
+
{
|
|
262
|
+
peer.sendError('RATE_LIMITED', 'too many signaling messages');
|
|
263
|
+
peer.close(1008, 'rate-limited');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const text = typeof raw === 'string' ? raw : (raw && raw.toString ? raw.toString('utf8') : String(raw));
|
|
268
|
+
let msg;
|
|
269
|
+
try { msg = JSON.parse(text); }
|
|
270
|
+
catch
|
|
271
|
+
{
|
|
272
|
+
peer.errors++;
|
|
273
|
+
peer.sendError('BAD_FRAME', 'malformed JSON');
|
|
274
|
+
this.emit('wireError', { peer, code: 'BAD_FRAME' });
|
|
275
|
+
this._checkErrorBackoff(peer);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (!msg || typeof msg !== 'object' || typeof msg.type !== 'string')
|
|
279
|
+
{
|
|
280
|
+
peer.errors++;
|
|
281
|
+
peer.sendError('BAD_FRAME', 'missing message type');
|
|
282
|
+
this._checkErrorBackoff(peer);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (!VALID_TYPES.has(msg.type))
|
|
286
|
+
{
|
|
287
|
+
peer.errors++;
|
|
288
|
+
peer.sendError('UNKNOWN_TYPE', `unknown message type "${msg.type}"`);
|
|
289
|
+
this._checkErrorBackoff(peer);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try
|
|
294
|
+
{
|
|
295
|
+
this.emit('signal', { peer, type: msg.type, msg });
|
|
296
|
+
switch (msg.type)
|
|
297
|
+
{
|
|
298
|
+
case 'join': return this._handleJoin(peer, msg);
|
|
299
|
+
case 'leave': return this._handleLeave(peer);
|
|
300
|
+
case 'offer':
|
|
301
|
+
case 'answer': return this._handleSdp(peer, msg);
|
|
302
|
+
case 'ice': return this._handleIce(peer, msg);
|
|
303
|
+
case 'mute':
|
|
304
|
+
case 'unmute': return this._handleMuteState(peer, msg);
|
|
305
|
+
case 'bye': return peer.close(1000, 'bye');
|
|
306
|
+
case 'e2ee-key':return this._handleE2eeKey(peer, msg);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch (err)
|
|
310
|
+
{
|
|
311
|
+
peer.errors++;
|
|
312
|
+
peer.sendError('INTERNAL', err && err.message ? err.message : 'internal error');
|
|
313
|
+
this.emit('wireError', { peer, code: 'INTERNAL', error: err });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** @private */
|
|
318
|
+
_onClose(peer)
|
|
319
|
+
{
|
|
320
|
+
if (!this._peers.has(peer.id)) return;
|
|
321
|
+
const room = peer.room;
|
|
322
|
+
this._peers.delete(peer.id);
|
|
323
|
+
this._rate.delete(peer.id);
|
|
324
|
+
if (room)
|
|
325
|
+
{
|
|
326
|
+
room._remove(peer);
|
|
327
|
+
this.emit('leave', { peer, room });
|
|
328
|
+
// Tell other peers in the room
|
|
329
|
+
room.broadcast('peer-left', { id: peer.id }, peer.id);
|
|
330
|
+
}
|
|
331
|
+
peer.closed = true;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// -- Handlers --
|
|
335
|
+
|
|
336
|
+
/** @private */
|
|
337
|
+
_handleJoin(peer, msg)
|
|
338
|
+
{
|
|
339
|
+
if (typeof msg.room !== 'string' || msg.room.length === 0)
|
|
340
|
+
{
|
|
341
|
+
this.emit('joinFailed', { peer, reason: 'BAD_FRAME' });
|
|
342
|
+
return peer.sendError('BAD_FRAME', 'join.room must be a string');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (peer.room)
|
|
346
|
+
{
|
|
347
|
+
this.emit('joinFailed', { peer, reason: 'ALREADY_JOINED', room: peer.room.name });
|
|
348
|
+
return peer.sendError('ALREADY_JOINED', 'peer is already in a room');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// -- Join-token enforcement --
|
|
352
|
+
if (this.joinTokenSecret)
|
|
353
|
+
{
|
|
354
|
+
if (typeof msg.token !== 'string' || msg.token.length === 0)
|
|
355
|
+
{
|
|
356
|
+
this.emit('joinFailed', { peer, reason: 'TOKEN_REQUIRED', room: msg.room });
|
|
357
|
+
return peer.sendError('TOKEN_REQUIRED', 'join token required');
|
|
358
|
+
}
|
|
359
|
+
let claims;
|
|
360
|
+
try { claims = verifyJoinToken(msg.token, { secret: this.joinTokenSecret, room: msg.room }); }
|
|
361
|
+
catch (err)
|
|
362
|
+
{
|
|
363
|
+
this.emit('joinFailed', { peer, reason: 'INVALID_TOKEN', room: msg.room });
|
|
364
|
+
return peer.sendError('INVALID_TOKEN', err && err.message ? err.message : 'invalid token');
|
|
365
|
+
}
|
|
366
|
+
// Token's user claim wins when the transport didn't already authenticate one.
|
|
367
|
+
if (!peer.user && claims && claims.user) peer.user = claims.user;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let room = this._rooms.get(msg.room);
|
|
371
|
+
if (!room)
|
|
372
|
+
{
|
|
373
|
+
if (!this.autoCreateRooms)
|
|
374
|
+
{
|
|
375
|
+
this.emit('joinFailed', { peer, reason: 'UNKNOWN_ROOM', room: msg.room });
|
|
376
|
+
return peer.sendError('UNKNOWN_ROOM', `no such room "${msg.room}"`);
|
|
377
|
+
}
|
|
378
|
+
room = this.room(msg.room).open();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!room.canJoin(peer))
|
|
382
|
+
{
|
|
383
|
+
this.emit('joinFailed', { peer, reason: 'FORBIDDEN', room: room.name });
|
|
384
|
+
return peer.sendError('FORBIDDEN', `not allowed to join "${room.name}"`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
room._add(peer);
|
|
388
|
+
peer.send('joined', { room: room.name, peerId: peer.id, peers: room.peers().map(p => p.id) });
|
|
389
|
+
room.broadcast('peer-joined', { id: peer.id }, peer.id);
|
|
390
|
+
this.emit('join', { peer, room });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** @private */
|
|
394
|
+
_handleLeave(peer)
|
|
395
|
+
{
|
|
396
|
+
const room = peer.room;
|
|
397
|
+
if (!room) return;
|
|
398
|
+
room._remove(peer);
|
|
399
|
+
peer.send('left', { room: room.name });
|
|
400
|
+
room.broadcast('peer-left', { id: peer.id }, peer.id);
|
|
401
|
+
this.emit('leave', { peer, room });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** @private */
|
|
405
|
+
_handleSdp(peer, msg)
|
|
406
|
+
{
|
|
407
|
+
if (typeof msg.sdp !== 'string')
|
|
408
|
+
return peer.sendError('BAD_FRAME', `${msg.type}.sdp must be a string`);
|
|
409
|
+
if (msg.sdp.length > this.maxSdpSize)
|
|
410
|
+
return peer.sendError('SDP_TOO_LARGE', `sdp exceeds ${this.maxSdpSize} bytes`);
|
|
411
|
+
|
|
412
|
+
const structErr = _validateSdpStructure(msg.sdp);
|
|
413
|
+
if (structErr) return peer.sendError(structErr, 'sdp failed validation');
|
|
414
|
+
|
|
415
|
+
if (_countCandidatesInSdp(msg.sdp) > this.maxCandidatesPerOffer)
|
|
416
|
+
return peer.sendError('TOO_MANY_CANDIDATES', `>${this.maxCandidatesPerOffer} candidates`);
|
|
417
|
+
|
|
418
|
+
if (!peer.room)
|
|
419
|
+
return peer.sendError('NOT_IN_ROOM', 'peer must join a room first');
|
|
420
|
+
|
|
421
|
+
if (typeof msg.target !== 'string')
|
|
422
|
+
return peer.sendError('BAD_FRAME', `${msg.type}.target must be a string`);
|
|
423
|
+
|
|
424
|
+
const target = this._peers.get(msg.target);
|
|
425
|
+
const remote = (!target && this._cluster) ? this._cluster.locate(msg.target) : null;
|
|
426
|
+
const targetRoomName = target ? (target.room && target.room.name) : (remote ? remote.room : null);
|
|
427
|
+
if (!targetRoomName || targetRoomName !== peer.room.name)
|
|
428
|
+
return peer.sendError('TARGET_NOT_IN_ROOM', `peer "${msg.target}" not in this room`);
|
|
429
|
+
|
|
430
|
+
// Publish / subscribe gates
|
|
431
|
+
if (msg.type === 'offer' && peer.room._canPublish && !peer.room._canPublish(peer))
|
|
432
|
+
{
|
|
433
|
+
this.emit('publishFailed', { peer, reason: 'FORBIDDEN', room: peer.room.name });
|
|
434
|
+
return peer.sendError('FORBIDDEN', 'peer may not publish');
|
|
435
|
+
}
|
|
436
|
+
if (msg.type === 'answer' && peer.room._canSubscribe && !peer.room._canSubscribe(peer))
|
|
437
|
+
{
|
|
438
|
+
this.emit('subscribeFailed', { peer, reason: 'FORBIDDEN', room: peer.room.name });
|
|
439
|
+
return peer.sendError('FORBIDDEN', 'peer may not subscribe');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Advance JSEP state machine
|
|
443
|
+
if (msg.type === 'offer')
|
|
444
|
+
{
|
|
445
|
+
peer.state = PEER_STATE.HAVE_LOCAL_OFFER;
|
|
446
|
+
if (target) target.state = PEER_STATE.HAVE_REMOTE_OFFER;
|
|
447
|
+
}
|
|
448
|
+
else // answer
|
|
449
|
+
{
|
|
450
|
+
peer.state = PEER_STATE.STABLE;
|
|
451
|
+
if (target) target.state = PEER_STATE.STABLE;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (target)
|
|
455
|
+
target.send(msg.type, { from: peer.id, sdp: msg.sdp });
|
|
456
|
+
else
|
|
457
|
+
this._cluster.routeDirect(msg.target, msg.type, { from: peer.id, sdp: msg.sdp });
|
|
458
|
+
this.emit(msg.type, { peer, target: target || null, room: peer.room, sdp: msg.sdp });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** @private */
|
|
462
|
+
_handleIce(peer, msg)
|
|
463
|
+
{
|
|
464
|
+
if (typeof msg.candidate !== 'string' || msg.candidate.length === 0)
|
|
465
|
+
return peer.sendError('BAD_FRAME', 'ice.candidate must be a non-empty string');
|
|
466
|
+
|
|
467
|
+
// Validate candidate. parseCandidate expects the wire form with or without "a=" prefix.
|
|
468
|
+
const line = msg.candidate.startsWith('candidate:') ? msg.candidate : ('candidate:' + msg.candidate.replace(/^a=candidate:/, ''));
|
|
469
|
+
try { parseCandidate(line); }
|
|
470
|
+
catch (err)
|
|
471
|
+
{
|
|
472
|
+
if (err instanceof IceError) return peer.sendError('INVALID_ICE', err.message);
|
|
473
|
+
return peer.sendError('INVALID_ICE', 'malformed candidate');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!peer.room) return peer.sendError('NOT_IN_ROOM', 'peer must join a room first');
|
|
477
|
+
if (typeof msg.target !== 'string') return peer.sendError('BAD_FRAME', 'ice.target must be a string');
|
|
478
|
+
|
|
479
|
+
const target = this._peers.get(msg.target);
|
|
480
|
+
const remote = (!target && this._cluster) ? this._cluster.locate(msg.target) : null;
|
|
481
|
+
const targetRoomName = target ? (target.room && target.room.name) : (remote ? remote.room : null);
|
|
482
|
+
if (!targetRoomName || targetRoomName !== peer.room.name)
|
|
483
|
+
return peer.sendError('TARGET_NOT_IN_ROOM', `peer "${msg.target}" not in this room`);
|
|
484
|
+
|
|
485
|
+
if (target)
|
|
486
|
+
target.send('ice', { from: peer.id, candidate: msg.candidate });
|
|
487
|
+
else
|
|
488
|
+
this._cluster.routeDirect(msg.target, 'ice', { from: peer.id, candidate: msg.candidate });
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** @private */
|
|
492
|
+
_handleMuteState(peer, msg)
|
|
493
|
+
{
|
|
494
|
+
if (!peer.room) return peer.sendError('NOT_IN_ROOM', 'peer must join a room first');
|
|
495
|
+
const kind = msg.kind === 'video' ? 'video' : 'audio';
|
|
496
|
+
peer.room.broadcast(msg.type, { from: peer.id, kind }, peer.id);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** @private */
|
|
500
|
+
_handleE2eeKey(peer, msg)
|
|
501
|
+
{
|
|
502
|
+
if (!peer.room) return peer.sendError('NOT_IN_ROOM', 'peer must join a room first');
|
|
503
|
+
if (typeof msg.epoch !== 'number' || typeof msg.key !== 'string')
|
|
504
|
+
return peer.sendError('BAD_FRAME', 'e2ee-key requires {epoch:number, key:string}');
|
|
505
|
+
peer.room.broadcast('e2ee-key', { from: peer.id, epoch: msg.epoch, key: msg.key }, peer.id);
|
|
506
|
+
this.emit('e2eeKey', { peer, room: peer.room, epoch: msg.epoch, key: msg.key });
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// -- Rate limiter --
|
|
510
|
+
|
|
511
|
+
/** @private */
|
|
512
|
+
_allowRate(peer)
|
|
513
|
+
{
|
|
514
|
+
const now = Date.now();
|
|
515
|
+
const win = this._rate.get(peer.id) || [];
|
|
516
|
+
// Drop entries older than 1 s
|
|
517
|
+
while (win.length && (now - win[0]) > 1000) win.shift();
|
|
518
|
+
win.push(now);
|
|
519
|
+
this._rate.set(peer.id, win);
|
|
520
|
+
return win.length <= this.peerMessageRate;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** @private Per-IP attach throttle. */
|
|
524
|
+
_allowIpAttach(ip)
|
|
525
|
+
{
|
|
526
|
+
const now = Date.now();
|
|
527
|
+
const win = this._ipAttachLog.get(ip) || [];
|
|
528
|
+
const cutoff = now - (IP_ATTACH_WINDOW_SEC * 1000);
|
|
529
|
+
while (win.length && win[0] < cutoff) win.shift();
|
|
530
|
+
win.push(now);
|
|
531
|
+
this._ipAttachLog.set(ip, win);
|
|
532
|
+
return win.length <= this.ipAttachRate;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** @private Disconnect a peer that has exceeded the protocol-error budget. */
|
|
536
|
+
_checkErrorBackoff(peer)
|
|
537
|
+
{
|
|
538
|
+
if (this.maxProtocolErrors > 0 && peer.errors >= this.maxProtocolErrors)
|
|
539
|
+
{
|
|
540
|
+
peer.sendError('TOO_MANY_ERRORS', 'protocol-error budget exhausted');
|
|
541
|
+
peer.close(1008, 'too-many-errors');
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
module.exports = { SignalingHub, Room, Peer, PEER_STATE };
|