@rapidmx/meet-plugin 0.13.1 → 0.14.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.
Files changed (41) hide show
  1. package/README.md +55 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +463 -394
  4. package/apps/meet/_CallView.tsx +891 -848
  5. package/apps/meet/_DiagnosticsWindow.tsx +80 -0
  6. package/apps/meet/_MeetLobby.tsx +224 -224
  7. package/apps/meet/_ParticipantTile.tsx +169 -169
  8. package/apps/meet/_ParticipantsDrawer.tsx +10 -104
  9. package/apps/meet/_SettingsModal.tsx +152 -0
  10. package/apps/meet/_icons.tsx +80 -82
  11. package/apps/meet/_meetApi.ts +212 -212
  12. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  13. package/apps/shared/media/filters/mlModels.ts +174 -174
  14. package/apps/shared/push/GuestSignalingClient.ts +359 -359
  15. package/apps/shared/relay/RelayClient.ts +7 -2
  16. package/apps/shared/webrtc/MeshConnectionManager.ts +949 -795
  17. package/apps/shared/webrtc/types.ts +247 -222
  18. package/dist/apps/meet/_CallControls.d.ts +19 -9
  19. package/dist/apps/meet/_CallControls.js +28 -10
  20. package/dist/apps/meet/_CallView.d.ts +14 -2
  21. package/dist/apps/meet/_CallView.js +27 -2
  22. package/dist/apps/meet/_DiagnosticsWindow.d.ts +25 -0
  23. package/dist/apps/meet/_DiagnosticsWindow.js +45 -0
  24. package/dist/apps/meet/_ParticipantsDrawer.d.ts +10 -21
  25. package/dist/apps/meet/_ParticipantsDrawer.js +9 -39
  26. package/dist/apps/meet/_SettingsModal.d.ts +28 -0
  27. package/dist/apps/meet/_SettingsModal.js +59 -0
  28. package/dist/apps/meet/_icons.d.ts +1 -1
  29. package/dist/apps/meet/_icons.js +1 -1
  30. package/dist/apps/shared/relay/RelayClient.d.ts +6 -1
  31. package/dist/apps/shared/relay/RelayClient.js +7 -2
  32. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +55 -2
  33. package/dist/apps/shared/webrtc/MeshConnectionManager.js +153 -9
  34. package/dist/apps/shared/webrtc/types.d.ts +25 -1
  35. package/dist/lib/util/RedisRelayBus.js +61 -5
  36. package/dist/lib/util/RedisRelayBus.js.map +1 -1
  37. package/dist/lib/util/RelayHub.js +10 -3
  38. package/dist/lib/util/RelayHub.js.map +1 -1
  39. package/dist/types/util/RedisRelayBus.d.ts +45 -6
  40. package/dist/types/util/RelayHub.d.ts +9 -2
  41. package/package.json +208 -208
