@rapidmx/meet-plugin 0.6.0 → 0.8.0
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 +15 -2
- package/apps/meet/[token].tsx +3 -2
- package/apps/meet/_CallControls.tsx +28 -2
- package/apps/meet/_CallView.tsx +23 -2
- package/apps/meet/_EffectsPanel.tsx +134 -0
- package/apps/meet/_MeetLobby.tsx +22 -0
- package/apps/meet/_ParticipantTile.tsx +62 -9
- package/apps/meet/_icons.tsx +4 -0
- package/apps/meet/_meetApi.ts +6 -0
- package/apps/shared/media/filters/VideoFilterProcessor.ts +383 -0
- package/apps/shared/media/filters/accessories.ts +262 -0
- package/apps/shared/media/filters/backgroundImage.ts +69 -0
- package/apps/shared/media/filters/filterTypes.ts +89 -0
- package/apps/shared/media/filters/mlModels.ts +174 -0
- package/apps/shared/media/filters/pixelEffects.ts +61 -0
- package/apps/shared/media/mediaPreferences.ts +105 -0
- package/apps/shared/media/useLocalMedia.ts +198 -14
- package/apps/shared/relay/AudioPlayer.ts +111 -0
- package/apps/shared/relay/AudioSender.ts +194 -0
- package/apps/shared/relay/RelayClient.ts +262 -0
- package/apps/shared/relay/RelayReceiver.ts +72 -0
- package/apps/shared/relay/RelaySender.ts +74 -0
- package/apps/shared/relay/RelayTransport.ts +188 -0
- package/apps/shared/relay/VideoPlayer.ts +110 -0
- package/apps/shared/relay/VideoSender.ts +216 -0
- package/apps/shared/relay/audioResume.ts +55 -0
- package/apps/shared/relay/frames.ts +275 -0
- package/apps/shared/relay/relayEnv.ts +230 -0
- package/apps/shared/relay/senderCommon.ts +30 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +158 -6
- package/apps/shared/webrtc/realPeerConnection.ts +22 -0
- package/apps/shared/webrtc/types.ts +32 -2
- package/dist/apps/meet/[token].js +3 -3
- package/dist/apps/meet/_CallControls.d.ts +2 -1
- package/dist/apps/meet/_CallControls.js +6 -3
- package/dist/apps/meet/_CallView.d.ts +4 -1
- package/dist/apps/meet/_CallView.js +17 -3
- package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
- package/dist/apps/meet/_EffectsPanel.js +60 -0
- package/dist/apps/meet/_MeetLobby.d.ts +4 -0
- package/dist/apps/meet/_MeetLobby.js +8 -1
- package/dist/apps/meet/_ParticipantTile.d.ts +7 -1
- package/dist/apps/meet/_ParticipantTile.js +20 -3
- package/dist/apps/meet/_icons.d.ts +1 -0
- package/dist/apps/meet/_icons.js +1 -0
- package/dist/apps/meet/_meetApi.d.ts +6 -0
- package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +75 -0
- package/dist/apps/shared/media/filters/VideoFilterProcessor.js +304 -0
- package/dist/apps/shared/media/filters/accessories.d.ts +37 -0
- package/dist/apps/shared/media/filters/accessories.js +211 -0
- package/dist/apps/shared/media/filters/backgroundImage.d.ts +22 -0
- package/dist/apps/shared/media/filters/backgroundImage.js +54 -0
- package/dist/apps/shared/media/filters/filterTypes.d.ts +40 -0
- package/dist/apps/shared/media/filters/filterTypes.js +63 -0
- package/dist/apps/shared/media/filters/mlModels.d.ts +54 -0
- package/dist/apps/shared/media/filters/mlModels.js +106 -0
- package/dist/apps/shared/media/filters/pixelEffects.d.ts +14 -0
- package/dist/apps/shared/media/filters/pixelEffects.js +55 -0
- package/dist/apps/shared/media/mediaPreferences.d.ts +34 -0
- package/dist/apps/shared/media/mediaPreferences.js +93 -0
- package/dist/apps/shared/media/useLocalMedia.d.ts +17 -1
- package/dist/apps/shared/media/useLocalMedia.js +160 -14
- package/dist/apps/shared/relay/AudioPlayer.d.ts +38 -0
- package/dist/apps/shared/relay/AudioPlayer.js +103 -0
- package/dist/apps/shared/relay/AudioSender.d.ts +50 -0
- package/dist/apps/shared/relay/AudioSender.js +172 -0
- package/dist/apps/shared/relay/RelayClient.d.ts +65 -0
- package/dist/apps/shared/relay/RelayClient.js +236 -0
- package/dist/apps/shared/relay/RelayReceiver.d.ts +21 -0
- package/dist/apps/shared/relay/RelayReceiver.js +59 -0
- package/dist/apps/shared/relay/RelaySender.d.ts +34 -0
- package/dist/apps/shared/relay/RelaySender.js +54 -0
- package/dist/apps/shared/relay/RelayTransport.d.ts +38 -0
- package/dist/apps/shared/relay/RelayTransport.js +142 -0
- package/dist/apps/shared/relay/VideoPlayer.d.ts +31 -0
- package/dist/apps/shared/relay/VideoPlayer.js +102 -0
- package/dist/apps/shared/relay/VideoSender.d.ts +56 -0
- package/dist/apps/shared/relay/VideoSender.js +204 -0
- package/dist/apps/shared/relay/audioResume.d.ts +12 -0
- package/dist/apps/shared/relay/audioResume.js +45 -0
- package/dist/apps/shared/relay/frames.d.ts +101 -0
- package/dist/apps/shared/relay/frames.js +221 -0
- package/dist/apps/shared/relay/relayEnv.d.ts +181 -0
- package/dist/apps/shared/relay/relayEnv.js +69 -0
- package/dist/apps/shared/relay/senderCommon.d.ts +19 -0
- package/dist/apps/shared/relay/senderCommon.js +10 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +51 -1
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +137 -6
- package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
- package/dist/apps/shared/webrtc/realPeerConnection.js +20 -0
- package/dist/apps/shared/webrtc/types.d.ts +29 -2
- package/dist/lib/index.js +2 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/routes/BaseVideoMeetingRoute.js +237 -3
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/EffectsUtils.js +21 -0
- package/dist/lib/util/EffectsUtils.js.map +1 -0
- package/dist/lib/util/IceServerUtils.js +25 -4
- package/dist/lib/util/IceServerUtils.js.map +1 -1
- package/dist/lib/util/RedisRelayBus.js +241 -0
- package/dist/lib/util/RedisRelayBus.js.map +1 -0
- package/dist/lib/util/RelayHub.js +405 -0
- package/dist/lib/util/RelayHub.js.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +79 -0
- package/dist/types/util/EffectsUtils.d.ts +10 -0
- package/dist/types/util/IceServerUtils.d.ts +12 -1
- package/dist/types/util/RedisRelayBus.d.ts +131 -0
- package/dist/types/util/RelayHub.d.ts +188 -0
- package/package.json +23 -6
|
@@ -61,6 +61,27 @@
|
|
|
61
61
|
* `presenter-release`) once it observes the winning claim. See `handlePresenterClaim()`.
|
|
62
62
|
*
|
|
63
63
|
* Sharing a screen is `setLocalTrack("video", screenTrack)`; stopping is `setLocalTrack("video", cameraTrack)`.
|
|
64
|
+
*
|
|
65
|
+
* ## Three media paths, tried in order
|
|
66
|
+
*
|
|
67
|
+
* Each pair of participants reaches for the best path that works, and `MeshParticipant.transport` says which one it
|
|
68
|
+
* got:
|
|
69
|
+
*
|
|
70
|
+
* 1. **Direct peer-to-peer** (`"p2p"`). The `RTCPeerConnection` is given the STUN servers and the TURN server
|
|
71
|
+
* together, and ICE ranks a direct pair above a relayed one, so this is what a pair gets whenever it can.
|
|
72
|
+
* 2. **Relayed through the TURN server** (`"turn"`). The same connection, on the relay candidate ICE falls back to
|
|
73
|
+
* when the direct pairs fail - a participant behind a symmetric NAT or a firewall that blocks direct UDP. Which of
|
|
74
|
+
* the two ICE picked is read from the selected candidate pair once connected (`RTCPeerConnectionLike.connectionType()`).
|
|
75
|
+
* 3. **Proxied by this server over a WebSocket** (`"websocket"`, `apps/shared/relay/`). For a participant whose
|
|
76
|
+
* network lets nothing but ordinary HTTPS out, neither of the above ever connects. A pair whose connection has not
|
|
77
|
+
* come up within `connectTimeoutMs`, has failed, or has stayed `disconnected` for `disconnectedGraceMs` gives up
|
|
78
|
+
* on WebRTC and switches to it, telling the other side with a `relay-fallback` message so both switch even when
|
|
79
|
+
* only one of them noticed. Nothing is renegotiated - the pair simply stops using the `RTCPeerConnection` - and the
|
|
80
|
+
* relay's stream replaces the one WebRTC would have filled (a `remote-stream` event again). When the relay is
|
|
81
|
+
* unavailable (turned off by the operator, or a browser without WebCodecs) the pair is marked `"failed"` and stays
|
|
82
|
+
* in the roster, rather than the participant vanishing with no explanation as it used to.
|
|
83
|
+
*
|
|
84
|
+
* A pair never moves back up: once on the relay it stays there for the rest of the call.
|
|
64
85
|
*/
|
|
65
86
|
import { REACTION_EMOJIS, } from "./types.js";
|
|
66
87
|
/** The lexicographically smaller uid is always the offerer for that pair - see this module's doc comment. */
|
|
@@ -71,6 +92,12 @@ export function isOfferer(selfId, peerId) {
|
|
|
71
92
|
* misbehaving participant could make this hold for peers that never materialize. */
|
|
72
93
|
const MAX_ORPHAN_PEERS = 32;
|
|
73
94
|
const MAX_ORPHAN_CANDIDATES = 64;
|
|
95
|
+
/** How long a connection may stay in `connecting` before the pair gives up on WebRTC. ICE with an unreachable TURN
|
|
96
|
+
* server can take the better part of a minute to report `failed`; a person waiting on a call will not. It is still long
|
|
97
|
+
* enough for a slow TURN allocation and a few round trips of signaling. */
|
|
98
|
+
export const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
|
|
99
|
+
/** How long a connection may stay `disconnected` (ICE lost the path but may still recover) before giving up on it. */
|
|
100
|
+
export const DEFAULT_DISCONNECTED_GRACE_MS = 8000;
|
|
74
101
|
export class MeshConnectionManager {
|
|
75
102
|
constructor(options) {
|
|
76
103
|
this.options = options;
|
|
@@ -87,6 +114,8 @@ export class MeshConnectionManager {
|
|
|
87
114
|
handRaised: false,
|
|
88
115
|
...options.localState,
|
|
89
116
|
};
|
|
117
|
+
options.relay?.setLocalTrack("audio", this.localTracks.audio);
|
|
118
|
+
options.relay?.setLocalTrack("video", this.localTracks.video);
|
|
90
119
|
}
|
|
91
120
|
get participants() {
|
|
92
121
|
return [...this.peers.values()].map((peer) => toParticipant(peer));
|
|
@@ -122,11 +151,13 @@ export class MeshConnectionManager {
|
|
|
122
151
|
this.unsubscribe?.();
|
|
123
152
|
this.unsubscribe = undefined;
|
|
124
153
|
for (const peer of this.peers.values()) {
|
|
154
|
+
this.clearTimers(peer);
|
|
125
155
|
peer.pc.close();
|
|
126
156
|
}
|
|
127
157
|
this.peers.clear();
|
|
128
158
|
this.orphanCandidates.clear();
|
|
129
159
|
this.listeners.clear();
|
|
160
|
+
this.options.relay?.close();
|
|
130
161
|
}
|
|
131
162
|
/** Sets the track sent as `kind` (`null` to send nothing) on every connection, now and for every peer that
|
|
132
163
|
* joins later - see this module's doc comment. */
|
|
@@ -135,6 +166,7 @@ export class MeshConnectionManager {
|
|
|
135
166
|
for (const peer of this.peers.values()) {
|
|
136
167
|
void peer.senders[kind]?.replaceTrack(track);
|
|
137
168
|
}
|
|
169
|
+
this.options.relay?.setLocalTrack(kind, track);
|
|
138
170
|
}
|
|
139
171
|
/** Updates what this participant announces (`audioOn`/`videoOn`/`handRaised`) and tells everyone if anything
|
|
140
172
|
* actually changed. Before `start()` it only records the state, which `hello` then carries. */
|
|
@@ -231,6 +263,9 @@ export class MeshConnectionManager {
|
|
|
231
263
|
case "reaction":
|
|
232
264
|
this.handleReaction(message);
|
|
233
265
|
return;
|
|
266
|
+
case "relay-fallback":
|
|
267
|
+
this.handleRelayFallback(message.from);
|
|
268
|
+
return;
|
|
234
269
|
}
|
|
235
270
|
}
|
|
236
271
|
handleHello(message) {
|
|
@@ -288,8 +323,13 @@ export class MeshConnectionManager {
|
|
|
288
323
|
if (!peer) {
|
|
289
324
|
return;
|
|
290
325
|
}
|
|
326
|
+
this.clearTimers(peer);
|
|
291
327
|
peer.pc.close();
|
|
292
328
|
this.peers.delete(uid);
|
|
329
|
+
if (peer.transport === "websocket") {
|
|
330
|
+
this.options.relay?.stopReceivingFrom(uid);
|
|
331
|
+
this.syncRelaySending();
|
|
332
|
+
}
|
|
293
333
|
this.emit({ type: "participant-left", uid });
|
|
294
334
|
if (this.currentPresenterUid === uid) {
|
|
295
335
|
this.currentPresenterUid = undefined;
|
|
@@ -312,6 +352,9 @@ export class MeshConnectionManager {
|
|
|
312
352
|
remoteStream: (this.options.createMediaStream ?? (() => new MediaStream()))(),
|
|
313
353
|
remoteDescriptionSet: false,
|
|
314
354
|
pendingCandidates: this.orphanCandidates.get(uid) ?? [],
|
|
355
|
+
transport: "connecting",
|
|
356
|
+
connectTimer: undefined,
|
|
357
|
+
disconnectTimer: undefined,
|
|
315
358
|
};
|
|
316
359
|
this.orphanCandidates.delete(uid);
|
|
317
360
|
this.peers.set(uid, peer);
|
|
@@ -326,13 +369,101 @@ export class MeshConnectionManager {
|
|
|
326
369
|
}
|
|
327
370
|
this.emit({ type: "remote-stream", uid, stream: peer.remoteStream });
|
|
328
371
|
};
|
|
329
|
-
pc.onconnectionstatechange = () =>
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
};
|
|
372
|
+
pc.onconnectionstatechange = () => this.handleConnectionState(peer);
|
|
373
|
+
// Cleared the moment the connection comes up, so this only ever fires for a pair still waiting on WebRTC.
|
|
374
|
+
peer.connectTimer = setTimeout(() => this.fallBack(peer), this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
|
|
334
375
|
return peer;
|
|
335
376
|
}
|
|
377
|
+
/** Reacts to the peer connection's state: notes which path a connected pair got, and gives up on WebRTC for a
|
|
378
|
+
* pair that failed or stays disconnected - see `fallBack()`. Ignores a pair that already left WebRTC. */
|
|
379
|
+
handleConnectionState(peer) {
|
|
380
|
+
if (peer.transport === "websocket" || this.peers.get(peer.uid) !== peer) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
switch (peer.pc.connectionState) {
|
|
384
|
+
case "connected":
|
|
385
|
+
this.clearTimers(peer);
|
|
386
|
+
void this.detectTransport(peer);
|
|
387
|
+
return;
|
|
388
|
+
case "disconnected":
|
|
389
|
+
// ICE often recovers from this on its own, so it is only given up on after a grace period.
|
|
390
|
+
peer.disconnectTimer ?? (peer.disconnectTimer = setTimeout(() => this.fallBack(peer), this.options.disconnectedGraceMs ?? DEFAULT_DISCONNECTED_GRACE_MS));
|
|
391
|
+
return;
|
|
392
|
+
case "failed":
|
|
393
|
+
this.fallBack(peer);
|
|
394
|
+
return;
|
|
395
|
+
case "closed":
|
|
396
|
+
this.handleBye(peer.uid);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/** Records whether a connected pair is direct or relayed through TURN, from the candidate pair ICE selected. */
|
|
401
|
+
async detectTransport(peer) {
|
|
402
|
+
let type;
|
|
403
|
+
try {
|
|
404
|
+
type = await peer.pc.connectionType();
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
type = "unknown";
|
|
408
|
+
}
|
|
409
|
+
// The pair may have moved on (fallen back, left, or dropped again) while the stats were being read.
|
|
410
|
+
if (peer.transport !== "websocket" && this.peers.get(peer.uid) === peer && peer.pc.connectionState === "connected") {
|
|
411
|
+
this.setTransport(peer, type === "turn" ? "turn" : "p2p");
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
setTransport(peer, transport) {
|
|
415
|
+
if (peer.transport !== transport) {
|
|
416
|
+
peer.transport = transport;
|
|
417
|
+
this.emit({ type: "participant-updated", participant: toParticipant(peer) });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
clearTimers(peer) {
|
|
421
|
+
clearTimeout(peer.connectTimer);
|
|
422
|
+
clearTimeout(peer.disconnectTimer);
|
|
423
|
+
peer.connectTimer = undefined;
|
|
424
|
+
peer.disconnectTimer = undefined;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Gives up on WebRTC for `peer` and moves the pair to the WebSocket relay (the third path - see this module's doc
|
|
428
|
+
* comment), or marks it `"failed"` when there is no relay to move to. `notify` tells the other side, which may not
|
|
429
|
+
* have noticed yet; a pair told by the other side does not tell it back.
|
|
430
|
+
*/
|
|
431
|
+
fallBack(peer, notify = true) {
|
|
432
|
+
if (peer.transport === "websocket" || this.peers.get(peer.uid) !== peer) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
this.clearTimers(peer);
|
|
436
|
+
const relay = this.options.relay;
|
|
437
|
+
if (!relay?.supported) {
|
|
438
|
+
this.setTransport(peer, "failed");
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
// Stop listening to the dead connection before closing it, so closing it cannot be mistaken for the peer leaving.
|
|
442
|
+
peer.pc.onconnectionstatechange = null;
|
|
443
|
+
peer.pc.onicecandidate = null;
|
|
444
|
+
peer.pc.ontrack = null;
|
|
445
|
+
peer.pc.close();
|
|
446
|
+
peer.transport = "websocket";
|
|
447
|
+
relay.receiveFrom(peer.uid, (stream) => {
|
|
448
|
+
peer.remoteStream = stream;
|
|
449
|
+
this.emit({ type: "remote-stream", uid: peer.uid, stream });
|
|
450
|
+
});
|
|
451
|
+
this.syncRelaySending();
|
|
452
|
+
this.emit({ type: "participant-updated", participant: toParticipant(peer) });
|
|
453
|
+
if (notify) {
|
|
454
|
+
this.send({ kind: "relay-fallback", to: peer.uid });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
handleRelayFallback(from) {
|
|
458
|
+
const peer = this.peers.get(from);
|
|
459
|
+
if (peer) {
|
|
460
|
+
this.fallBack(peer, false);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/** The relay only encodes and uploads the local media while at least one pair is actually using it. */
|
|
464
|
+
syncRelaySending() {
|
|
465
|
+
this.options.relay?.setSending([...this.peers.values()].some((peer) => peer.transport === "websocket"));
|
|
466
|
+
}
|
|
336
467
|
async initiateOffer(peer) {
|
|
337
468
|
const offer = await peer.pc.createOffer();
|
|
338
469
|
await peer.pc.setLocalDescription(offer);
|
|
@@ -438,7 +569,7 @@ export class MeshConnectionManager {
|
|
|
438
569
|
}
|
|
439
570
|
}
|
|
440
571
|
function toParticipant(peer) {
|
|
441
|
-
return { uid: peer.uid, name: peer.name, audioOn: peer.audioOn, videoOn: peer.videoOn, handRaised: peer.handRaised };
|
|
572
|
+
return { uid: peer.uid, name: peer.name, audioOn: peer.audioOn, videoOn: peer.videoOn, handRaised: peer.handRaised, transport: peer.transport };
|
|
442
573
|
}
|
|
443
574
|
function sameState(a, b) {
|
|
444
575
|
return a.audioOn === b.audioOn && a.videoOn === b.videoOn && a.handRaised === b.handRaised;
|
|
@@ -4,4 +4,11 @@
|
|
|
4
4
|
* module scope, so importing this file is safe during this plugin's page SSR, where `RTCPeerConnection` does not
|
|
5
5
|
* exist at all - it would only throw if actually *called* outside a browser. */
|
|
6
6
|
import type { RTCPeerConnectionFactory } from "./types.js";
|
|
7
|
+
/**
|
|
8
|
+
* Whether the pair ICE settled on goes through a TURN relay. The selected pair is found the standard way (the
|
|
9
|
+
* `transport` report's `selectedCandidatePairId`) and, for a browser that does not fill that in, as the succeeded and
|
|
10
|
+
* nominated `candidate-pair`. A relay on *either* end means the media passes through a TURN server, so it counts as
|
|
11
|
+
* `"turn"`; `"unknown"` when no pair is reported at all.
|
|
12
|
+
*/
|
|
13
|
+
export declare function selectedConnectionType(pc: Pick<RTCPeerConnection, "getStats">): Promise<"p2p" | "turn" | "unknown">;
|
|
7
14
|
export declare const createBrowserPeerConnection: RTCPeerConnectionFactory;
|
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the pair ICE settled on goes through a TURN relay. The selected pair is found the standard way (the
|
|
3
|
+
* `transport` report's `selectedCandidatePairId`) and, for a browser that does not fill that in, as the succeeded and
|
|
4
|
+
* nominated `candidate-pair`. A relay on *either* end means the media passes through a TURN server, so it counts as
|
|
5
|
+
* `"turn"`; `"unknown"` when no pair is reported at all.
|
|
6
|
+
*/
|
|
7
|
+
export async function selectedConnectionType(pc) {
|
|
8
|
+
const reports = new Map();
|
|
9
|
+
(await pc.getStats()).forEach((report) => reports.set(String(report.id), report));
|
|
10
|
+
const transport = [...reports.values()].find((report) => report.type === "transport" && report.selectedCandidatePairId);
|
|
11
|
+
const pair = (transport ? reports.get(String(transport.selectedCandidatePairId)) : undefined) ??
|
|
12
|
+
[...reports.values()].find((report) => report.type === "candidate-pair" && (report.selected || (report.nominated && report.state === "succeeded")));
|
|
13
|
+
if (!pair) {
|
|
14
|
+
return "unknown";
|
|
15
|
+
}
|
|
16
|
+
const local = reports.get(String(pair.localCandidateId));
|
|
17
|
+
const remote = reports.get(String(pair.remoteCandidateId));
|
|
18
|
+
return local?.candidateType === "relay" || remote?.candidateType === "relay" ? "turn" : "p2p";
|
|
19
|
+
}
|
|
1
20
|
export const createBrowserPeerConnection = (config) => {
|
|
2
21
|
const pc = new RTCPeerConnection(config);
|
|
3
22
|
const like = {
|
|
@@ -22,6 +41,7 @@ export const createBrowserPeerConnection = (config) => {
|
|
|
22
41
|
setRemoteDescription: (description) => pc.setRemoteDescription(description),
|
|
23
42
|
addIceCandidate: (candidate) => pc.addIceCandidate(candidate),
|
|
24
43
|
close: () => pc.close(),
|
|
44
|
+
connectionType: () => selectedConnectionType(pc),
|
|
25
45
|
onicecandidate: null,
|
|
26
46
|
ontrack: null,
|
|
27
47
|
onconnectionstatechange: null,
|
|
@@ -28,7 +28,7 @@ export declare const REACTION_EMOJIS: readonly ["👍", "👏", "❤️", "🎉"
|
|
|
28
28
|
*/
|
|
29
29
|
export interface SignalMessage {
|
|
30
30
|
type: "video-meeting-signal";
|
|
31
|
-
kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release" | "state" | "reaction";
|
|
31
|
+
kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release" | "state" | "reaction" | "relay-fallback";
|
|
32
32
|
/** The sender's authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
|
|
33
33
|
* the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server refuses a published message whose
|
|
34
34
|
* `from` isn't the authenticated caller's uid (it stops one participant speaking as another), so this must be
|
|
@@ -39,7 +39,7 @@ export interface SignalMessage {
|
|
|
39
39
|
* ignored, so it can't name someone else's tab. Absent from a sender that predates it (the participant is then
|
|
40
40
|
* identified by `from`). */
|
|
41
41
|
peer?: string;
|
|
42
|
-
/** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`). */
|
|
42
|
+
/** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`/`relay-fallback`). */
|
|
43
43
|
to?: string;
|
|
44
44
|
/** `hello` only - the display name the sender chose in the lobby. */
|
|
45
45
|
name?: string;
|
|
@@ -99,6 +99,31 @@ export interface RTCPeerConnectionLike {
|
|
|
99
99
|
}) => void) | null;
|
|
100
100
|
onconnectionstatechange: (() => void) | null;
|
|
101
101
|
connectionState: string;
|
|
102
|
+
/** Which path the connected pair is using - read from the selected candidate pair once `connectionState` is
|
|
103
|
+
* `connected`. `"unknown"` when the browser does not report one (treated as direct by the caller). */
|
|
104
|
+
connectionType(): Promise<"p2p" | "turn" | "unknown">;
|
|
105
|
+
}
|
|
106
|
+
/** Which of the three media paths a participant's audio and video currently take, in the order they are tried:
|
|
107
|
+
*
|
|
108
|
+
* - `"p2p"`: a direct peer-to-peer WebRTC connection, the preferred path.
|
|
109
|
+
* - `"turn"`: still WebRTC, but relayed through the TURN server, for a participant a direct path cannot reach.
|
|
110
|
+
* - `"websocket"`: not WebRTC at all - encoded frames proxied by this server over a WebSocket (see
|
|
111
|
+
* `apps/shared/relay/`), the last resort when neither of the above connects.
|
|
112
|
+
* - `"connecting"`: no path established yet. `"failed"`: every path this browser can try has failed. */
|
|
113
|
+
export type MediaTransport = "connecting" | "p2p" | "turn" | "websocket" | "failed";
|
|
114
|
+
/** What `MeshConnectionManager` needs from the WebSocket media relay (`apps/shared/relay/RelayTransport.ts` implements
|
|
115
|
+
* it) - declared here so the mesh, and its tests, depend on the shape alone. */
|
|
116
|
+
export interface RelayTransportLike {
|
|
117
|
+
/** `false` when this browser cannot do it (no WebSocket, no WebCodecs) - the mesh then never falls back to it. */
|
|
118
|
+
readonly supported: boolean;
|
|
119
|
+
/** Starts receiving `peerId`'s media. `onStream` is given the `MediaStream` that plays it. */
|
|
120
|
+
receiveFrom(peerId: string, onStream: (stream: MediaStream) => void): void;
|
|
121
|
+
stopReceivingFrom(peerId: string): void;
|
|
122
|
+
/** Starts or stops publishing the local tracks to the relay. */
|
|
123
|
+
setSending(active: boolean): void;
|
|
124
|
+
/** Same as `MeshConnectionManager.setLocalTrack()` - `null` sends nothing for that kind. */
|
|
125
|
+
setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void;
|
|
126
|
+
close(): void;
|
|
102
127
|
}
|
|
103
128
|
export type RTCPeerConnectionFactory = (config: {
|
|
104
129
|
iceServers: RTCIceServer[];
|
|
@@ -108,6 +133,8 @@ export type RTCPeerConnectionFactory = (config: {
|
|
|
108
133
|
export interface MeshParticipant extends ParticipantState {
|
|
109
134
|
uid: string;
|
|
110
135
|
name: string;
|
|
136
|
+
/** How this participant's media currently reaches the local one - see `MediaTransport`. */
|
|
137
|
+
transport: MediaTransport;
|
|
111
138
|
}
|
|
112
139
|
export type MeshEvent = {
|
|
113
140
|
type: "participant-joined";
|
package/dist/lib/index.js
CHANGED
|
@@ -19,6 +19,8 @@ export * from "./models/types.js";
|
|
|
19
19
|
export * from "./util/BookingIntegrationUtils.js";
|
|
20
20
|
export * from "./util/IceServerUtils.js";
|
|
21
21
|
export * from "./util/PublicUrlUtils.js";
|
|
22
|
+
export * from "./util/RedisRelayBus.js";
|
|
23
|
+
export * from "./util/RelayHub.js";
|
|
22
24
|
export * from "./util/RouteAccessUtils.js";
|
|
23
25
|
export * from "./util/TokenUtils.js";
|
|
24
26
|
export * from "./routes/BaseVideoMeetingRoute.js";
|
package/dist/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,mEAAmE;AACnE,mCAAmC;AACnC,+EAA+E;AAC/E;;;;;;;;;;;;GAYG;AACH,cAAc,mBAAmB,CAAC;AAClC,cAAc,mCAAmC,CAAC;AAClD,cAAc,0BAA0B,CAAC;AACzC,cAAc,0BAA0B,CAAC;AACzC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AACrC,cAAc,mCAAmC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,mEAAmE;AACnE,mCAAmC;AACnC,+EAA+E;AAC/E;;;;;;;;;;;;GAYG;AACH,cAAc,mBAAmB,CAAC;AAClC,cAAc,mCAAmC,CAAC;AAClD,cAAc,0BAA0B,CAAC;AACzC,cAAc,0BAA0B,CAAC;AACzC,cAAc,yBAAyB,CAAC;AACxC,cAAc,oBAAoB,CAAC;AACnC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AACrC,cAAc,mCAAmC,CAAC"}
|
|
@@ -17,15 +17,30 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
|
17
17
|
import * as crypto from "crypto";
|
|
18
18
|
import { ApiError, JWTUtils, ObjectDecorators } from "@rapidrest/core";
|
|
19
19
|
import { ACLAction, ACLUtils, ApiErrorMessages, ApiErrors, DatabaseDecorators, DocDecorators, ModelUtils, RepoUtils, RouteDecorators, } from "@rapidrest/service-core";
|
|
20
|
+
import { parseEffectsAssetsUrl } from "../util/EffectsUtils.js";
|
|
20
21
|
import { buildIceServers } from "../util/IceServerUtils.js";
|
|
21
22
|
import { buildBaseUrl } from "../util/PublicUrlUtils.js";
|
|
23
|
+
import { RedisRelayBus } from "../util/RedisRelayBus.js";
|
|
24
|
+
import { parseRelayEnabled, RELAY_LARGE_PAYLOAD_BYTES, RELAY_MAX_PAYLOAD_BYTES, RELAY_WS_MAX_BACKPRESSURE_BYTES, RelayHub, } from "../util/RelayHub.js";
|
|
22
25
|
import { stripTrustedRoles } from "../util/RouteAccessUtils.js";
|
|
23
26
|
import { JOIN_TOKEN_PATTERN, PUBLIC_SLUG_PATTERN, mintJoinToken, mintPublicSlug } from "../util/TokenUtils.js";
|
|
24
27
|
import { VideoMeetingStatus, VideoMeetingVisibility } from "../models/types.js";
|
|
25
|
-
const { Config, Inject, Logger } = ObjectDecorators;
|
|
28
|
+
const { Config, Destroy, Init, Inject, Logger } = ObjectDecorators;
|
|
26
29
|
const { Description, Summary } = DocDecorators;
|
|
27
30
|
const { Transactional } = DatabaseDecorators;
|
|
28
|
-
const { Delete, Get, Param, Post, Put, Query, RateLimit, Request, User: AuthUser } = RouteDecorators;
|
|
31
|
+
const { Auth, Delete, Get, Param, Post, Put, Query, RateLimit, Request, Socket, User: AuthUser, WebSocket } = RouteDecorators;
|
|
32
|
+
/** Whether the installed `@rapidrest/service-core` lets a `@WebSocket()` route set its own message and backpressure
|
|
33
|
+
* limits (its 2.4.0 and later export the option bounds). Without that the framework holds every WebSocket route to
|
|
34
|
+
* 16 KiB messages, and asking for more would advertise a limit that is not enforced - so the route then keeps 16 KiB. */
|
|
35
|
+
const WS_ROUTE_OPTIONS_SUPPORTED = "MAX_WEBSOCKET_PAYLOAD_LENGTH" in RouteDecorators;
|
|
36
|
+
/** The largest message a relay socket may send: what the route asks the framework for and what `ready` tells the
|
|
37
|
+
* client. */
|
|
38
|
+
const RELAY_MESSAGE_LIMIT_BYTES = WS_ROUTE_OPTIONS_SUPPORTED ? RELAY_LARGE_PAYLOAD_BYTES : RELAY_MAX_PAYLOAD_BYTES;
|
|
39
|
+
/** The relay route's WebSocket options: bigger messages, so a key frame is one message, and a bigger send buffer, so a
|
|
40
|
+
* receiver that stalls for a moment loses nothing. `undefined` (the framework's defaults) where it cannot be set. */
|
|
41
|
+
const RELAY_WS_OPTIONS = WS_ROUTE_OPTIONS_SUPPORTED
|
|
42
|
+
? { maxPayloadLength: RELAY_LARGE_PAYLOAD_BYTES, maxBackpressure: RELAY_WS_MAX_BACKPRESSURE_BYTES }
|
|
43
|
+
: undefined;
|
|
29
44
|
/** Upper bounds on caller-supplied text, matching the general shape `booking-plugin`'s `BaseBookingRoute` bounds
|
|
30
45
|
* its own free-text fields with. */
|
|
31
46
|
const MAX_TITLE_LENGTH = 200;
|
|
@@ -52,6 +67,13 @@ export const GUEST_UID_PREFIX = "guest:";
|
|
|
52
67
|
const GUEST_GRANT_MAX_ATTEMPTS = 5;
|
|
53
68
|
/** The `label` of every `CalendarEventAttendeeLink` `persistMeeting()` writes. */
|
|
54
69
|
const ATTENDEE_LINK_LABEL = "Join video call";
|
|
70
|
+
/** The reason `relay()` closes (1008) a socket it refuses for any authorization reason - a caller with no grant, a
|
|
71
|
+
* meeting that doesn't exist, and one that is cancelled or ended all get exactly this, so a refusal never reveals
|
|
72
|
+
* whether the meeting exists. */
|
|
73
|
+
const RELAY_NOT_PERMITTED = "Not permitted.";
|
|
74
|
+
/** How many messages `relay()` holds for a socket that sends before its authorization finished (a client's `hello`
|
|
75
|
+
* can arrive while the meeting is still being looked up); the rest are dropped. */
|
|
76
|
+
const RELAY_MAX_QUEUED_MESSAGES = 8;
|
|
55
77
|
/**
|
|
56
78
|
* The owner's management of their own `VideoMeeting`s (JWT-authenticated, mailbox-ACL-checked exactly like any
|
|
57
79
|
* other mailbox-scoped entity - see `booking-plugin`'s `BaseBookingTypeRoute` for the same "must own or hold a
|
|
@@ -175,6 +197,27 @@ const ATTENDEE_LINK_LABEL = "Join video call";
|
|
|
175
197
|
* now run every loaded meeting through `withJoinUrls()` - the exact same computation `create()` already did,
|
|
176
198
|
* applied uniformly on read instead of only once on write.
|
|
177
199
|
*
|
|
200
|
+
* ## The last-resort WebSocket media relay (`relay()`)
|
|
201
|
+
*
|
|
202
|
+
* Clients connect peer-to-peer over WebRTC, fall back to TURN, and as a third and last resort (a corporate firewall
|
|
203
|
+
* that allows neither) send media over a WebSocket this server proxies: `relay()`, at `/relay/:id` under the
|
|
204
|
+
* subclass's own base path (`/api/mail/video-meetings/relay/<meetingUid>`). It is a fan-out of opaque binary
|
|
205
|
+
* frames between the participants of one meeting - the wire protocol and limits are documented on `RelayHub`, which
|
|
206
|
+
* does all of the relaying; this class only authorizes a socket and hands it over, and picks how frames travel
|
|
207
|
+
* between server replicas: when the deployment configures `datastores:events` (the Redis the framework's push system
|
|
208
|
+
* already uses) frames cross replicas over Redis pub/sub (`RedisRelayBus`), so participants connected to different
|
|
209
|
+
* replicas reach each other; without it they travel in-process only, with a one-time warning at start-up that relay
|
|
210
|
+
* does not cross replicas.
|
|
211
|
+
*
|
|
212
|
+
* Authorization is the very grant `join()`'s `ensureChannelGrant()` already gives every participant, guest or real:
|
|
213
|
+
* `READ` and `CREATE` on the meeting's uid, checked with the caller's trusted roles stripped exactly as
|
|
214
|
+
* `MailPushRoute` does for `/push`, so a trusted administrator with no grant is refused just like there. The meeting must exist and be neither
|
|
215
|
+
* cancelled nor ended. Every refusal closes the socket with code 1008; a missing meeting and a missing grant are
|
|
216
|
+
* indistinguishable ("Not permitted."), so a caller learns nothing about meetings they can't join. The whole
|
|
217
|
+
* thing can be turned off with `mail:videoconf:relay:enabled` (default `true`; `false`/`"false"`/`0`/`"0"` disable
|
|
218
|
+
* it): a disabled relay closes every socket with 1008 "Relay disabled." and `join()` reports `relayEnabled: false`, so
|
|
219
|
+
* clients don't try it.
|
|
220
|
+
*
|
|
178
221
|
* ## Other known limitations
|
|
179
222
|
*
|
|
180
223
|
* **`VideoMeetingInvitee.joinToken` never expires** and has no GC job - identical tradeoff to `Booking.manageToken`.
|
|
@@ -200,6 +243,19 @@ export class BaseVideoMeetingRoute {
|
|
|
200
243
|
this.turnUsername = "";
|
|
201
244
|
this.turnCredential = "";
|
|
202
245
|
this.turnSharedSecret = "";
|
|
246
|
+
/** Whether `relay()` accepts sockets, and `join()` advertises it - read through `isRelayEnabled()`, since a value
|
|
247
|
+
* set from an environment variable arrives as a string. */
|
|
248
|
+
this.relayEnabledSetting = true;
|
|
249
|
+
/** The base URL the video filters' models are hosted under, when not on the public CDNs - read through
|
|
250
|
+
* `parseEffectsAssetsUrl()`, which drops anything that isn't a plain URL or path. */
|
|
251
|
+
this.effectsAssetsUrlSetting = "";
|
|
252
|
+
/** The deployment's `events` datastore (`datastores:events`) - the Redis the framework's push system publishes
|
|
253
|
+
* across replicas on; `null` when the deployment has none. Decides how `initRelayBus()` carries relay frames. */
|
|
254
|
+
this.eventsConfig = null;
|
|
255
|
+
/** The rooms of this process's relay sockets. One per route instance. It starts out on an in-process bus, which
|
|
256
|
+
* is what a route that was never initialized (a test's) keeps; `initRelayBus()` swaps in a hub on a Redis bus
|
|
257
|
+
* when the deployment has one, before any socket can attach. Its socket caps are per server replica. */
|
|
258
|
+
this.relayHub = new RelayHub({ maxPayloadBytes: RELAY_MESSAGE_LIMIT_BYTES, logger: { debug: (message) => this.logger.debug(message) } });
|
|
203
259
|
}
|
|
204
260
|
/**
|
|
205
261
|
* Exposes the `@Model(...)`-supplied entity class as an instance property so `@Transactional()` on
|
|
@@ -210,6 +266,38 @@ export class BaseVideoMeetingRoute {
|
|
|
210
266
|
get modelClass() {
|
|
211
267
|
return this.constructor.modelClass;
|
|
212
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* Picks how relay frames travel between server replicas, once the route is created: over Redis pub/sub
|
|
271
|
+
* (`RedisRelayBus`) when `datastores:events` is configured, else in-process only with a single warning that
|
|
272
|
+
* participants on different replicas cannot relay to each other. A Redis bus that cannot be created (the `redis`
|
|
273
|
+
* package is missing, an unusable URL) falls back the same way, with the error logged. Runs before the route
|
|
274
|
+
* serves anything, so no socket is attached to the hub it replaces.
|
|
275
|
+
*/
|
|
276
|
+
async initRelayBus() {
|
|
277
|
+
const url = this.eventsConfig?.url;
|
|
278
|
+
if (!url) {
|
|
279
|
+
this.logger.warn("The `events` datastore is not configured: the video meeting relay only carries media between participants " +
|
|
280
|
+
"connected to this server instance, not between server replicas.");
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const bus = new RedisRelayBus({ url, logger: this.logger });
|
|
284
|
+
try {
|
|
285
|
+
await bus.connect();
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
this.logger.error("Could not create the video meeting relay's Redis bus, so relay only works between participants connected to this " +
|
|
289
|
+
`server instance: ${err?.message ?? err}`);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
this.relayBus = bus;
|
|
293
|
+
this.relayHub = new RelayHub({ bus, maxPayloadBytes: RELAY_MESSAGE_LIMIT_BYTES, logger: { debug: (message) => this.logger.debug(message) } });
|
|
294
|
+
}
|
|
295
|
+
/** Disconnects the Redis relay bus, if there is one, when the object factory destroys the route. (The factory
|
|
296
|
+
* runs one destroy method per object: a subclass declaring its own `@Destroy` must call this one.) */
|
|
297
|
+
destroyRelayBus() {
|
|
298
|
+
this.relayBus?.close();
|
|
299
|
+
this.relayBus = undefined;
|
|
300
|
+
}
|
|
213
301
|
async init() {
|
|
214
302
|
if (!this.meetingRepo) {
|
|
215
303
|
this.meetingRepo = await this._objectFactory.newInstance(RepoUtils, { name: this.meetingClass.name, args: [this.meetingClass] });
|
|
@@ -639,6 +727,8 @@ export class BaseVideoMeetingRoute {
|
|
|
639
727
|
throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
|
|
640
728
|
}
|
|
641
729
|
}
|
|
730
|
+
const relayEnabled = this.isRelayEnabled();
|
|
731
|
+
const effectsAssetsUrl = parseEffectsAssetsUrl(this.effectsAssetsUrlSetting);
|
|
642
732
|
const publicMeeting = {
|
|
643
733
|
uid: meeting.uid,
|
|
644
734
|
title: meeting.title,
|
|
@@ -657,7 +747,7 @@ export class BaseVideoMeetingRoute {
|
|
|
657
747
|
// prefix check is a safe, cheap discriminator - see this class's doc comment and GUEST_UID_PREFIX).
|
|
658
748
|
if (user && !user.uid.startsWith(GUEST_UID_PREFIX)) {
|
|
659
749
|
await this.ensureChannelGrant(meeting.uid, user.uid);
|
|
660
|
-
return { meeting: publicMeeting, iceServers, authenticated: true, selfUid: user.uid };
|
|
750
|
+
return { meeting: publicMeeting, iceServers, authenticated: true, selfUid: user.uid, relayEnabled, ...(effectsAssetsUrl && { effectsAssetsUrl }) };
|
|
661
751
|
}
|
|
662
752
|
const { guestUid, token: guestToken, expiresAt } = this.mintGuestToken();
|
|
663
753
|
await this.ensureChannelGrant(meeting.uid, guestUid);
|
|
@@ -668,8 +758,107 @@ export class BaseVideoMeetingRoute {
|
|
|
668
758
|
selfUid: guestUid,
|
|
669
759
|
token: guestToken,
|
|
670
760
|
expiresAt: expiresAt.toISOString(),
|
|
761
|
+
relayEnabled,
|
|
762
|
+
...(effectsAssetsUrl && { effectsAssetsUrl }),
|
|
671
763
|
};
|
|
672
764
|
}
|
|
765
|
+
/** Whether the WebSocket media relay is enabled - `mail:videoconf:relay:enabled`, see `parseRelayEnabled()`. */
|
|
766
|
+
isRelayEnabled() {
|
|
767
|
+
return parseRelayEnabled(this.relayEnabledSetting);
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Decides whether `user` may open a relay socket to meeting `id`: the relay must be enabled, `user` must hold BOTH
|
|
771
|
+
* `READ` and `CREATE` on the meeting's uid - exactly what `ensureChannelGrant()` gives a joined participant, and
|
|
772
|
+
* what `/push` demands to subscribe and publish - and the meeting must exist and be neither cancelled nor ended.
|
|
773
|
+
* Returns the reason to close the socket with, or `undefined` when permitted.
|
|
774
|
+
*
|
|
775
|
+
* `hasPermission()` gets `user` with its trusted roles stripped (`stripTrustedRoles()`), exactly as
|
|
776
|
+
* `@rapidmx/restapi`'s `MailPushRoute` does for the same grant on `/push`: without it the framework's "trusted
|
|
777
|
+
* users always have permission" rule would let any administrator relay in any meeting they hold no grant on, so
|
|
778
|
+
* a trusted administrator with no grant is refused here exactly as `/push` refuses them. Every authorization
|
|
779
|
+
* failure gives the same `RELAY_NOT_PERMITTED`, whether the grant, the meeting or its state is what's missing.
|
|
780
|
+
*/
|
|
781
|
+
async authorizeRelay(id, user) {
|
|
782
|
+
if (!this.isRelayEnabled()) {
|
|
783
|
+
return "Relay disabled.";
|
|
784
|
+
}
|
|
785
|
+
// `hasPermission()` answers `true` for an empty ACL uid, so an empty id must never reach it.
|
|
786
|
+
if (!user || !id) {
|
|
787
|
+
return RELAY_NOT_PERMITTED;
|
|
788
|
+
}
|
|
789
|
+
await this.init();
|
|
790
|
+
const checked = stripTrustedRoles(user, this.trustedRoles);
|
|
791
|
+
if (!(await this.aclUtils.hasPermission(checked, id, ACLAction.READ)) || !(await this.aclUtils.hasPermission(checked, id, ACLAction.CREATE))) {
|
|
792
|
+
return RELAY_NOT_PERMITTED;
|
|
793
|
+
}
|
|
794
|
+
const meeting = await this.meetingRepo.findOne(id, { ignoreACL: true });
|
|
795
|
+
if (!meeting || meeting.status === VideoMeetingStatus.CANCELLED || meeting.status === VideoMeetingStatus.ENDED) {
|
|
796
|
+
return RELAY_NOT_PERMITTED;
|
|
797
|
+
}
|
|
798
|
+
return undefined;
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* The meeting uid a relay socket's URL names. `@Param("id")` is filled in by the Bun router, but NOT by the uWS
|
|
802
|
+
* router this framework actually serves with: it never populates `req.params` for a WebSocket upgrade (a
|
|
803
|
+
* `@WebSocket()` route has, so far, never had a path parameter - `BasePushRoute.connect()` has none). So when the
|
|
804
|
+
* decorated argument is empty this reads the last segment of the request path instead, which the
|
|
805
|
+
* `/relay/:id` pattern guarantees is the id (the framework also registers the trailing-slash form; the path
|
|
806
|
+
* never carries the query string). Returns `undefined` when there is none or it isn't valid percent-encoding.
|
|
807
|
+
*/
|
|
808
|
+
relayMeetingId(paramId, req) {
|
|
809
|
+
if (typeof paramId === "string" && paramId) {
|
|
810
|
+
return paramId;
|
|
811
|
+
}
|
|
812
|
+
const segments = String(req?.path ?? "").split("/").filter((segment) => segment);
|
|
813
|
+
try {
|
|
814
|
+
return decodeURIComponent(segments[segments.length - 1] ?? "") || undefined;
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
return undefined;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
async relay(paramId, req, sock, user) {
|
|
821
|
+
const id = this.relayMeetingId(paramId, req);
|
|
822
|
+
// Listeners first, before the first await: the client may send its `hello` the moment the socket opens,
|
|
823
|
+
// while the meeting is still being looked up, and an event with no listener is lost. Whatever arrives until
|
|
824
|
+
// the socket is admitted is held (a few messages at most) and replayed to it.
|
|
825
|
+
let conn;
|
|
826
|
+
let closed = false;
|
|
827
|
+
const queued = [];
|
|
828
|
+
sock.on("message", (data, isBinary) => {
|
|
829
|
+
if (conn) {
|
|
830
|
+
conn.message(data, isBinary);
|
|
831
|
+
}
|
|
832
|
+
else if (queued.length < RELAY_MAX_QUEUED_MESSAGES) {
|
|
833
|
+
queued.push({ data, isBinary });
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
sock.on("close", () => {
|
|
837
|
+
closed = true;
|
|
838
|
+
conn?.close();
|
|
839
|
+
});
|
|
840
|
+
let refusal;
|
|
841
|
+
try {
|
|
842
|
+
refusal = await this.authorizeRelay(id, user);
|
|
843
|
+
}
|
|
844
|
+
catch (err) {
|
|
845
|
+
this.logger.error(`Failed to authorize a relay socket for meeting ${id}: ${err?.message ?? err}`);
|
|
846
|
+
sock.close(1011, "Relay unavailable.");
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (refusal) {
|
|
850
|
+
sock.close(1008, refusal);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
// The socket closed while it was being authorized: nothing to attach, and nothing left to clean up.
|
|
854
|
+
if (closed) {
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
conn = this.relayHub.attach(id, user.uid, sock);
|
|
858
|
+
for (const message of queued) {
|
|
859
|
+
conn?.message(message.data, message.isBinary);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
673
862
|
}
|
|
674
863
|
__decorate([
|
|
675
864
|
Inject(ACLUtils),
|
|
@@ -703,10 +892,34 @@ __decorate([
|
|
|
703
892
|
Config("mail:videoconf:turn:shared_secret", ""),
|
|
704
893
|
__metadata("design:type", String)
|
|
705
894
|
], BaseVideoMeetingRoute.prototype, "turnSharedSecret", void 0);
|
|
895
|
+
__decorate([
|
|
896
|
+
Config("mail:videoconf:relay:enabled", true),
|
|
897
|
+
__metadata("design:type", Object)
|
|
898
|
+
], BaseVideoMeetingRoute.prototype, "relayEnabledSetting", void 0);
|
|
899
|
+
__decorate([
|
|
900
|
+
Config("mail:videoconf:effects:assets_url", ""),
|
|
901
|
+
__metadata("design:type", String)
|
|
902
|
+
], BaseVideoMeetingRoute.prototype, "effectsAssetsUrlSetting", void 0);
|
|
903
|
+
__decorate([
|
|
904
|
+
Config("datastores:events", null),
|
|
905
|
+
__metadata("design:type", Object)
|
|
906
|
+
], BaseVideoMeetingRoute.prototype, "eventsConfig", void 0);
|
|
706
907
|
__decorate([
|
|
707
908
|
Logger,
|
|
708
909
|
__metadata("design:type", Object)
|
|
709
910
|
], BaseVideoMeetingRoute.prototype, "logger", void 0);
|
|
911
|
+
__decorate([
|
|
912
|
+
Init,
|
|
913
|
+
__metadata("design:type", Function),
|
|
914
|
+
__metadata("design:paramtypes", []),
|
|
915
|
+
__metadata("design:returntype", Promise)
|
|
916
|
+
], BaseVideoMeetingRoute.prototype, "initRelayBus", null);
|
|
917
|
+
__decorate([
|
|
918
|
+
Destroy,
|
|
919
|
+
__metadata("design:type", Function),
|
|
920
|
+
__metadata("design:paramtypes", []),
|
|
921
|
+
__metadata("design:returntype", void 0)
|
|
922
|
+
], BaseVideoMeetingRoute.prototype, "destroyRelayBus", null);
|
|
710
923
|
__decorate([
|
|
711
924
|
Transactional(),
|
|
712
925
|
__metadata("design:type", Function),
|
|
@@ -795,4 +1008,25 @@ __decorate([
|
|
|
795
1008
|
__metadata("design:paramtypes", [String, Object]),
|
|
796
1009
|
__metadata("design:returntype", Promise)
|
|
797
1010
|
], BaseVideoMeetingRoute.prototype, "join", null);
|
|
1011
|
+
__decorate([
|
|
1012
|
+
Summary("Relays media over a WebSocket."),
|
|
1013
|
+
Description("The last-resort media path for a participant whose network blocks both a direct WebRTC connection and TURN: " +
|
|
1014
|
+
"a WebSocket at /relay/<meetingUid> carrying opaque binary frames between the meeting's participants " +
|
|
1015
|
+
"(see RelayHub for the protocol: a 'hello' text message first, then 'want' to choose whose media to " +
|
|
1016
|
+
"receive, then binary frames of at most the 'maxMessageBytes' the server's 'ready' reply names: 64 KiB, or 16 KiB on a framework that cannot raise its limit). Requires a JWT (a real user's, or the guest token " +
|
|
1017
|
+
"join() minted) holding READ and CREATE on the meeting - the grant join() gives - and an existing, " +
|
|
1018
|
+
"unended, uncancelled meeting; anything else, or a disabled relay, closes the socket with code 1008. When " +
|
|
1019
|
+
"the server runs several replicas and the events datastore (Redis) is configured, media is relayed " +
|
|
1020
|
+
"between participants connected to different replicas too; without it, only between participants " +
|
|
1021
|
+
"connected to the same replica. The socket limits apply per replica."),
|
|
1022
|
+
Auth(["jwt"]),
|
|
1023
|
+
WebSocket("/relay/:id", RELAY_WS_OPTIONS),
|
|
1024
|
+
__param(0, Param("id")),
|
|
1025
|
+
__param(1, Request),
|
|
1026
|
+
__param(2, Socket),
|
|
1027
|
+
__param(3, AuthUser),
|
|
1028
|
+
__metadata("design:type", Function),
|
|
1029
|
+
__metadata("design:paramtypes", [Object, Object, Object, Object]),
|
|
1030
|
+
__metadata("design:returntype", Promise)
|
|
1031
|
+
], BaseVideoMeetingRoute.prototype, "relay", null);
|
|
798
1032
|
//# sourceMappingURL=BaseVideoMeetingRoute.js.map
|