@rapidmx/meet-plugin 0.15.0 → 0.16.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.
Files changed (74) hide show
  1. package/README.md +35 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +508 -483
  4. package/apps/meet/_CallView.tsx +1060 -924
  5. package/apps/meet/_ChatPanel.tsx +194 -0
  6. package/apps/meet/_ColorWheel.tsx +136 -0
  7. package/apps/meet/_MeetLobby.tsx +224 -224
  8. package/apps/meet/_ParticipantTile.tsx +243 -242
  9. package/apps/meet/_Whiteboard.tsx +577 -0
  10. package/apps/meet/_icons.tsx +135 -90
  11. package/apps/meet/_meetApi.ts +213 -212
  12. package/apps/shared/collab/CollabSession.ts +675 -0
  13. package/apps/shared/collab/WhiteboardModel.ts +216 -0
  14. package/apps/shared/collab/chatMarkdown.tsx +375 -0
  15. package/apps/shared/collab/color.ts +67 -0
  16. package/apps/shared/collab/protocol.ts +321 -0
  17. package/apps/shared/collab/whiteboardRender.ts +311 -0
  18. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  19. package/apps/shared/media/filters/mlModels.ts +174 -174
  20. package/apps/shared/relay/RelayClient.ts +2 -1
  21. package/apps/shared/relay/RelayTransport.ts +2 -1
  22. package/apps/shared/relay/relayEnv.ts +1 -1
  23. package/apps/shared/signaling/SignalingClient.ts +335 -0
  24. package/apps/shared/webrtc/MeshConnectionManager.ts +971 -971
  25. package/apps/shared/webrtc/types.ts +335 -316
  26. package/dist/apps/meet/[token].js +1 -1
  27. package/dist/apps/meet/_CallControls.d.ts +12 -4
  28. package/dist/apps/meet/_CallControls.js +5 -5
  29. package/dist/apps/meet/_CallView.d.ts +19 -4
  30. package/dist/apps/meet/_CallView.js +100 -11
  31. package/dist/apps/meet/_ChatPanel.d.ts +24 -0
  32. package/dist/apps/meet/_ChatPanel.js +116 -0
  33. package/dist/apps/meet/_ColorWheel.d.ts +14 -0
  34. package/dist/apps/meet/_ColorWheel.js +82 -0
  35. package/dist/apps/meet/_ParticipantTile.d.ts +2 -1
  36. package/dist/apps/meet/_ParticipantTile.js +1 -1
  37. package/dist/apps/meet/_Whiteboard.d.ts +64 -0
  38. package/dist/apps/meet/_Whiteboard.js +312 -0
  39. package/dist/apps/meet/_icons.d.ts +12 -0
  40. package/dist/apps/meet/_icons.js +12 -0
  41. package/dist/apps/meet/_meetApi.d.ts +8 -7
  42. package/dist/apps/meet/_meetApi.js +2 -2
  43. package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
  44. package/dist/apps/shared/collab/CollabSession.js +559 -0
  45. package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
  46. package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
  47. package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
  48. package/dist/apps/shared/collab/chatMarkdown.js +311 -0
  49. package/dist/apps/shared/collab/color.d.ts +18 -0
  50. package/dist/apps/shared/collab/color.js +59 -0
  51. package/dist/apps/shared/collab/protocol.d.ts +157 -0
  52. package/dist/apps/shared/collab/protocol.js +222 -0
  53. package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
  54. package/dist/apps/shared/collab/whiteboardRender.js +268 -0
  55. package/dist/apps/shared/relay/RelayClient.js +2 -1
  56. package/dist/apps/shared/relay/RelayTransport.d.ts +2 -1
  57. package/dist/apps/shared/relay/RelayTransport.js +2 -1
  58. package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
  59. package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
  60. package/dist/apps/shared/signaling/SignalingClient.js +278 -0
  61. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
  62. package/dist/apps/shared/webrtc/MeshConnectionManager.js +8 -8
  63. package/dist/apps/shared/webrtc/types.d.ts +38 -24
  64. package/dist/apps/shared/webrtc/types.js +3 -4
  65. package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
  66. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  67. package/dist/lib/util/SignalingHub.js +339 -0
  68. package/dist/lib/util/SignalingHub.js.map +1 -0
  69. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
  70. package/dist/types/util/SignalingHub.d.ts +112 -0
  71. package/package.json +208 -208
  72. package/apps/shared/push/GuestSignalingClient.ts +0 -359
  73. package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
  74. package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