@@ -1,359 +1,359 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- /**
6
- * A standalone `SignalingChannel` (`../webrtc/types.js`) over `@rapidrest/service-core`'s `/push` WebSocket -
7
- * built from scratch rather than reusing `@rapidmx/web-client`'s `PushClient`/`getPushClient()` (`lib/mail/pushClient.js`).
8
- *
9
- * ## Why not `PushClient`
10
- *
11
- * `PushClient` was investigated first, as this plugin's Phase 2 `.claude/NOTES.md` entry required. Its `connect()`
12
- * opens a bare `new WebSocket(url)` and relies entirely on the browser automatically attaching this deployment's
13
- * `jwt` `HttpOnly` cookie to the upgrade request - it never sends a token of its own (see its own doc comment:
14
- * "a script can't read that cookie, so there is no token to send"). That fits the webmail tab's own authenticated
15
- * session perfectly, but this plugin's anonymous guest has no such cookie at all - only the bearer `token`
16
- * `BaseVideoMeetingRoute.join()` minted, handed to this client explicitly. `PushClient`'s API has no parameter
17
- * anywhere to supply that token, and its shared-singleton design (`getPushClient()`, one client per tab) is itself
18
- * the wrong shape here too: a guest's signaling identity must never be confused with (or silently share a socket
19
- * with) whatever webmail session, if any, happens to already be open in the same browser. So this module
20
- * implements the same wire protocol independently instead - see `../../.claude/NOTES.md` and
21
- * `BasePushRoute`/`JWTStrategy` (`@rapidrest/service-core`) for the protocol this matches:
22
- *
23
- * - `wss://<origin>/push` - authenticated at the WebSocket upgrade. A real browser `WebSocket` cannot attach a
24
- * custom `Authorization` header to that upgrade request at all (a WHATWG API limitation, not something any
25
- * client-side code can work around) - `JWTStrategy.getAuthToken()`'s only other source is the same `jwt` cookie
26
- * `PushClient` already relies on. When `token` is supplied (the common anonymous/guest case -
27
- * `VideoMeetingJoinResult.authenticated` is `false`), this client sets that cookie itself, to `token`, immediately
28
- * before connecting (`applyAuthCookie()`). When `token` is omitted (the caller already authenticated as a real
29
- * RapidMX identity in `join()` - `authenticated: true` - see the FIXED LIMITATION below), this client writes no
30
- * cookie at all and simply lets the browser attach whatever real `jwt` session cookie already exists for this
31
- * origin, exactly as `PushClient` does for an ordinary webmail session.
32
- * - Right after connecting, the server sends `{ id: 0, type: "SUBSCRIBED", data: [...] }` (channels the socket
33
- * already holds - the caller's own uid channel, never this meeting's). `connect()` then explicitly `SUBSCRIBE`s to
34
- * the one meeting channel and waits for the matching `{ id, type: "SUBSCRIBED", data }` reply, resolving once
35
- * `channel` is actually in that reply's `data` (rejecting otherwise - an unauthorized/expired token, or the
36
- * meeting's ACL grant somehow missing).
37
- * - Publishing (`send()`) is **not** a WebSocket message at all - `BasePushRoute.send()` is `POST /push/:id`, a
38
- * perfectly ordinary authenticated HTTP endpoint. When `token` is supplied it's sent there as a normal
39
- * `Authorization: Bearer` header (no browser limitation applies to a plain `fetch()`); when it's omitted, the
40
- * request carries no `Authorization` header at all and instead relies on `fetch()`'s own default same-origin
41
- * credentials mode, which already attaches the browser's real `jwt` cookie to a same-origin request with zero
42
- * extra code - `JWTStrategy.getAuthToken()`'s cookie fallback authenticates it exactly as it would any other
43
- * same-origin authenticated call. Either way `send()` also attaches the CSRF double-submit header
44
- * (`withCsrfHeader()`) - required server-side only for the cookie case, but harmless to send for the bearer one
45
- * too - see `send()`'s own doc comment. Fire-and-forget either way, matching every other push publish in this
46
- * codebase - a failed send is logged, never thrown, since a caller (`MeshConnectionManager`) has no meaningful
47
- * per-message retry of its own.
48
- * - An incoming meeting message arrives wrapped `{ type: "MESSAGE", channel, data: <the posted body> }` - this
49
- * client unwraps it and delivers `data` (expected to be a `SignalMessage`) to `onMessage()` listeners.
50
- *
51
- * ## FIXED: the browser-session-collision limitation this module used to carry
52
- *
53
- * Phase 2 originally always wrote the guest `token` as the `jwt` cookie before connecting, unconditionally. If the
54
- * visiting browser already held a *real*, `HttpOnly` `jwt` session cookie for this exact origin (a logged-in
55
- * RapidMX user, e.g. with webmail open), that write was silently blocked by the browser's own "script cannot
56
- * override an `HttpOnly` cookie of the same name" protection - the WebSocket then authenticated as that real
57
- * session instead of the intended guest identity, and since the meeting's `AccessControlList` grant only named the
58
- * guest uid, the real session's `SUBSCRIBE` was simply refused. Net effect: a logged-in user could never actually
59
- * join a call at all (safe - never a cross-identity leak - but broken).
60
- *
61
- * The fix is in `BaseVideoMeetingRoute.join()` (backend): when the caller already presents a valid session for a
62
- * real RapidMX identity, it grants *that* identity's own uid on the meeting's channel directly and returns
63
- * `authenticated: true` with no guest `token` at all - see `VideoMeetingJoinResult`'s doc comment. This module's
64
- * own half of the fix is simply to stop writing a cookie (and stop sending a bearer header) whenever `token` is
65
- * absent, as described above - the browser's real cookie was always going to win that write anyway, so the
66
- * correct behavior is to not fight it and let it authenticate normally instead.
67
- */
68
- import { apiOrigin, withCsrfHeader } from "@rapidmx/web-client/lib/util/api.js";
69
- import { pushUrl } from "@rapidmx/web-client/lib/mail/pushClient.js";
70
- import type { SignalMessage, SignalingChannel } from "../webrtc/types.js";
71
-
72
- /** The subset of the browser's `WebSocket` this client uses - matches `PushClient`'s own identical seam, so a
73
- * test's fake can be a plain object with no real socket behind it. */
74
- export interface PushSocket {
75
- readyState: number;
76
- send(data: string): void;
77
- close(code?: number, reason?: string): void;
78
- onopen: ((event: unknown) => void) | null;
79
- onmessage: ((event: { data: unknown }) => void) | null;
80
- onclose: ((event: unknown) => void) | null;
81
- onerror: ((event: unknown) => void) | null;
82
- }
83
-
84
- export type PushSocketFactory = (url: string) => PushSocket;
85
-
86
- const SOCKET_OPEN = 1;
87
-
88
- function defaultSocketFactory(): PushSocketFactory | undefined {
89
- return typeof WebSocket === "undefined" ? undefined : (url) => new WebSocket(url) as unknown as PushSocket;
90
- }
91
-
92
- /** The cookie name `JWTStrategyOptions.cookieName` defaults to (and this deployment always uses - see
93
- * `BaseVideoMeetingRoute`'s guest JWT doc comment, signed with the same `auth` config every other token is). */
94
- const AUTH_COOKIE_NAME = "jwt";
95
-
96
- export interface GuestSignalingClientOptions {
97
- /** The meeting's own uid - both its push channel and the `POST /push/:id` target. */
98
- channel: string;
99
- /** The guest JWT from `VideoMeetingJoinResult.token`. Omitted when the caller already authenticated as a real
100
- * RapidMX identity (`VideoMeetingJoinResult.authenticated`) - this client then writes no cookie and sends no
101
- * `Authorization` header at all, relying entirely on the browser's own already-existing `jwt` session cookie
102
- * for both the WebSocket upgrade and each `POST /push/:id` publish - see this module's doc comment. */
103
- token?: string;
104
- /** Defaults to `@rapidmx/web-client`'s `pushUrl()` (`lib/mail/pushClient.js`) - the exact URL `PushClient` itself connects to. */
105
- url?: () => string | undefined;
106
- createSocket?: PushSocketFactory;
107
- fetchImpl?: typeof fetch;
108
- /** Overrides `document`, for a test - see `applyAuthCookie()`. */
109
- documentRef?: Pick<Document, "cookie"> & { location?: Pick<Location, "protocol"> };
110
- random?: () => number;
111
- }
112
-
113
- /** The first reconnect waits about this long, doubling on each further failure, matching `PushClient`'s own
114
- * constants (kept independent rather than imported, since this module deliberately doesn't depend on
115
- * `pushClient.ts` beyond its `pushUrl()` helper - see this module's doc comment on why it isn't `PushClient` itself). */
116
- export const SIGNALING_BACKOFF_BASE_MS = 1_000;
117
- export const SIGNALING_BACKOFF_MAX_MS = 30_000;
118
-
119
- export class GuestSignalingClient implements SignalingChannel {
120
- private socket: PushSocket | undefined;
121
- private closed = false;
122
- private nextRequestId = 1;
123
- private attempt = 0;
124
- private timer: ReturnType<typeof setTimeout> | undefined;
125
- private cookieApplied = false;
126
- private readonly listeners = new Set<(message: SignalMessage) => void>();
127
- private pendingConnect: { resolve: () => void; reject: (err: Error) => void } | undefined;
128
-
129
- constructor(private readonly options: GuestSignalingClientOptions) {}
130
-
131
- /** Connects, authenticates (see this module's doc comment) and subscribes to the meeting's own channel.
132
- * Resolves once genuinely ready to send/receive; rejects if the channel is refused (an invalid/expired token,
133
- * or - now fixed rather than merely possible, see the FIXED LIMITATION section above - a stale grant) or there
134
- * is nothing to connect with at all (no `WebSocket`, e.g. a non-HTTPS context in some browsers - the lobby's
135
- * own device-support checks already cover the more common case of no `mediaDevices`, but this is an
136
- * independent capability). Calling this a second time on an already-closed client rejects immediately. */
137
- connect(): Promise<void> {
138
- if (this.closed) {
139
- return Promise.reject(new Error("This signaling client has already been closed."));
140
- }
141
- return new Promise<void>((resolve, reject) => {
142
- this.pendingConnect = { resolve, reject };
143
- this.openSocket();
144
- });
145
- }
146
-
147
- /** Closes the socket, stops reconnecting and clears the guest auth cookie this client applied (see
148
- * `applyAuthCookie()`) - idempotent. */
149
- close(): void {
150
- if (this.closed) {
151
- return;
152
- }
153
- this.closed = true;
154
- clearTimeout(this.timer);
155
- this.timer = undefined;
156
- const socket = this.socket;
157
- this.socket = undefined;
158
- if (socket) {
159
- socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null;
160
- try {
161
- socket.close(1000, "closing");
162
- } catch {
163
- // Already gone.
164
- }
165
- }
166
- this.clearAuthCookie();
167
- this.listeners.clear();
168
- this.pendingConnect?.reject(new Error("Closed before the signaling channel connected."));
169
- this.pendingConnect = undefined;
170
- }
171
-
172
- onMessage(handler: (message: SignalMessage) => void): () => void {
173
- this.listeners.add(handler);
174
- return () => this.listeners.delete(handler);
175
- }
176
-
177
- /** Publishes over `POST /push/:id`, with `token` as a bearer credential when present - see this module's doc
178
- * comment on why this, unlike subscribing, needs no cookie workaround at all, and on the omitted-`token` case
179
- * (an already-authenticated real caller), which relies on the browser's own real `jwt` cookie instead - and,
180
- * because that's a *cookie*-authenticated request, also needs the matching CSRF header
181
- * (`@rapidmx/web-client`'s `withCsrfHeader()`, the same double-submit echo `apiFetch()` applies to every other
182
- * mutating call this server answers): without it, `RouteUtils.checkCsrf()` refuses the request with 403
183
- * `"This request is missing a valid CSRF token."` before it ever reaches `BasePushRoute.send()`'s own ACL
184
- * check - every `hello`/offer/answer/ICE candidate/`bye` a participant on that path sends 403s and is
185
- * silently dropped (`send()` is fire-and-forget), which looks exactly like "stuck connecting, then no audio or
186
- * video" from everyone else's side. `withCsrfHeader()` is harmless to also add on the bearer-token (guest)
187
- * branch - the server only enforces the check for `req.auth.source === "cookie"` - so it's applied
188
- * unconditionally here rather than only for the cookie case.
189
- * Fire-and-forget: a failure is swallowed (there is nothing a signaling message's own sender can usefully do
190
- * about a dropped publish beyond what the mesh's own `hello` re-announcement/renegotiation already tolerates -
191
- * see `MeshConnectionManager`'s doc comment on fire-and-forget delivery). */
192
- send(message: SignalMessage): void {
193
- const fetchImpl = this.options.fetchImpl ?? (typeof fetch === "undefined" ? undefined : fetch);
194
- if (!fetchImpl) {
195
- return;
196
- }
197
- const origin = apiOrigin() || (typeof window !== "undefined" ? window.location.origin : "");
198
- const headers: Record<string, string> = { "Content-Type": "application/json" };
199
- if (this.options.token) {
200
- headers.Authorization = `Bearer ${this.options.token}`;
201
- }
202
- void fetchImpl(`${origin}/push/${encodeURIComponent(this.options.channel)}`, {
203
- method: "POST",
204
- headers: withCsrfHeader(headers, "POST"),
205
- body: JSON.stringify(message),
206
- // Only a `bye` needs to survive the page unloading - every other message is sent while the page (and
207
- // the rest of this call's signaling) is very much alive. `keepalive` is a real-world liability to set
208
- // unconditionally: some restrictive networks' inspecting proxies single out a `keepalive` fetch (it
209
- // tells the browser the request may outlive the page - the same signal `navigator.sendBeacon()` and an
210
- // `unload` handler's own fetch give off) for extra scrutiny or drop it outright, well short of Chrome's
211
- // documented 64 KiB-per-request/several-MB-total keepalive quota. A participant behind such a network
212
- // was seen stuck signaling forever - every `hello`/offer/answer/ICE candidate sent with `keepalive` and
213
- // silently going nowhere (`send()` is fire-and-forget) - while an identical join from an unrestricted
214
- // network connected normally.
215
- keepalive: message.kind === "bye",
216
- }).catch(() => {
217
- // Best-effort - see this method's doc comment.
218
- });
219
- }
220
-
221
- private openSocket(): void {
222
- const url = (this.options.url ?? pushUrl)();
223
- const createSocket = this.options.createSocket ?? defaultSocketFactory();
224
- if (!url || !createSocket) {
225
- this.failConnect(new Error("This browser (or this page's connection) doesn't support the signaling channel."));
226
- return;
227
- }
228
- this.applyAuthCookie();
229
- let socket: PushSocket;
230
- try {
231
- socket = createSocket(url);
232
- } catch {
233
- this.scheduleReconnect();
234
- return;
235
- }
236
- this.socket = socket;
237
- socket.onmessage = (event) => this.handleFrame(event.data);
238
- socket.onclose = () => {
239
- // Guards against a stale socket's belated close event: `close()` always sets `this.socket = undefined`
240
- // synchronously (before this handler could possibly be detached asynchronously), so once this client
241
- // is closed, `this.socket` can never equal `socket` again and this always returns here first.
242
- if (this.socket !== socket) {
243
- return;
244
- }
245
- this.socket = undefined;
246
- this.scheduleReconnect();
247
- };
248
- socket.onerror = () => undefined;
249
- }
250
-
251
- private scheduleReconnect(): void {
252
- const ceiling = Math.min(SIGNALING_BACKOFF_MAX_MS, SIGNALING_BACKOFF_BASE_MS * 2 ** Math.min(this.attempt, 10));
253
- const delay = ceiling / 2 + ((this.options.random ?? Math.random)() * ceiling) / 2;
254
- this.attempt += 1;
255
- this.timer = setTimeout(() => {
256
- this.timer = undefined;
257
- this.openSocket();
258
- }, delay);
259
- }
260
-
261
- private handleFrame(raw: unknown): void {
262
- if (typeof raw !== "string") {
263
- return;
264
- }
265
- let frame: Record<string, unknown>;
266
- try {
267
- const parsed: unknown = JSON.parse(raw);
268
- if (!parsed || typeof parsed !== "object") {
269
- return;
270
- }
271
- frame = parsed as Record<string, unknown>;
272
- } catch {
273
- return;
274
- }
275
- if (frame.type === "SUBSCRIBED") {
276
- this.handleSubscribed(frame);
277
- return;
278
- }
279
- if (frame.type === "MESSAGE" && frame.channel === this.options.channel) {
280
- const data = frame.data;
281
- if (data && typeof data === "object" && (data as { type?: unknown }).type === "video-meeting-signal") {
282
- this.emit(data as SignalMessage);
283
- }
284
- }
285
- }
286
-
287
- private handleSubscribed(frame: Record<string, unknown>): void {
288
- const channels = Array.isArray(frame.data) ? frame.data.filter((c): c is string => typeof c === "string") : [];
289
- if (frame.id === 0) {
290
- // The connect-time greeting: now actually subscribe to the one channel this client cares about.
291
- const id = this.nextRequestId++;
292
- this.send0({ id, type: "SUBSCRIBE", data: [this.options.channel] });
293
- return;
294
- }
295
- if (channels.includes(this.options.channel)) {
296
- this.attempt = 0;
297
- this.pendingConnect?.resolve();
298
- this.pendingConnect = undefined;
299
- } else if (this.pendingConnect) {
300
- this.failConnect(new Error("This meeting's signaling channel refused the subscription."));
301
- }
302
- }
303
-
304
- private send0(message: object): void {
305
- const socket = this.socket;
306
- if (!socket || socket.readyState !== SOCKET_OPEN) {
307
- return;
308
- }
309
- try {
310
- socket.send(JSON.stringify(message));
311
- } catch {
312
- // The close that follows a dead socket reconnects and re-subscribes.
313
- }
314
- }
315
-
316
- private failConnect(err: Error): void {
317
- this.pendingConnect?.reject(err);
318
- this.pendingConnect = undefined;
319
- }
320
-
321
- private emit(message: SignalMessage): void {
322
- for (const listener of [...this.listeners]) {
323
- listener(message);
324
- }
325
- }
326
-
327
- /** Sets the `jwt` cookie to `token` for this origin, so the browser attaches it (as `PushClient`'s own doc
328
- * comment describes for a real session) to the WebSocket upgrade this client is about to open - see this
329
- * module's doc comment for the full reasoning. A no-op when `token` is omitted (the caller already
330
- * authenticated as a real RapidMX identity - see the FIXED LIMITATION section above): there is then no guest
331
- * token to apply, and writing nothing leaves the browser's own real `jwt` session cookie, whatever it is,
332
- * untouched and free to authenticate the connection normally. */
333
- private applyAuthCookie(): void {
334
- if (!this.options.token) {
335
- return;
336
- }
337
- const doc = this.options.documentRef ?? (typeof document === "undefined" ? undefined : document);
338
- if (!doc) {
339
- return;
340
- }
341
- this.cookieApplied = true;
342
- const secure = (doc.location ?? (typeof location === "undefined" ? undefined : location))?.protocol === "https:" ? "; Secure" : "";
343
- doc.cookie = `${AUTH_COOKIE_NAME}=${encodeURIComponent(this.options.token)}; path=/; SameSite=Lax${secure}`;
344
- }
345
-
346
- /** Expires the cookie `applyAuthCookie()` set - a no-op if that write was itself silently blocked, or never
347
- * attempted at all because `token` was omitted (see `applyAuthCookie()`'s own doc comment), either way leaving
348
- * whatever real session cookie was already there untouched. `cookieApplied` is only ever set once
349
- * `applyAuthCookie()` has already confirmed both a `token` to write and a `doc` to write it to, and neither
350
- * `this.options.token`, `this.options.documentRef` nor the real global `document` can change mid-call, so
351
- * resolving it here is guaranteed to succeed too - no second `!doc` guard needed. */
352
- private clearAuthCookie(): void {
353
- if (!this.cookieApplied) {
354
- return;
355
- }
356
- const doc = this.options.documentRef ?? (typeof document === "undefined" ? undefined : document);
357
- doc!.cookie = `${AUTH_COOKIE_NAME}=; path=/; Max-Age=0`;
358
- }
359
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * A standalone `SignalingChannel` (`../webrtc/types.js`) over `@rapidrest/service-core`'s `/push` WebSocket -
7
+ * built from scratch rather than reusing `@rapidmx/web-client`'s `PushClient`/`getPushClient()` (`lib/mail/pushClient.js`).
8
+ *
9
+ * ## Why not `PushClient`
10
+ *
11
+ * `PushClient` was investigated first, as this plugin's Phase 2 `.claude/NOTES.md` entry required. Its `connect()`
12
+ * opens a bare `new WebSocket(url)` and relies entirely on the browser automatically attaching this deployment's
13
+ * `jwt` `HttpOnly` cookie to the upgrade request - it never sends a token of its own (see its own doc comment:
14
+ * "a script can't read that cookie, so there is no token to send"). That fits the webmail tab's own authenticated
15
+ * session perfectly, but this plugin's anonymous guest has no such cookie at all - only the bearer `token`
16
+ * `BaseVideoMeetingRoute.join()` minted, handed to this client explicitly. `PushClient`'s API has no parameter
17
+ * anywhere to supply that token, and its shared-singleton design (`getPushClient()`, one client per tab) is itself
18
+ * the wrong shape here too: a guest's signaling identity must never be confused with (or silently share a socket
19
+ * with) whatever webmail session, if any, happens to already be open in the same browser. So this module
20
+ * implements the same wire protocol independently instead - see `../../.claude/NOTES.md` and
21
+ * `BasePushRoute`/`JWTStrategy` (`@rapidrest/service-core`) for the protocol this matches:
22
+ *
23
+ * - `wss://<origin>/push` - authenticated at the WebSocket upgrade. A real browser `WebSocket` cannot attach a
24
+ * custom `Authorization` header to that upgrade request at all (a WHATWG API limitation, not something any
25
+ * client-side code can work around) - `JWTStrategy.getAuthToken()`'s only other source is the same `jwt` cookie
26
+ * `PushClient` already relies on. When `token` is supplied (the common anonymous/guest case -
27
+ * `VideoMeetingJoinResult.authenticated` is `false`), this client sets that cookie itself, to `token`, immediately
28
+ * before connecting (`applyAuthCookie()`). When `token` is omitted (the caller already authenticated as a real
29
+ * RapidMX identity in `join()` - `authenticated: true` - see the FIXED LIMITATION below), this client writes no
30
+ * cookie at all and simply lets the browser attach whatever real `jwt` session cookie already exists for this
31
+ * origin, exactly as `PushClient` does for an ordinary webmail session.
32
+ * - Right after connecting, the server sends `{ id: 0, type: "SUBSCRIBED", data: [...] }` (channels the socket
33
+ * already holds - the caller's own uid channel, never this meeting's). `connect()` then explicitly `SUBSCRIBE`s to
34
+ * the one meeting channel and waits for the matching `{ id, type: "SUBSCRIBED", data }` reply, resolving once
35
+ * `channel` is actually in that reply's `data` (rejecting otherwise - an unauthorized/expired token, or the
36
+ * meeting's ACL grant somehow missing).
37
+ * - Publishing (`send()`) is **not** a WebSocket message at all - `BasePushRoute.send()` is `POST /push/:id`, a
38
+ * perfectly ordinary authenticated HTTP endpoint. When `token` is supplied it's sent there as a normal
39
+ * `Authorization: Bearer` header (no browser limitation applies to a plain `fetch()`); when it's omitted, the
40
+ * request carries no `Authorization` header at all and instead relies on `fetch()`'s own default same-origin
41
+ * credentials mode, which already attaches the browser's real `jwt` cookie to a same-origin request with zero
42
+ * extra code - `JWTStrategy.getAuthToken()`'s cookie fallback authenticates it exactly as it would any other
43
+ * same-origin authenticated call. Either way `send()` also attaches the CSRF double-submit header
44
+ * (`withCsrfHeader()`) - required server-side only for the cookie case, but harmless to send for the bearer one
45
+ * too - see `send()`'s own doc comment. Fire-and-forget either way, matching every other push publish in this
46
+ * codebase - a failed send is logged, never thrown, since a caller (`MeshConnectionManager`) has no meaningful
47
+ * per-message retry of its own.
48
+ * - An incoming meeting message arrives wrapped `{ type: "MESSAGE", channel, data: <the posted body> }` - this
49
+ * client unwraps it and delivers `data` (expected to be a `SignalMessage`) to `onMessage()` listeners.
50
+ *
51
+ * ## FIXED: the browser-session-collision limitation this module used to carry
52
+ *
53
+ * Phase 2 originally always wrote the guest `token` as the `jwt` cookie before connecting, unconditionally. If the
54
+ * visiting browser already held a *real*, `HttpOnly` `jwt` session cookie for this exact origin (a logged-in
55
+ * RapidMX user, e.g. with webmail open), that write was silently blocked by the browser's own "script cannot
56
+ * override an `HttpOnly` cookie of the same name" protection - the WebSocket then authenticated as that real
57
+ * session instead of the intended guest identity, and since the meeting's `AccessControlList` grant only named the
58
+ * guest uid, the real session's `SUBSCRIBE` was simply refused. Net effect: a logged-in user could never actually
59
+ * join a call at all (safe - never a cross-identity leak - but broken).
60
+ *
61
+ * The fix is in `BaseVideoMeetingRoute.join()` (backend): when the caller already presents a valid session for a
62
+ * real RapidMX identity, it grants *that* identity's own uid on the meeting's channel directly and returns
63
+ * `authenticated: true` with no guest `token` at all - see `VideoMeetingJoinResult`'s doc comment. This module's
64
+ * own half of the fix is simply to stop writing a cookie (and stop sending a bearer header) whenever `token` is
65
+ * absent, as described above - the browser's real cookie was always going to win that write anyway, so the
66
+ * correct behavior is to not fight it and let it authenticate normally instead.
67
+ */
68
+ import { apiOrigin, withCsrfHeader } from "@rapidmx/web-client/lib/util/api.js";
69
+ import { pushUrl } from "@rapidmx/web-client/lib/mail/pushClient.js";
70
+ import type { SignalMessage, SignalingChannel } from "../webrtc/types.js";
71
+
72
+ /** The subset of the browser's `WebSocket` this client uses - matches `PushClient`'s own identical seam, so a
73
+ * test's fake can be a plain object with no real socket behind it. */
74
+ export interface PushSocket {
75
+ readyState: number;
76
+ send(data: string): void;
77
+ close(code?: number, reason?: string): void;
78
+ onopen: ((event: unknown) => void) | null;
79
+ onmessage: ((event: { data: unknown }) => void) | null;
80
+ onclose: ((event: unknown) => void) | null;
81
+ onerror: ((event: unknown) => void) | null;
82
+ }
83
+
84
+ export type PushSocketFactory = (url: string) => PushSocket;
85
+
86
+ const SOCKET_OPEN = 1;
87
+
88
+ function defaultSocketFactory(): PushSocketFactory | undefined {
89
+ return typeof WebSocket === "undefined" ? undefined : (url) => new WebSocket(url) as unknown as PushSocket;
90
+ }
91
+
92
+ /** The cookie name `JWTStrategyOptions.cookieName` defaults to (and this deployment always uses - see
93
+ * `BaseVideoMeetingRoute`'s guest JWT doc comment, signed with the same `auth` config every other token is). */
94
+ const AUTH_COOKIE_NAME = "jwt";
95
+
96
+ export interface GuestSignalingClientOptions {
97
+ /** The meeting's own uid - both its push channel and the `POST /push/:id` target. */
98
+ channel: string;
99
+ /** The guest JWT from `VideoMeetingJoinResult.token`. Omitted when the caller already authenticated as a real
100
+ * RapidMX identity (`VideoMeetingJoinResult.authenticated`) - this client then writes no cookie and sends no
101
+ * `Authorization` header at all, relying entirely on the browser's own already-existing `jwt` session cookie
102
+ * for both the WebSocket upgrade and each `POST /push/:id` publish - see this module's doc comment. */
103
+ token?: string;
104
+ /** Defaults to `@rapidmx/web-client`'s `pushUrl()` (`lib/mail/pushClient.js`) - the exact URL `PushClient` itself connects to. */
105
+ url?: () => string | undefined;
106
+ createSocket?: PushSocketFactory;
107
+ fetchImpl?: typeof fetch;
108
+ /** Overrides `document`, for a test - see `applyAuthCookie()`. */
109
+ documentRef?: Pick<Document, "cookie"> & { location?: Pick<Location, "protocol"> };
110
+ random?: () => number;
111
+ }
112
+
113
+ /** The first reconnect waits about this long, doubling on each further failure, matching `PushClient`'s own
114
+ * constants (kept independent rather than imported, since this module deliberately doesn't depend on
115
+ * `pushClient.ts` beyond its `pushUrl()` helper - see this module's doc comment on why it isn't `PushClient` itself). */
116
+ export const SIGNALING_BACKOFF_BASE_MS = 1_000;
117
+ export const SIGNALING_BACKOFF_MAX_MS = 30_000;
118
+
119
+ export class GuestSignalingClient implements SignalingChannel {
120
+ private socket: PushSocket | undefined;
121
+ private closed = false;
122
+ private nextRequestId = 1;
123
+ private attempt = 0;
124
+ private timer: ReturnType<typeof setTimeout> | undefined;
125
+ private cookieApplied = false;
126
+ private readonly listeners = new Set<(message: SignalMessage) => void>();
127
+ private pendingConnect: { resolve: () => void; reject: (err: Error) => void } | undefined;
128
+
129
+ constructor(private readonly options: GuestSignalingClientOptions) {}
130
+
131
+ /** Connects, authenticates (see this module's doc comment) and subscribes to the meeting's own channel.
132
+ * Resolves once genuinely ready to send/receive; rejects if the channel is refused (an invalid/expired token,
133
+ * or - now fixed rather than merely possible, see the FIXED LIMITATION section above - a stale grant) or there
134
+ * is nothing to connect with at all (no `WebSocket`, e.g. a non-HTTPS context in some browsers - the lobby's
135
+ * own device-support checks already cover the more common case of no `mediaDevices`, but this is an
136
+ * independent capability). Calling this a second time on an already-closed client rejects immediately. */
137
+ connect(): Promise<void> {
138
+ if (this.closed) {
139
+ return Promise.reject(new Error("This signaling client has already been closed."));
140
+ }
141
+ return new Promise<void>((resolve, reject) => {
142
+ this.pendingConnect = { resolve, reject };
143
+ this.openSocket();
144
+ });
145
+ }
146
+
147
+ /** Closes the socket, stops reconnecting and clears the guest auth cookie this client applied (see
148
+ * `applyAuthCookie()`) - idempotent. */
149
+ close(): void {
150
+ if (this.closed) {
151
+ return;
152
+ }
153
+ this.closed = true;
154
+ clearTimeout(this.timer);
155
+ this.timer = undefined;
156
+ const socket = this.socket;
157
+ this.socket = undefined;
158
+ if (socket) {
159
+ socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null;
160
+ try {
161
+ socket.close(1000, "closing");
162
+ } catch {
163
+ // Already gone.
164
+ }
165
+ }
166
+ this.clearAuthCookie();
167
+ this.listeners.clear();
168
+ this.pendingConnect?.reject(new Error("Closed before the signaling channel connected."));
169
+ this.pendingConnect = undefined;
170
+ }
171
+
172
+ onMessage(handler: (message: SignalMessage) => void): () => void {
173
+ this.listeners.add(handler);
174
+ return () => this.listeners.delete(handler);
175
+ }
176
+
177
+ /** Publishes over `POST /push/:id`, with `token` as a bearer credential when present - see this module's doc
178
+ * comment on why this, unlike subscribing, needs no cookie workaround at all, and on the omitted-`token` case
179
+ * (an already-authenticated real caller), which relies on the browser's own real `jwt` cookie instead - and,
180
+ * because that's a *cookie*-authenticated request, also needs the matching CSRF header
181
+ * (`@rapidmx/web-client`'s `withCsrfHeader()`, the same double-submit echo `apiFetch()` applies to every other
182
+ * mutating call this server answers): without it, `RouteUtils.checkCsrf()` refuses the request with 403
183
+ * `"This request is missing a valid CSRF token."` before it ever reaches `BasePushRoute.send()`'s own ACL
184
+ * check - every `hello`/offer/answer/ICE candidate/`bye` a participant on that path sends 403s and is
185
+ * silently dropped (`send()` is fire-and-forget), which looks exactly like "stuck connecting, then no audio or
186
+ * video" from everyone else's side. `withCsrfHeader()` is harmless to also add on the bearer-token (guest)
187
+ * branch - the server only enforces the check for `req.auth.source === "cookie"` - so it's applied
188
+ * unconditionally here rather than only for the cookie case.
189
+ * Fire-and-forget: a failure is swallowed (there is nothing a signaling message's own sender can usefully do
190
+ * about a dropped publish beyond what the mesh's own `hello` re-announcement/renegotiation already tolerates -
191
+ * see `MeshConnectionManager`'s doc comment on fire-and-forget delivery). */
192
+ send(message: SignalMessage): void {
193
+ const fetchImpl = this.options.fetchImpl ?? (typeof fetch === "undefined" ? undefined : fetch);
194
+ if (!fetchImpl) {
195
+ return;
196
+ }
197
+ const origin = apiOrigin() || (typeof window !== "undefined" ? window.location.origin : "");
198
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
199
+ if (this.options.token) {
200
+ headers.Authorization = `Bearer ${this.options.token}`;
201
+ }
202
+ void fetchImpl(`${origin}/push/${encodeURIComponent(this.options.channel)}`, {
203
+ method: "POST",
204
+ headers: withCsrfHeader(headers, "POST"),
205
+ body: JSON.stringify(message),
206
+ // Only a `bye` needs to survive the page unloading - every other message is sent while the page (and
207
+ // the rest of this call's signaling) is very much alive. `keepalive` is a real-world liability to set
208
+ // unconditionally: some restrictive networks' inspecting proxies single out a `keepalive` fetch (it
209
+ // tells the browser the request may outlive the page - the same signal `navigator.sendBeacon()` and an
210
+ // `unload` handler's own fetch give off) for extra scrutiny or drop it outright, well short of Chrome's
211
+ // documented 64 KiB-per-request/several-MB-total keepalive quota. A participant behind such a network
212
+ // was seen stuck signaling forever - every `hello`/offer/answer/ICE candidate sent with `keepalive` and
213
+ // silently going nowhere (`send()` is fire-and-forget) - while an identical join from an unrestricted
214
+ // network connected normally.
215
+ keepalive: message.kind === "bye",
216
+ }).catch(() => {
217
+ // Best-effort - see this method's doc comment.
218
+ });
219
+ }
220
+
221
+ private openSocket(): void {
222
+ const url = (this.options.url ?? pushUrl)();
223
+ const createSocket = this.options.createSocket ?? defaultSocketFactory();
224
+ if (!url || !createSocket) {
225
+ this.failConnect(new Error("This browser (or this page's connection) doesn't support the signaling channel."));
226
+ return;
227
+ }
228
+ this.applyAuthCookie();
229
+ let socket: PushSocket;
230
+ try {
231
+ socket = createSocket(url);
232
+ } catch {
233
+ this.scheduleReconnect();
234
+ return;
235
+ }
236
+ this.socket = socket;
237
+ socket.onmessage = (event) => this.handleFrame(event.data);
238
+ socket.onclose = () => {
239
+ // Guards against a stale socket's belated close event: `close()` always sets `this.socket = undefined`
240
+ // synchronously (before this handler could possibly be detached asynchronously), so once this client
241
+ // is closed, `this.socket` can never equal `socket` again and this always returns here first.
242
+ if (this.socket !== socket) {
243
+ return;
244
+ }
245
+ this.socket = undefined;
246
+ this.scheduleReconnect();
247
+ };
248
+ socket.onerror = () => undefined;
249
+ }
250
+
251
+ private scheduleReconnect(): void {
252
+ const ceiling = Math.min(SIGNALING_BACKOFF_MAX_MS, SIGNALING_BACKOFF_BASE_MS * 2 ** Math.min(this.attempt, 10));
253
+ const delay = ceiling / 2 + ((this.options.random ?? Math.random)() * ceiling) / 2;
254
+ this.attempt += 1;
255
+ this.timer = setTimeout(() => {
256
+ this.timer = undefined;
257
+ this.openSocket();
258
+ }, delay);
259
+ }
260
+
261
+ private handleFrame(raw: unknown): void {
262
+ if (typeof raw !== "string") {
263
+ return;
264
+ }
265
+ let frame: Record<string, unknown>;
266
+ try {
267
+ const parsed: unknown = JSON.parse(raw);
268
+ if (!parsed || typeof parsed !== "object") {
269
+ return;
270
+ }
271
+ frame = parsed as Record<string, unknown>;
272
+ } catch {
273
+ return;
274
+ }
275
+ if (frame.type === "SUBSCRIBED") {
276
+ this.handleSubscribed(frame);
277
+ return;
278
+ }
279
+ if (frame.type === "MESSAGE" && frame.channel === this.options.channel) {
280
+ const data = frame.data;
281
+ if (data && typeof data === "object" && (data as { type?: unknown }).type === "video-meeting-signal") {
282
+ this.emit(data as SignalMessage);
283
+ }
284
+ }
285
+ }
286
+
287
+ private handleSubscribed(frame: Record<string, unknown>): void {
288
+ const channels = Array.isArray(frame.data) ? frame.data.filter((c): c is string => typeof c === "string") : [];
289
+ if (frame.id === 0) {
290
+ // The connect-time greeting: now actually subscribe to the one channel this client cares about.
291
+ const id = this.nextRequestId++;
292
+ this.send0({ id, type: "SUBSCRIBE", data: [this.options.channel] });
293
+ return;
294
+ }
295
+ if (channels.includes(this.options.channel)) {
296
+ this.attempt = 0;
297
+ this.pendingConnect?.resolve();
298
+ this.pendingConnect = undefined;
299
+ } else if (this.pendingConnect) {
300
+ this.failConnect(new Error("This meeting's signaling channel refused the subscription."));
301
+ }
302
+ }
303
+
304
+ private send0(message: object): void {
305
+ const socket = this.socket;
306
+ if (!socket || socket.readyState !== SOCKET_OPEN) {
307
+ return;
308
+ }
309
+ try {
310
+ socket.send(JSON.stringify(message));
311
+ } catch {
312
+ // The close that follows a dead socket reconnects and re-subscribes.
313
+ }
314
+ }
315
+
316
+ private failConnect(err: Error): void {
317
+ this.pendingConnect?.reject(err);
318
+ this.pendingConnect = undefined;
319
+ }
320
+
321
+ private emit(message: SignalMessage): void {
322
+ for (const listener of [...this.listeners]) {
323
+ listener(message);
324
+ }
325
+ }
326
+
327
+ /** Sets the `jwt` cookie to `token` for this origin, so the browser attaches it (as `PushClient`'s own doc
328
+ * comment describes for a real session) to the WebSocket upgrade this client is about to open - see this
329
+ * module's doc comment for the full reasoning. A no-op when `token` is omitted (the caller already
330
+ * authenticated as a real RapidMX identity - see the FIXED LIMITATION section above): there is then no guest
331
+ * token to apply, and writing nothing leaves the browser's own real `jwt` session cookie, whatever it is,
332
+ * untouched and free to authenticate the connection normally. */
333
+ private applyAuthCookie(): void {
334
+ if (!this.options.token) {
335
+ return;
336
+ }
337
+ const doc = this.options.documentRef ?? (typeof document === "undefined" ? undefined : document);
338
+ if (!doc) {
339
+ return;
340
+ }
341
+ this.cookieApplied = true;
342
+ const secure = (doc.location ?? (typeof location === "undefined" ? undefined : location))?.protocol === "https:" ? "; Secure" : "";
343
+ doc.cookie = `${AUTH_COOKIE_NAME}=${encodeURIComponent(this.options.token)}; path=/; SameSite=Lax${secure}`;
344
+ }
345
+
346
+ /** Expires the cookie `applyAuthCookie()` set - a no-op if that write was itself silently blocked, or never
347
+ * attempted at all because `token` was omitted (see `applyAuthCookie()`'s own doc comment), either way leaving
348
+ * whatever real session cookie was already there untouched. `cookieApplied` is only ever set once
349
+ * `applyAuthCookie()` has already confirmed both a `token` to write and a `doc` to write it to, and neither
350
+ * `this.options.token`, `this.options.documentRef` nor the real global `document` can change mid-call, so
351
+ * resolving it here is guaranteed to succeed too - no second `!doc` guard needed. */
352
+ private clearAuthCookie(): void {
353
+ if (!this.cookieApplied) {
354
+ return;
355
+ }
356
+ const doc = this.options.documentRef ?? (typeof document === "undefined" ? undefined : document);
357
+ doc!.cookie = `${AUTH_COOKIE_NAME}=; path=/; Max-Age=0`;
358
+ }
359
+ }