@rapidmx/meet-plugin 0.6.0 → 0.8.1
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 +18 -5
- package/apps/meet/[token].tsx +11 -10
- package/apps/meet/_CallControls.tsx +28 -2
- package/apps/meet/_CallView.tsx +26 -2
- package/apps/meet/_EffectsPanel.tsx +134 -0
- package/apps/meet/_MeetChrome.tsx +1 -1
- package/apps/meet/_MeetLobby.tsx +36 -4
- package/apps/meet/_ParticipantTile.tsx +73 -10
- package/apps/meet/_icons.tsx +4 -0
- package/apps/meet/_layout.tsx +2 -2
- package/apps/meet/_meetApi.ts +7 -1
- package/apps/meet/index.tsx +2 -2
- package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +4 -4
- package/apps/settings-video-conferencing/_layout.tsx +2 -2
- package/apps/settings-video-conferencing/index.tsx +3 -3
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -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/push/GuestSignalingClient.ts +4 -4
- 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 +159 -7
- package/apps/shared/webrtc/realPeerConnection.ts +22 -0
- package/apps/shared/webrtc/types.ts +32 -2
- package/dist/apps/meet/[token].d.ts +3 -3
- package/dist/apps/meet/[token].js +10 -10
- 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 +20 -3
- package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
- package/dist/apps/meet/_EffectsPanel.js +60 -0
- package/dist/apps/meet/_MeetChrome.d.ts +1 -1
- package/dist/apps/meet/_MeetLobby.d.ts +8 -0
- package/dist/apps/meet/_MeetLobby.js +15 -4
- package/dist/apps/meet/_ParticipantTile.d.ts +17 -2
- package/dist/apps/meet/_ParticipantTile.js +27 -4
- package/dist/apps/meet/_icons.d.ts +1 -0
- package/dist/apps/meet/_icons.js +1 -0
- package/dist/apps/meet/_layout.d.ts +1 -1
- package/dist/apps/meet/_layout.js +1 -1
- package/dist/apps/meet/_meetApi.d.ts +6 -0
- package/dist/apps/meet/_meetApi.js +1 -1
- package/dist/apps/meet/index.js +2 -2
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +1 -1
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +4 -4
- package/dist/apps/settings-video-conferencing/_layout.d.ts +1 -1
- package/dist/apps/settings-video-conferencing/_layout.js +1 -1
- package/dist/apps/settings-video-conferencing/index.d.ts +1 -1
- package/dist/apps/settings-video-conferencing/index.js +3 -3
- package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +78 -0
- package/dist/apps/shared/media/filters/VideoFilterProcessor.js +329 -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/push/GuestSignalingClient.d.ts +1 -1
- package/dist/apps/shared/push/GuestSignalingClient.js +3 -3
- 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 +52 -2
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +138 -7
- 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 +25 -11
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import * as crypto from "crypto";
|
|
6
|
+
import { attachRedisErrorHandler, importRedis } from "@rapidrest/service-core";
|
|
7
|
+
/** The prefix of the Redis pub/sub channel a room's frames travel on: `videoconf:relay:<roomId>`. */
|
|
8
|
+
export const REDIS_RELAY_CHANNEL_PREFIX = "videoconf:relay:";
|
|
9
|
+
/** The length, in bytes, of the random id every `RedisRelayBus` stamps on what it publishes. */
|
|
10
|
+
export const REDIS_RELAY_ORIGIN_BYTES = 16;
|
|
11
|
+
/** The least time between two `warn` messages about failing Redis commands; the failures in between are logged at
|
|
12
|
+
* `debug`, so a Redis outage cannot flood the log at the rate media frames arrive. */
|
|
13
|
+
export const REDIS_RELAY_WARN_INTERVAL_MS = 30000;
|
|
14
|
+
/** The channel a room's frames are published to. */
|
|
15
|
+
export function redisRelayChannel(roomId) {
|
|
16
|
+
return REDIS_RELAY_CHANNEL_PREFIX + roomId;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A `RelayBus` that also carries frames between server replicas over Redis pub/sub, so participants whose relay
|
|
20
|
+
* sockets landed on different replicas still reach each other. The Redis is the deployment's `datastores:events` one,
|
|
21
|
+
* the same the framework's push system (`BasePushRoute`) uses across replicas.
|
|
22
|
+
*
|
|
23
|
+
* ## How
|
|
24
|
+
*
|
|
25
|
+
* The bus owns exactly two Redis clients for the whole process, however many sockets and rooms there are: a
|
|
26
|
+
* publisher, and one shared subscriber (a client in subscriber mode cannot publish). Both come from
|
|
27
|
+
* `importRedis()` and `attachRedisErrorHandler()` of `@rapidrest/service-core`, exactly as `BasePushRoute` obtains its
|
|
28
|
+
* clients - see `connect()`.
|
|
29
|
+
*
|
|
30
|
+
* Each room has a channel, `videoconf:relay:<roomId>`. The bus counts the local listeners of every room: the first
|
|
31
|
+
* one to arrive makes it SUBSCRIBE and the last one to leave makes it UNSUBSCRIBE, so a replica receives only the
|
|
32
|
+
* rooms it has sockets in. Subscriptions use the client's BUFFER mode, so media bytes are never decoded as text.
|
|
33
|
+
*
|
|
34
|
+
* `publish()` delivers to this process's listeners synchronously, exactly as `InProcessRelayBus` does - local
|
|
35
|
+
* delivery never goes through Redis - and, when the publisher is connected, also publishes the frame to the room's
|
|
36
|
+
* channel without waiting for it. Every message carries the id of the bus that published it and a bus ignores its own,
|
|
37
|
+
* so nothing is delivered twice on the replica of origin, while every other replica delivers it to its own listeners.
|
|
38
|
+
*
|
|
39
|
+
* ## Wire format of a Redis message
|
|
40
|
+
*
|
|
41
|
+
* `[16 bytes: origin bus id][1 byte: N][N bytes: the sender's peer id, UTF-8][the frame]`, where `frame` is what the
|
|
42
|
+
* `RelayHub` gave `publish()` (its own `[sender length][sender][payload]` header included). The origin id is random
|
|
43
|
+
* per bus instance. A message that is shorter than that, whose length byte is 0 or overruns it, or that carries no
|
|
44
|
+
* frame, is dropped; parsing never throws.
|
|
45
|
+
*
|
|
46
|
+
* ## Failure behaviour
|
|
47
|
+
*
|
|
48
|
+
* Redis being down or slow never affects local delivery and never throws into the hub: `publish()` is fire-and-forget,
|
|
49
|
+
* is skipped outright while the publisher is not connected (its offline queue is disabled, so an outage cannot pile
|
|
50
|
+
* frames up in memory), and every failed command is caught and counted. Failures are logged at `warn` at most once per
|
|
51
|
+
* `REDIS_RELAY_WARN_INTERVAL_MS` and at `debug` otherwise; connection errors themselves are logged by
|
|
52
|
+
* `attachRedisErrorHandler()` (one error per outage). The clients keep node-redis's default reconnect strategy
|
|
53
|
+
* (retry forever with backoff), and on reconnect node-redis re-issues SUBSCRIBE for every channel the subscriber held
|
|
54
|
+
* (`resubscribe()` in the client's socket initiator), so the bus needs no re-subscribe logic of its own - relay
|
|
55
|
+
* between replicas resumes by itself when Redis comes back, and frames sent during the outage are simply lost, which
|
|
56
|
+
* media tolerates. A bus used before `connect()`, after `close()`, or whose Redis is unavailable behaves as an
|
|
57
|
+
* `InProcessRelayBus`.
|
|
58
|
+
*
|
|
59
|
+
* ## Bandwidth
|
|
60
|
+
*
|
|
61
|
+
* Every relayed frame is published once, whatever the number of receivers, and Redis delivers it to every replica
|
|
62
|
+
* subscribed to that room - including replicas whose sockets there do not `want` the sender at all (the hub filters
|
|
63
|
+
* after the bus). A room's cross-replica traffic is therefore roughly its total relayed upload times the number of
|
|
64
|
+
* OTHER replicas holding sockets of it, and each of those replicas' subscribers reads all of it. A single-replica
|
|
65
|
+
* room costs a publish per frame and no receiving. Redis pub/sub buffers nothing for a slow subscriber beyond the
|
|
66
|
+
* server's client-output-buffer limit for pub/sub, after which Redis drops that connection (the client then
|
|
67
|
+
* reconnects and resubscribes).
|
|
68
|
+
*
|
|
69
|
+
* @author Jean-Philippe Steinmetz
|
|
70
|
+
*/
|
|
71
|
+
export class RedisRelayBus {
|
|
72
|
+
constructor(options) {
|
|
73
|
+
this.options = options;
|
|
74
|
+
/** What the bus has done so far. */
|
|
75
|
+
this.stats = { published: 0, publishSkipped: 0, failures: 0, received: 0, receivedDropped: 0 };
|
|
76
|
+
this.id = crypto.randomBytes(REDIS_RELAY_ORIGIN_BYTES);
|
|
77
|
+
this.rooms = new Map();
|
|
78
|
+
this.lastWarn = Number.NEGATIVE_INFINITY;
|
|
79
|
+
}
|
|
80
|
+
/** Whether frames are currently being published to Redis: `connect()` was called, the bus is not closed and the
|
|
81
|
+
* publisher's connection is up. */
|
|
82
|
+
get connected() {
|
|
83
|
+
return this.publisher?.isReady === true;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Creates the publisher and the shared subscriber and starts connecting them, then returns without waiting for
|
|
87
|
+
* either connection - a Redis that is down at start-up delays cross-replica relay, not the server, and the
|
|
88
|
+
* clients keep retrying. Rooms subscribed to before this call are not caught up (the route calls it before it
|
|
89
|
+
* exposes the bus). Does nothing when already connected. Throws when the `redis` package is not installed or the
|
|
90
|
+
* URL is unusable; nothing is left open then.
|
|
91
|
+
*/
|
|
92
|
+
async connect() {
|
|
93
|
+
if (this.publisher) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const { createClient } = await importRedis();
|
|
97
|
+
// The publisher must never queue commands while disconnected: a frame is only worth sending right now.
|
|
98
|
+
const publisher = attachRedisErrorHandler(createClient({ url: this.options.url, disableOfflineQueue: true }), this.options.logger, "events (relay publisher)");
|
|
99
|
+
let subscriber;
|
|
100
|
+
try {
|
|
101
|
+
subscriber = attachRedisErrorHandler(createClient({ url: this.options.url }), this.options.logger, "events (relay subscriber)");
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
publisher.destroy();
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
this.publisher = publisher;
|
|
108
|
+
this.subscriber = subscriber;
|
|
109
|
+
this.run("connect the publisher", () => publisher.connect());
|
|
110
|
+
this.run("connect the subscriber", () => subscriber.connect());
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Shuts the bus down: destroys both clients (which ends their connections, and with them every subscription) and
|
|
114
|
+
* stops publishing. Local delivery keeps working, as after a failed `connect()`. Safe to call repeatedly.
|
|
115
|
+
* Nothing is unsubscribed one by one: the connection going away does that server-side, and an UNSUBSCRIBE could
|
|
116
|
+
* only stall behind a Redis that is down.
|
|
117
|
+
*/
|
|
118
|
+
close() {
|
|
119
|
+
const clients = [this.publisher, this.subscriber];
|
|
120
|
+
this.publisher = undefined;
|
|
121
|
+
this.subscriber = undefined;
|
|
122
|
+
for (const client of clients) {
|
|
123
|
+
try {
|
|
124
|
+
client?.destroy();
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
this.report("destroy a client", err);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
subscribe(roomId, listener) {
|
|
132
|
+
let room = this.rooms.get(roomId);
|
|
133
|
+
if (!room) {
|
|
134
|
+
room = { listeners: new Set() };
|
|
135
|
+
this.rooms.set(roomId, room);
|
|
136
|
+
this.redisSubscribe(roomId, room);
|
|
137
|
+
}
|
|
138
|
+
const current = room;
|
|
139
|
+
current.listeners.add(listener);
|
|
140
|
+
return () => {
|
|
141
|
+
current.listeners.delete(listener);
|
|
142
|
+
if (current.listeners.size === 0 && this.rooms.get(roomId) === current) {
|
|
143
|
+
this.rooms.delete(roomId);
|
|
144
|
+
this.redisUnsubscribe(roomId, current);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
publish(roomId, sender, frame) {
|
|
149
|
+
this.publishToRedis(roomId, sender, frame);
|
|
150
|
+
for (const listener of this.rooms.get(roomId)?.listeners ?? []) {
|
|
151
|
+
listener(sender, frame);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
publishToRedis(roomId, sender, frame) {
|
|
155
|
+
const publisher = this.publisher;
|
|
156
|
+
const senderBytes = Buffer.from(sender, "utf8");
|
|
157
|
+
if (!publisher?.isReady || senderBytes.length === 0 || senderBytes.length > 255) {
|
|
158
|
+
this.stats.publishSkipped++;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const message = Buffer.allocUnsafe(REDIS_RELAY_ORIGIN_BYTES + 1 + senderBytes.length + frame.length);
|
|
162
|
+
this.id.copy(message, 0);
|
|
163
|
+
message[REDIS_RELAY_ORIGIN_BYTES] = senderBytes.length;
|
|
164
|
+
senderBytes.copy(message, REDIS_RELAY_ORIGIN_BYTES + 1);
|
|
165
|
+
frame.copy(message, REDIS_RELAY_ORIGIN_BYTES + 1 + senderBytes.length);
|
|
166
|
+
this.stats.published++;
|
|
167
|
+
this.run("publish a frame", () => publisher.publish(redisRelayChannel(roomId), message));
|
|
168
|
+
}
|
|
169
|
+
redisSubscribe(roomId, room) {
|
|
170
|
+
const subscriber = this.subscriber;
|
|
171
|
+
if (!subscriber) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const onMessage = (message) => this.receive(roomId, message);
|
|
175
|
+
room.onMessage = onMessage;
|
|
176
|
+
this.run("subscribe to a room", () => subscriber.subscribe(redisRelayChannel(roomId), onMessage, true));
|
|
177
|
+
}
|
|
178
|
+
redisUnsubscribe(roomId, room) {
|
|
179
|
+
const subscriber = this.subscriber;
|
|
180
|
+
if (!subscriber || !room.onMessage) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const onMessage = room.onMessage;
|
|
184
|
+
this.run("unsubscribe from a room", () => subscriber.unsubscribe(redisRelayChannel(roomId), onMessage, true));
|
|
185
|
+
}
|
|
186
|
+
/** Handles one message of a room's channel: parses it defensively and hands its frame to the local listeners,
|
|
187
|
+
* unless this very bus published it. Never throws - it runs inside the Redis client's own message dispatch. */
|
|
188
|
+
receive(roomId, message) {
|
|
189
|
+
try {
|
|
190
|
+
const listeners = this.rooms.get(roomId)?.listeners;
|
|
191
|
+
const parsed = listeners ? this.parse(message) : undefined;
|
|
192
|
+
if (!listeners || !parsed) {
|
|
193
|
+
this.stats.receivedDropped++;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
this.stats.received++;
|
|
197
|
+
for (const listener of listeners) {
|
|
198
|
+
listener(parsed.sender, parsed.frame);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
this.report("deliver a frame", err);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** Splits a Redis message into its sender and frame; `undefined` for a garbled one or this bus's own. */
|
|
206
|
+
parse(message) {
|
|
207
|
+
if (!Buffer.isBuffer(message) || message.length < REDIS_RELAY_ORIGIN_BYTES + 3) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
if (message.subarray(0, REDIS_RELAY_ORIGIN_BYTES).equals(this.id)) {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
const senderLength = message[REDIS_RELAY_ORIGIN_BYTES];
|
|
214
|
+
const frameStart = REDIS_RELAY_ORIGIN_BYTES + 1 + senderLength;
|
|
215
|
+
// A peer id is never empty and the frame carries at least its own header.
|
|
216
|
+
if (senderLength === 0 || frameStart >= message.length) {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
return { sender: message.toString("utf8", REDIS_RELAY_ORIGIN_BYTES + 1, frameStart), frame: message.subarray(frameStart) };
|
|
220
|
+
}
|
|
221
|
+
/** Runs a Redis command without waiting for it: a failure, thrown or rejected, is only counted and logged. */
|
|
222
|
+
run(what, command) {
|
|
223
|
+
(async () => {
|
|
224
|
+
await command();
|
|
225
|
+
})().catch((err) => this.report(what, err));
|
|
226
|
+
}
|
|
227
|
+
/** Counts a failure and logs it - at `warn` once per `REDIS_RELAY_WARN_INTERVAL_MS`, at `debug` in between. */
|
|
228
|
+
report(what, err) {
|
|
229
|
+
this.stats.failures++;
|
|
230
|
+
const message = `Relay redis bus failed to ${what}: ${err?.message ?? err}`;
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
if (now - this.lastWarn >= REDIS_RELAY_WARN_INTERVAL_MS) {
|
|
233
|
+
this.lastWarn = now;
|
|
234
|
+
this.options.logger.warn(message);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
this.options.logger.debug(message);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
//# sourceMappingURL=RedisRelayBus.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RedisRelayBus.js","sourceRoot":"","sources":["../../../src/util/RedisRelayBus.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** The version of the relay wire protocol this module implements - the `v` of `hello`/`ready`. */
|
|
6
|
+
export const RELAY_PROTOCOL_VERSION = 1;
|
|
7
|
+
/** The largest binary payload (in bytes, excluding the sender header the server adds) a socket may send; anything
|
|
8
|
+
* larger or empty is dropped. Matches `@rapidrest/service-core`'s uWS `DEFAULT_WS_OPTIONS.maxPayloadLength` (16 KiB),
|
|
9
|
+
* which this framework's route registration cannot raise - a larger message closes the socket at the transport. */
|
|
10
|
+
export const RELAY_MAX_PAYLOAD_BYTES = 16 * 1024;
|
|
11
|
+
/** The larger message limit the relay route asks the framework for when the installed `@rapidrest/service-core` lets a
|
|
12
|
+
* `@WebSocket()` route set one (its 2.4.0 and later): a video key frame then fits in one message instead of several,
|
|
13
|
+
* with far less fragmenting and reassembly for the client to get wrong. The hub advertises whichever limit applies to
|
|
14
|
+
* the client in `ready` (`maxMessageBytes`). */
|
|
15
|
+
export const RELAY_LARGE_PAYLOAD_BYTES = 64 * 1024;
|
|
16
|
+
/** How many bytes the framework may hold unsent for one relay socket before it drops further messages to it, when the
|
|
17
|
+
* installed `@rapidrest/service-core` lets a route set that (its default, 64 KiB, is smaller than one key frame's worth
|
|
18
|
+
* of headroom and made a briefly slow receiver lose frames constantly). One MiB is roughly two seconds of a 350 kbps
|
|
19
|
+
* video stream plus audio. */
|
|
20
|
+
export const RELAY_WS_MAX_BACKPRESSURE_BYTES = 1024 * 1024;
|
|
21
|
+
/** The most sockets (registered or still awaiting their `hello`) one room holds at once. */
|
|
22
|
+
export const RELAY_MAX_SOCKETS_PER_ROOM = 16;
|
|
23
|
+
/** The most sockets one authenticated uid may hold in one room at once (several tabs or devices of one account). */
|
|
24
|
+
export const RELAY_MAX_SOCKETS_PER_UID = 4;
|
|
25
|
+
/** Sustained inbound binary budget per socket, in bytes per second (a token bucket refill rate). */
|
|
26
|
+
export const RELAY_RATE_BYTES_PER_SECOND = 512 * 1024;
|
|
27
|
+
/** Burst allowance of a socket's token bucket, in bytes - also the bucket's capacity. */
|
|
28
|
+
export const RELAY_BURST_BYTES = 1024 * 1024;
|
|
29
|
+
/** How long a socket may stay over budget continuously before it is closed with 1008 "Too much data.". */
|
|
30
|
+
export const RELAY_OVER_BUDGET_CLOSE_MS = 10000;
|
|
31
|
+
/** Two over-budget drops further apart than this are separate episodes: a socket is "continuously" over budget only
|
|
32
|
+
* while it keeps being dropped at least this often. A sender that exceeds its budget by a hair (one drop every few
|
|
33
|
+
* seconds) is throttled but never closed; one that keeps sending well past it is closed after
|
|
34
|
+
* `RELAY_OVER_BUDGET_CLOSE_MS`. */
|
|
35
|
+
export const RELAY_OVER_BUDGET_GRACE_MS = 1000;
|
|
36
|
+
/** How long a new socket has to send its `hello` before it is closed with 1008 "Hello timeout.". */
|
|
37
|
+
export const RELAY_HELLO_TIMEOUT_MS = 10000;
|
|
38
|
+
/** The longest peer id, in characters. Its UTF-8 encoding must also fit in the one length byte of a forwarded frame
|
|
39
|
+
* (255), which `isValidRelayPeer()` enforces too. */
|
|
40
|
+
export const RELAY_MAX_PEER_LENGTH = 128;
|
|
41
|
+
/** The most peers one `want` message may name; further valid entries are ignored. */
|
|
42
|
+
export const RELAY_MAX_WANT_PEERS = 32;
|
|
43
|
+
/** A `RelayBus` that delivers synchronously to the listeners of this one process - see `RelayHub`'s doc comment for
|
|
44
|
+
* what that means for a deployment with several server replicas. */
|
|
45
|
+
export class InProcessRelayBus {
|
|
46
|
+
constructor() {
|
|
47
|
+
this.listeners = new Map();
|
|
48
|
+
}
|
|
49
|
+
subscribe(roomId, listener) {
|
|
50
|
+
let set = this.listeners.get(roomId);
|
|
51
|
+
if (!set) {
|
|
52
|
+
set = new Set();
|
|
53
|
+
this.listeners.set(roomId, set);
|
|
54
|
+
}
|
|
55
|
+
set.add(listener);
|
|
56
|
+
return () => {
|
|
57
|
+
set.delete(listener);
|
|
58
|
+
if (set.size === 0 && this.listeners.get(roomId) === set) {
|
|
59
|
+
this.listeners.delete(roomId);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
publish(roomId, sender, frame) {
|
|
64
|
+
for (const listener of this.listeners.get(roomId) ?? []) {
|
|
65
|
+
listener(sender, frame);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Returns whether `peer` is a well-formed relay peer id: a string of 1 to `RELAY_MAX_PEER_LENGTH` characters with no
|
|
71
|
+
* whitespace, separator, control or other invisible (Unicode category C or Z) character, whose UTF-8 encoding fits in
|
|
72
|
+
* the single length byte of a forwarded frame.
|
|
73
|
+
*/
|
|
74
|
+
export function isValidRelayPeer(peer) {
|
|
75
|
+
return (typeof peer === "string" &&
|
|
76
|
+
peer.length >= 1 &&
|
|
77
|
+
peer.length <= RELAY_MAX_PEER_LENGTH &&
|
|
78
|
+
/^[^\s\p{C}\p{Z}]+$/u.test(peer) &&
|
|
79
|
+
Buffer.byteLength(peer, "utf8") <= 255);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Reads the `mail:videoconf:relay:enabled` setting. Environment-variable config arrives as a string ("true", "false",
|
|
83
|
+
* "0", "1") and nconf may parse it into a boolean or a number, so all of those are accepted; `false`, `0`, `"false"`
|
|
84
|
+
* and `"0"` (case-insensitive, trimmed) disable the relay and anything else - including an unset or unrecognizable
|
|
85
|
+
* value - leaves it enabled, the default.
|
|
86
|
+
*/
|
|
87
|
+
export function parseRelayEnabled(value) {
|
|
88
|
+
if (typeof value === "string") {
|
|
89
|
+
const normalized = value.trim().toLowerCase();
|
|
90
|
+
return normalized !== "false" && normalized !== "0";
|
|
91
|
+
}
|
|
92
|
+
return value !== false && value !== 0;
|
|
93
|
+
}
|
|
94
|
+
function toBuffer(data) {
|
|
95
|
+
if (typeof data === "string") {
|
|
96
|
+
return Buffer.from(data, "utf8");
|
|
97
|
+
}
|
|
98
|
+
return Buffer.isBuffer(data) ? data : Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
99
|
+
}
|
|
100
|
+
/** A token bucket: `capacity` tokens (bytes), refilled at `rate` per second, starting full. */
|
|
101
|
+
class TokenBucket {
|
|
102
|
+
constructor(now) {
|
|
103
|
+
this.tokens = RELAY_BURST_BYTES;
|
|
104
|
+
this.last = now;
|
|
105
|
+
}
|
|
106
|
+
/** Takes `amount` tokens if that many are available; returns whether it did. */
|
|
107
|
+
take(amount, now) {
|
|
108
|
+
this.tokens = Math.min(RELAY_BURST_BYTES, this.tokens + ((now - this.last) / 1000) * RELAY_RATE_BYTES_PER_SECOND);
|
|
109
|
+
this.last = now;
|
|
110
|
+
if (this.tokens < amount) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
this.tokens -= amount;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
class Connection {
|
|
118
|
+
constructor(env, room, uid, socket) {
|
|
119
|
+
this.env = env;
|
|
120
|
+
this.room = room;
|
|
121
|
+
this.uid = uid;
|
|
122
|
+
this.socket = socket;
|
|
123
|
+
/** The peer ids this socket wants frames from - see `want`. */
|
|
124
|
+
this.wanted = new Set();
|
|
125
|
+
this.closed = false;
|
|
126
|
+
this.bucket = new TokenBucket(Date.now());
|
|
127
|
+
this.lastOverBudget = 0;
|
|
128
|
+
this.helloTimer = setTimeout(() => this.evict("Hello timeout."), RELAY_HELLO_TIMEOUT_MS);
|
|
129
|
+
this.helloTimer.unref();
|
|
130
|
+
}
|
|
131
|
+
message(data, isBinary) {
|
|
132
|
+
if (this.closed) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (isBinary) {
|
|
136
|
+
this.binary(toBuffer(data));
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
this.text(typeof data === "string" ? data : toBuffer(data).toString("utf8"));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
close() {
|
|
143
|
+
if (this.closed) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.closed = true;
|
|
147
|
+
clearTimeout(this.helloTimer);
|
|
148
|
+
this.env.remove(this);
|
|
149
|
+
}
|
|
150
|
+
/** Closes the socket from this side with 1008 and `reason`, and forgets it. Only ever called on a live
|
|
151
|
+
* connection: `message()` ignores a closed one, its hello timer is cleared on close, and a closed one is no
|
|
152
|
+
* longer in its room's `peers`. */
|
|
153
|
+
evict(reason) {
|
|
154
|
+
this.close();
|
|
155
|
+
this.socket.close(1008, reason);
|
|
156
|
+
}
|
|
157
|
+
/** Sends `frame` to this socket. A send the socket reports as dropped, or throws on, is counted and skipped. */
|
|
158
|
+
forward(frame) {
|
|
159
|
+
const fail = (err) => {
|
|
160
|
+
this.env.stats.framesDroppedBackpressure++;
|
|
161
|
+
this.env.debug(`Dropped a relay frame for ${this.peer} in ${this.room.id}: ${err?.message ?? err}`);
|
|
162
|
+
};
|
|
163
|
+
try {
|
|
164
|
+
this.socket.send(frame, (err) => {
|
|
165
|
+
if (err) {
|
|
166
|
+
fail(err);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
this.env.stats.framesForwarded++;
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
fail(err);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Sends `text` to this socket, ignoring a failure - a lost `ready` shows as a client that never gets ready. */
|
|
176
|
+
sendText(text) {
|
|
177
|
+
try {
|
|
178
|
+
this.socket.send(text);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// The socket is going away; its close event cleans up.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
text(text) {
|
|
185
|
+
// A character is at least one byte, so this bounds the parse below by the payload limit.
|
|
186
|
+
if (text.length > this.env.maxPayloadBytes) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
let message;
|
|
190
|
+
try {
|
|
191
|
+
message = JSON.parse(text);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (!this.peer) {
|
|
200
|
+
if (message.op === "hello") {
|
|
201
|
+
this.hello(message);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else if (message.op === "want") {
|
|
205
|
+
this.want(message);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
hello(message) {
|
|
209
|
+
if (message.v !== RELAY_PROTOCOL_VERSION) {
|
|
210
|
+
this.evict("Unsupported version.");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const peer = message.peer;
|
|
214
|
+
if (!isValidRelayPeer(peer) || (peer !== this.uid && !peer.startsWith(`${this.uid}~`))) {
|
|
215
|
+
this.evict("Invalid peer.");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// A reconnecting client's new socket replaces the old one, which may not have noticed it is dead yet.
|
|
219
|
+
this.room.peers.get(peer)?.evict("Replaced.");
|
|
220
|
+
this.room.peers.set(peer, this);
|
|
221
|
+
this.peer = peer;
|
|
222
|
+
this.peerBytes = Buffer.from(peer, "utf8");
|
|
223
|
+
clearTimeout(this.helloTimer);
|
|
224
|
+
this.sendText(JSON.stringify({ op: "ready", v: RELAY_PROTOCOL_VERSION, maxMessageBytes: this.env.maxPayloadBytes }));
|
|
225
|
+
}
|
|
226
|
+
want(message) {
|
|
227
|
+
if (!Array.isArray(message.peers)) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const wanted = new Set();
|
|
231
|
+
for (const peer of message.peers) {
|
|
232
|
+
if (wanted.size >= RELAY_MAX_WANT_PEERS) {
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
if (isValidRelayPeer(peer)) {
|
|
236
|
+
wanted.add(peer);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
this.wanted = wanted;
|
|
240
|
+
}
|
|
241
|
+
binary(payload) {
|
|
242
|
+
if (!this.peer || payload.length === 0 || payload.length > this.env.maxPayloadBytes) {
|
|
243
|
+
this.env.stats.framesDroppedInvalid++;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
if (!this.bucket.take(payload.length, now)) {
|
|
248
|
+
this.env.stats.framesDroppedBudget++;
|
|
249
|
+
if (this.overBudgetSince === undefined || now - this.lastOverBudget > RELAY_OVER_BUDGET_GRACE_MS) {
|
|
250
|
+
this.overBudgetSince = now;
|
|
251
|
+
}
|
|
252
|
+
this.lastOverBudget = now;
|
|
253
|
+
if (now - this.overBudgetSince > RELAY_OVER_BUDGET_CLOSE_MS) {
|
|
254
|
+
this.evict("Too much data.");
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// Stamped here from the `hello`, never read from the payload: [sender length][sender][payload].
|
|
259
|
+
const sender = this.peerBytes;
|
|
260
|
+
const frame = Buffer.allocUnsafe(1 + sender.length + payload.length);
|
|
261
|
+
frame[0] = sender.length;
|
|
262
|
+
sender.copy(frame, 1);
|
|
263
|
+
payload.copy(frame, 1 + sender.length);
|
|
264
|
+
this.env.bus.publish(this.room.id, this.peer, frame);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* A fan-out relay of opaque binary frames between the participants of a room (one meeting) - the last-resort tier
|
|
269
|
+
* for participants whose network allows neither a direct WebRTC connection nor a TURN relay, only a WebSocket to this
|
|
270
|
+
* server. It knows nothing about meetings, users, HTTP or any framework: a caller authorizes a socket however it
|
|
271
|
+
* likes, hands it to `attach()` and feeds the returned `RelayConnection` the socket's `message` and `close` events.
|
|
272
|
+
*
|
|
273
|
+
* ## Wire protocol (version 1)
|
|
274
|
+
*
|
|
275
|
+
* Client to server, the first message must be a text message
|
|
276
|
+
* `{"op":"hello","v":1,"peer":"<peerId>"}`, sent within `RELAY_HELLO_TIMEOUT_MS`. `peer` is the caller's uid (the `uid`
|
|
277
|
+
* given to `attach()`) or starts with `<uid>~` - a per-tab or per-device id - and passes `isValidRelayPeer()`. Anything
|
|
278
|
+
* else closes the socket with 1008 "Invalid peer."; a wrong `v` closes it with 1008 "Unsupported version.". The server
|
|
279
|
+
* answers with the text message `{"op":"ready","v":1,"maxMessageBytes":<the limit below>}`. A second socket registering an already registered peer in the
|
|
280
|
+
* same room replaces the older one, which is closed with 1008 "Replaced.".
|
|
281
|
+
*
|
|
282
|
+
* After that, `{"op":"want","peers":["<peerId>", ...]}` REPLACES the socket's interest set with the valid entries, at
|
|
283
|
+
* most `RELAY_MAX_WANT_PEERS` of them; every other or malformed text message is ignored. Binary messages of 1 to
|
|
284
|
+
* `RelayHubOptions.maxPayloadBytes` bytes (`RELAY_MAX_PAYLOAD_BYTES` unless the route asked for more; opaque to the hub) are forwarded to every other socket of the room whose interest
|
|
285
|
+
* set contains the sender's peer id; binary before `hello`, empty and oversized messages are dropped.
|
|
286
|
+
*
|
|
287
|
+
* Server to client: `{"op":"ready","v":1,"maxMessageBytes":N}` once, then binary messages `[1 byte: UTF-8 length N of the sender's peer
|
|
288
|
+
* id][N bytes: the sender's peer id][the original payload]`. The sender is stamped by the hub from the `hello`,
|
|
289
|
+
* never trusted from the payload.
|
|
290
|
+
*
|
|
291
|
+
* ## Limits
|
|
292
|
+
*
|
|
293
|
+
* At most `RELAY_MAX_SOCKETS_PER_ROOM` sockets per room and `RELAY_MAX_SOCKETS_PER_UID` per uid per room, counting
|
|
294
|
+
* a socket from `attach()` on (a socket refused for either is closed with 1008 "Room full." / "Too many
|
|
295
|
+
* connections."). Each socket's inbound binary traffic is metered by a token bucket of `RELAY_RATE_BYTES_PER_SECOND`
|
|
296
|
+
* sustained with a `RELAY_BURST_BYTES` burst: an over-budget message is dropped silently, and a socket kept over
|
|
297
|
+
* budget for more than `RELAY_OVER_BUDGET_CLOSE_MS` (see `RELAY_OVER_BUDGET_GRACE_MS`) is closed with 1008 "Too
|
|
298
|
+
* much data.". A receiver whose `send()` reports the message as dropped (uWS backpressure) simply misses that
|
|
299
|
+
* frame - counted in `stats`, never queued or retried. Sockets and rooms are released when their socket closes; an
|
|
300
|
+
* empty room is deleted.
|
|
301
|
+
*
|
|
302
|
+
* ## Replicas
|
|
303
|
+
*
|
|
304
|
+
* The transport between sockets is the `RelayBus` given to the constructor, `InProcessRelayBus` by default, which
|
|
305
|
+
* delivers only within this process: **with several server replicas, participants whose sockets landed on different
|
|
306
|
+
* replicas cannot relay to each other** - a room exists independently on each replica and its members see only the
|
|
307
|
+
* members connected to the same one. Give the hub a `RedisRelayBus` and frames cross replicas over Redis pub/sub (one
|
|
308
|
+
* channel per room; `BaseVideoMeetingRoute` does that whenever `datastores:events` is configured); nothing else in the
|
|
309
|
+
* hub changes for it.
|
|
310
|
+
*
|
|
311
|
+
* Every limit above is enforced **per replica**, because a hub only knows its own sockets. A room's capacity across
|
|
312
|
+
* replicas is therefore up to `replicas x RELAY_MAX_SOCKETS_PER_ROOM` sockets (and `replicas x
|
|
313
|
+
* RELAY_MAX_SOCKETS_PER_UID` for one uid) in the worst case, where the load balancer spreads a meeting's sockets
|
|
314
|
+
* evenly. A peer id that registers on two replicas is not detected as a duplicate either (the "Replaced." rule is
|
|
315
|
+
* per replica), and each frame reaches every socket that wants its sender on every replica.
|
|
316
|
+
*
|
|
317
|
+
* @author Jean-Philippe Steinmetz
|
|
318
|
+
*/
|
|
319
|
+
export class RelayHub {
|
|
320
|
+
constructor(options = {}) {
|
|
321
|
+
/** What has happened to frames so far. */
|
|
322
|
+
this.stats = {
|
|
323
|
+
framesForwarded: 0,
|
|
324
|
+
framesDroppedBackpressure: 0,
|
|
325
|
+
framesDroppedBudget: 0,
|
|
326
|
+
framesDroppedInvalid: 0,
|
|
327
|
+
};
|
|
328
|
+
this.rooms = new Map();
|
|
329
|
+
this.bus = options.bus ?? new InProcessRelayBus();
|
|
330
|
+
this.logger = options.logger;
|
|
331
|
+
this.env = {
|
|
332
|
+
stats: this.stats,
|
|
333
|
+
bus: this.bus,
|
|
334
|
+
maxPayloadBytes: options.maxPayloadBytes ?? RELAY_MAX_PAYLOAD_BYTES,
|
|
335
|
+
debug: (message) => this.logger?.debug(message),
|
|
336
|
+
remove: (conn) => this.remove(conn),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/** The number of rooms that currently hold at least one socket. */
|
|
340
|
+
get roomCount() {
|
|
341
|
+
return this.rooms.size;
|
|
342
|
+
}
|
|
343
|
+
/** The number of sockets in `roomId` (0 for an unknown room). */
|
|
344
|
+
socketCount(roomId) {
|
|
345
|
+
return this.rooms.get(roomId)?.conns.size ?? 0;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Accepts an already-authorized `socket` of `uid` into room `roomId`, or refuses it (closing it with 1008) when
|
|
349
|
+
* the room or the uid is at its limit. Returns the connection to feed the socket's events to, or `undefined`
|
|
350
|
+
* when refused.
|
|
351
|
+
*/
|
|
352
|
+
attach(roomId, uid, socket) {
|
|
353
|
+
let room = this.rooms.get(roomId);
|
|
354
|
+
if (room) {
|
|
355
|
+
if (room.conns.size >= RELAY_MAX_SOCKETS_PER_ROOM) {
|
|
356
|
+
socket.close(1008, "Room full.");
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
let ofUid = 0;
|
|
360
|
+
for (const conn of room.conns) {
|
|
361
|
+
if (conn.uid === uid) {
|
|
362
|
+
ofUid++;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (ofUid >= RELAY_MAX_SOCKETS_PER_UID) {
|
|
366
|
+
socket.close(1008, "Too many connections.");
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
room = {
|
|
372
|
+
id: roomId,
|
|
373
|
+
conns: new Set(),
|
|
374
|
+
peers: new Map(),
|
|
375
|
+
unsubscribe: this.bus.subscribe(roomId, (sender, frame) => this.deliver(roomId, sender, frame)),
|
|
376
|
+
};
|
|
377
|
+
this.rooms.set(roomId, room);
|
|
378
|
+
}
|
|
379
|
+
const conn = new Connection(this.env, room, uid, socket);
|
|
380
|
+
room.conns.add(conn);
|
|
381
|
+
return conn;
|
|
382
|
+
}
|
|
383
|
+
/** Forgets `conn`, and its room once that is empty. */
|
|
384
|
+
remove(conn) {
|
|
385
|
+
const room = conn.room;
|
|
386
|
+
room.conns.delete(conn);
|
|
387
|
+
if (conn.peer && room.peers.get(conn.peer) === conn) {
|
|
388
|
+
room.peers.delete(conn.peer);
|
|
389
|
+
}
|
|
390
|
+
if (room.conns.size === 0 && this.rooms.get(room.id) === room) {
|
|
391
|
+
room.unsubscribe();
|
|
392
|
+
this.rooms.delete(room.id);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** Hands `frame` from `sender` to every other socket of room `roomId` that wants it. A bus that delivers
|
|
396
|
+
* asynchronously (across replicas) may deliver a frame for a room that has just emptied; that is ignored. */
|
|
397
|
+
deliver(roomId, sender, frame) {
|
|
398
|
+
for (const conn of this.rooms.get(roomId)?.peers.values() ?? []) {
|
|
399
|
+
if (conn.peer !== sender && conn.wanted.has(sender)) {
|
|
400
|
+
conn.forward(frame);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
//# sourceMappingURL=RelayHub.js.map
|