@@ -0,0 +1,335 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The `SignalingChannel` (`../webrtc/types.js`) of a call: one WebSocket to this plugin's own signaling route
7
+ * (`BaseVideoMeetingRoute.signal()`, `wss://<origin>/api/mail/video-meetings/signal/<meetingUid>`), carrying every
8
+ * `SignalMessage` both ways - see the backend's `SignalingHub` for the protocol this speaks:
9
+ *
10
+ * - Once the socket opens, it says `{"op":"hello","v":1,"peer":<this tab's peer id>}`; the server answers
11
+ * `{"op":"ready","v":1,"maxMessageBytes":N}` and `connect()` resolves.
12
+ * - `send()` writes `{"op":"signal","data":<message>}`. The server stamps `from`/`peer` itself, and delivers a message
13
+ * with `to` to that peer only - so unlike the shared `/push` channel this replaced, nobody receives offers, answers or
14
+ * ICE candidates meant for someone else, and the client never sees its own messages echoed back.
15
+ * - A message the server delivers arrives as `{"op":"signal","data":...}` and is handed to every `onMessage()` listener.
16
+ * - A `ping` every `SIGNALING_KEEPALIVE_MS` keeps proxies from closing an idle socket.
17
+ *
18
+ * ## Authentication
19
+ *
20
+ * A browser `WebSocket` cannot send an `Authorization` header, so the upgrade is authenticated by the `jwt` cookie.
21
+ * For a guest (`token` given - `VideoMeetingJoinResult.authenticated` is `false`) this client sets that cookie to the
22
+ * guest token right before connecting (`applyAuthCookie()`), and expires it on `close()`; the media relay's own
23
+ * socket (`../relay/RelayClient.ts`) then authenticates with the same cookie. For a caller already signed in to RapidMX
24
+ * (`token` omitted) it writes nothing: a script cannot overwrite the real `HttpOnly` session cookie anyway, and that
25
+ * session is the identity `join()` granted.
26
+ *
27
+ * ## Losing the connection
28
+ *
29
+ * A socket that drops after `connect()` resolved is reopened with jittered exponential backoff, says `hello` again
30
+ * and carries on; whatever was sent meanwhile is held (up to `SIGNALING_MAX_QUEUED`, oldest dropped first) and sent
31
+ * once the new socket is ready. A close with 1008 is the server refusing the socket - no grant, the meeting ended,
32
+ * the participant was removed - and is final: before `connect()` resolved it rejects `connect()`, after that it
33
+ * calls `onClosed` with the server's reason. (The one 1008 that is not final, "Replaced.", only ever reaches a socket
34
+ * this client has already let go of.)
35
+ */
36
+ import { apiOrigin } from "@rapidmx/web-client/lib/util/api.js";
37
+ import type { SignalMessage, SignalingChannel } from "../webrtc/types.js";
38
+
39
+ /** The version of the signaling wire protocol this client speaks - `SignalingHub`'s `SIGNALING_PROTOCOL_VERSION`. */
40
+ export const SIGNALING_PROTOCOL_VERSION = 1;
41
+
42
+ /** The first reconnect waits about this long, doubling on each further failure up to `SIGNALING_BACKOFF_MAX_MS`. */
43
+ export const SIGNALING_BACKOFF_BASE_MS = 1_000;
44
+ export const SIGNALING_BACKOFF_MAX_MS = 30_000;
45
+
46
+ /** How often an idle socket is pinged. */
47
+ export const SIGNALING_KEEPALIVE_MS = 25_000;
48
+
49
+ /** The most messages held while the socket is not ready. */
50
+ export const SIGNALING_MAX_QUEUED = 256;
51
+
52
+ /** What the server allows per message until it says otherwise in `ready` - its smallest possible limit. */
53
+ export const SIGNALING_DEFAULT_MAX_MESSAGE_BYTES: number = 16 * 1024;
54
+
55
+ /** The reason the server closes the socket with once the participant is removed from the meeting - `SignalingHub`'s
56
+ * `SIGNALING_EVICTED_REASON` (`apps/` cannot import from the backend's `src/`, so this mirrors it). */
57
+ export const SIGNALING_EVICTED_REASON = "Removed from the meeting.";
58
+
59
+ /** The subset of the browser's `WebSocket` this client uses, so a test's fake can be a plain object. */
60
+ export interface SignalingSocket {
61
+ readyState: number;
62
+ send(data: string): void;
63
+ close(code?: number, reason?: string): void;
64
+ onopen: ((event: unknown) => void) | null;
65
+ onmessage: ((event: { data: unknown }) => void) | null;
66
+ onclose: ((event: { code?: number; reason?: string }) => void) | null;
67
+ onerror: ((event: unknown) => void) | null;
68
+ }
69
+
70
+ export type SignalingSocketFactory = (url: string) => SignalingSocket;
71
+
72
+ const SOCKET_OPEN = 1;
73
+
74
+ /** The cookie name `JWTStrategyOptions.cookieName` defaults to (and this deployment always uses). */
75
+ const AUTH_COOKIE_NAME = "jwt";
76
+
77
+ /** The signaling WebSocket's URL for a meeting - the same origin derivation as the relay's `relayUrl()`. `undefined`
78
+ * when there is no origin at all (not running in a browser). */
79
+ export function signalingUrl(meetingUid: string): string | undefined {
80
+ const origin = apiOrigin() || (typeof window !== "undefined" ? window.location.origin : "");
81
+ return origin ? `${origin.replace(/^http/i, "ws")}/api/mail/video-meetings/signal/${encodeURIComponent(meetingUid)}` : undefined;
82
+ }
83
+
84
+ function defaultSocketFactory(): SignalingSocketFactory | undefined {
85
+ return typeof WebSocket === "undefined" ? undefined : (url) => new WebSocket(url) as unknown as SignalingSocket;
86
+ }
87
+
88
+ export interface SignalingClientOptions {
89
+ /** The meeting's uid. */
90
+ meetingUid: string;
91
+ /** This tab's peer id (`<uid>~<random>` - see `_CallView.tsx`'s `newPeerId()`), announced in `hello`. */
92
+ peerId: string;
93
+ /** The guest JWT from `VideoMeetingJoinResult.token`; omitted for a caller already signed in - see this module's
94
+ * doc comment on authentication. */
95
+ token?: string;
96
+ /** Called once when the server closes the socket for good after `connect()` resolved, with its reason. */
97
+ onClosed?: (reason: string) => void;
98
+ /** Overrides `signalingUrl(meetingUid)`. */
99
+ url?: string;
100
+ createSocket?: SignalingSocketFactory;
101
+ /** Overrides `document`, for a test - see `applyAuthCookie()`. */
102
+ documentRef?: Pick<Document, "cookie"> & { location?: Pick<Location, "protocol"> };
103
+ random?: () => number;
104
+ }
105
+
106
+ export class SignalingClient implements SignalingChannel {
107
+ private socket: SignalingSocket | undefined;
108
+ private ready = false;
109
+ private closed = false;
110
+ private everReady = false;
111
+ private attempt = 0;
112
+ private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
113
+ private keepaliveTimer: ReturnType<typeof setInterval> | undefined;
114
+ private cookieApplied = false;
115
+ private maxBytes: number = SIGNALING_DEFAULT_MAX_MESSAGE_BYTES;
116
+ private readonly queue: string[] = [];
117
+ private readonly listeners = new Set<(message: SignalMessage) => void>();
118
+ private pendingConnect: { resolve: () => void; reject: (err: Error) => void } | undefined;
119
+
120
+ constructor(private readonly options: SignalingClientOptions) {}
121
+
122
+ /** The largest message (the whole `signal` envelope, in bytes) the server accepts - what its `ready` said. */
123
+ get maxMessageBytes(): number {
124
+ return this.maxBytes;
125
+ }
126
+
127
+ /** Opens the socket and resolves once the server is ready; rejects when the server refuses it, or there is no way
128
+ * to open one at all (no `WebSocket`, no origin). Calling it on a closed client rejects at once. */
129
+ connect(): Promise<void> {
130
+ if (this.closed) {
131
+ return Promise.reject(new Error("This signaling client has already been closed."));
132
+ }
133
+ return new Promise<void>((resolve, reject) => {
134
+ this.pendingConnect = { resolve, reject };
135
+ this.openSocket();
136
+ });
137
+ }
138
+
139
+ /** Closes the socket, stops reconnecting and clears the guest auth cookie this client applied - idempotent. */
140
+ close(): void {
141
+ if (this.closed) {
142
+ return;
143
+ }
144
+ this.closed = true;
145
+ this.ready = false;
146
+ clearTimeout(this.reconnectTimer);
147
+ this.stopKeepalive();
148
+ const socket = this.socket;
149
+ this.socket = undefined;
150
+ if (socket) {
151
+ socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null;
152
+ try {
153
+ socket.close(1000, "closing");
154
+ } catch {
155
+ // Already gone.
156
+ }
157
+ }
158
+ this.clearAuthCookie();
159
+ this.listeners.clear();
160
+ this.queue.length = 0;
161
+ this.pendingConnect?.reject(new Error("Closed before the signaling channel connected."));
162
+ this.pendingConnect = undefined;
163
+ }
164
+
165
+ onMessage(handler: (message: SignalMessage) => void): () => void {
166
+ this.listeners.add(handler);
167
+ return () => this.listeners.delete(handler);
168
+ }
169
+
170
+ /** Sends `message` now, or once the socket is ready again - see this module's doc comment. A no-op once closed. */
171
+ send(message: SignalMessage): void {
172
+ if (this.closed) {
173
+ return;
174
+ }
175
+ const text = JSON.stringify({ op: "signal", data: message });
176
+ // Straight out only when nothing is waiting, so a message never overtakes one queued before it.
177
+ if (this.ready && this.queue.length === 0 && this.write(text)) {
178
+ return;
179
+ }
180
+ if (this.queue.length >= SIGNALING_MAX_QUEUED) {
181
+ this.queue.shift();
182
+ }
183
+ this.queue.push(text);
184
+ }
185
+
186
+ private openSocket(): void {
187
+ const url = this.options.url ?? signalingUrl(this.options.meetingUid);
188
+ const createSocket = this.options.createSocket ?? defaultSocketFactory();
189
+ if (!url || !createSocket) {
190
+ this.fail(new Error("This browser (or this page's connection) doesn't support the signaling channel."));
191
+ return;
192
+ }
193
+ this.applyAuthCookie();
194
+ let socket: SignalingSocket;
195
+ try {
196
+ socket = createSocket(url);
197
+ } catch {
198
+ this.scheduleReconnect();
199
+ return;
200
+ }
201
+ this.socket = socket;
202
+ socket.onopen = () => this.write(JSON.stringify({ op: "hello", v: SIGNALING_PROTOCOL_VERSION, peer: this.options.peerId }));
203
+ socket.onmessage = (event) => this.handleFrame(event.data);
204
+ socket.onclose = (event) => {
205
+ if (this.socket !== socket) {
206
+ return;
207
+ }
208
+ this.socket = undefined;
209
+ this.ready = false;
210
+ this.stopKeepalive();
211
+ if (event?.code === 1008) {
212
+ this.refused(event.reason || "The meeting refused the connection.");
213
+ return;
214
+ }
215
+ this.scheduleReconnect();
216
+ };
217
+ // An error is always followed by a close, which decides what happens next.
218
+ socket.onerror = () => undefined;
219
+ }
220
+
221
+ private handleFrame(raw: unknown): void {
222
+ if (typeof raw !== "string") {
223
+ return;
224
+ }
225
+ let frame: { op?: unknown; data?: unknown; maxMessageBytes?: unknown };
226
+ try {
227
+ const parsed: unknown = JSON.parse(raw);
228
+ if (!parsed || typeof parsed !== "object") {
229
+ return;
230
+ }
231
+ frame = parsed;
232
+ } catch {
233
+ return;
234
+ }
235
+ if (frame.op === "ready") {
236
+ this.handleReady(frame.maxMessageBytes);
237
+ } else if (frame.op === "signal" && frame.data && typeof frame.data === "object") {
238
+ for (const listener of [...this.listeners]) {
239
+ listener(frame.data as SignalMessage);
240
+ }
241
+ }
242
+ }
243
+
244
+ private handleReady(maxMessageBytes: unknown): void {
245
+ if (typeof maxMessageBytes === "number" && Number.isFinite(maxMessageBytes) && maxMessageBytes > 0) {
246
+ this.maxBytes = maxMessageBytes;
247
+ }
248
+ this.ready = true;
249
+ this.everReady = true;
250
+ this.attempt = 0;
251
+ this.startKeepalive();
252
+ while (this.queue.length > 0 && this.write(this.queue[0])) {
253
+ this.queue.shift();
254
+ }
255
+ this.pendingConnect?.resolve();
256
+ this.pendingConnect = undefined;
257
+ }
258
+
259
+ /** Writes `text` to the open socket; `false` when there is none or it threw. */
260
+ private write(text: string): boolean {
261
+ const socket = this.socket;
262
+ if (!socket || socket.readyState !== SOCKET_OPEN) {
263
+ return false;
264
+ }
265
+ try {
266
+ socket.send(text);
267
+ return true;
268
+ } catch {
269
+ return false;
270
+ }
271
+ }
272
+
273
+ private startKeepalive(): void {
274
+ this.stopKeepalive();
275
+ this.keepaliveTimer = setInterval(() => this.write(JSON.stringify({ op: "ping", t: Date.now() })), SIGNALING_KEEPALIVE_MS);
276
+ }
277
+
278
+ private stopKeepalive(): void {
279
+ clearInterval(this.keepaliveTimer);
280
+ this.keepaliveTimer = undefined;
281
+ }
282
+
283
+ private scheduleReconnect(): void {
284
+ if (this.closed) {
285
+ return;
286
+ }
287
+ const ceiling = Math.min(SIGNALING_BACKOFF_MAX_MS, SIGNALING_BACKOFF_BASE_MS * 2 ** Math.min(this.attempt, 10));
288
+ const delay = ceiling / 2 + ((this.options.random ?? Math.random)() * ceiling) / 2;
289
+ this.attempt += 1;
290
+ this.reconnectTimer = setTimeout(() => {
291
+ this.reconnectTimer = undefined;
292
+ this.openSocket();
293
+ }, delay);
294
+ }
295
+
296
+ /** The server closed the socket for good - see this module's doc comment. */
297
+ private refused(reason: string): void {
298
+ if (this.pendingConnect) {
299
+ this.fail(new Error(reason));
300
+ return;
301
+ }
302
+ if (this.everReady) {
303
+ this.options.onClosed?.(reason);
304
+ }
305
+ }
306
+
307
+ private fail(err: Error): void {
308
+ this.pendingConnect?.reject(err);
309
+ this.pendingConnect = undefined;
310
+ }
311
+
312
+ /** Sets the `jwt` cookie to the guest `token`, so the browser attaches it to the upgrade this client is about to
313
+ * open - see this module's doc comment. A no-op without a token or a document. */
314
+ private applyAuthCookie(): void {
315
+ if (!this.options.token) {
316
+ return;
317
+ }
318
+ const doc = this.options.documentRef ?? (typeof document === "undefined" ? undefined : document);
319
+ if (!doc) {
320
+ return;
321
+ }
322
+ this.cookieApplied = true;
323
+ const secure = (doc.location ?? (typeof location === "undefined" ? undefined : location))?.protocol === "https:" ? "; Secure" : "";
324
+ doc.cookie = `${AUTH_COOKIE_NAME}=${encodeURIComponent(this.options.token)}; path=/; SameSite=Lax${secure}`;
325
+ }
326
+
327
+ /** Expires the cookie `applyAuthCookie()` set; a no-op when it set none. */
328
+ private clearAuthCookie(): void {
329
+ if (!this.cookieApplied) {
330
+ return;
331
+ }
332
+ const doc = this.options.documentRef ?? (typeof document === "undefined" ? undefined : document);
333
+ doc!.cookie = `${AUTH_COOKIE_NAME}=; path=/; Max-Age=0`;
334
+ }
335
+ }