@rapidmx/meet-plugin 0.14.1 → 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 (98) hide show
  1. package/README.md +35 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +508 -463
  4. package/apps/meet/_CallView.tsx +1060 -891
  5. package/apps/meet/_ChatPanel.tsx +194 -0
  6. package/apps/meet/_ColorWheel.tsx +136 -0
  7. package/apps/meet/_DiagnosticsPanel.tsx +65 -7
  8. package/apps/meet/_MeetLobby.tsx +224 -224
  9. package/apps/meet/_ParticipantTile.tsx +243 -169
  10. package/apps/meet/_ParticipantsDrawer.tsx +10 -7
  11. package/apps/meet/_Whiteboard.tsx +577 -0
  12. package/apps/meet/_icons.tsx +135 -80
  13. package/apps/meet/_meetApi.ts +213 -212
  14. package/apps/shared/collab/CollabSession.ts +675 -0
  15. package/apps/shared/collab/WhiteboardModel.ts +216 -0
  16. package/apps/shared/collab/chatMarkdown.tsx +375 -0
  17. package/apps/shared/collab/color.ts +67 -0
  18. package/apps/shared/collab/protocol.ts +321 -0
  19. package/apps/shared/collab/whiteboardRender.ts +311 -0
  20. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  21. package/apps/shared/media/filters/mlModels.ts +174 -174
  22. package/apps/shared/relay/AudioPlayer.ts +29 -0
  23. package/apps/shared/relay/AudioSender.ts +23 -0
  24. package/apps/shared/relay/RelayClient.ts +54 -2
  25. package/apps/shared/relay/RelayReceiver.ts +43 -2
  26. package/apps/shared/relay/RelaySender.ts +23 -1
  27. package/apps/shared/relay/RelayTransport.ts +21 -1
  28. package/apps/shared/relay/SilenceMeter.ts +59 -0
  29. package/apps/shared/relay/relayEnv.ts +1 -1
  30. package/apps/shared/signaling/SignalingClient.ts +335 -0
  31. package/apps/shared/webrtc/MeshConnectionManager.ts +971 -949
  32. package/apps/shared/webrtc/types.ts +335 -247
  33. package/dist/apps/meet/[token].js +1 -1
  34. package/dist/apps/meet/_CallControls.d.ts +21 -6
  35. package/dist/apps/meet/_CallControls.js +14 -8
  36. package/dist/apps/meet/_CallView.d.ts +36 -7
  37. package/dist/apps/meet/_CallView.js +132 -16
  38. package/dist/apps/meet/_ChatPanel.d.ts +24 -0
  39. package/dist/apps/meet/_ChatPanel.js +116 -0
  40. package/dist/apps/meet/_ColorWheel.d.ts +14 -0
  41. package/dist/apps/meet/_ColorWheel.js +82 -0
  42. package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
  43. package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
  44. package/dist/apps/meet/_ParticipantTile.d.ts +11 -3
  45. package/dist/apps/meet/_ParticipantTile.js +36 -5
  46. package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
  47. package/dist/apps/meet/_Whiteboard.d.ts +64 -0
  48. package/dist/apps/meet/_Whiteboard.js +312 -0
  49. package/dist/apps/meet/_icons.d.ts +16 -0
  50. package/dist/apps/meet/_icons.js +16 -0
  51. package/dist/apps/meet/_meetApi.d.ts +8 -7
  52. package/dist/apps/meet/_meetApi.js +2 -2
  53. package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
  54. package/dist/apps/shared/collab/CollabSession.js +559 -0
  55. package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
  56. package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
  57. package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
  58. package/dist/apps/shared/collab/chatMarkdown.js +311 -0
  59. package/dist/apps/shared/collab/color.d.ts +18 -0
  60. package/dist/apps/shared/collab/color.js +59 -0
  61. package/dist/apps/shared/collab/protocol.d.ts +157 -0
  62. package/dist/apps/shared/collab/protocol.js +222 -0
  63. package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
  64. package/dist/apps/shared/collab/whiteboardRender.js +268 -0
  65. package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
  66. package/dist/apps/shared/relay/AudioPlayer.js +24 -4
  67. package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
  68. package/dist/apps/shared/relay/AudioSender.js +20 -4
  69. package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
  70. package/dist/apps/shared/relay/RelayClient.js +46 -2
  71. package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
  72. package/dist/apps/shared/relay/RelayReceiver.js +38 -6
  73. package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
  74. package/dist/apps/shared/relay/RelaySender.js +20 -5
  75. package/dist/apps/shared/relay/RelayTransport.d.ts +6 -1
  76. package/dist/apps/shared/relay/RelayTransport.js +16 -1
  77. package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
  78. package/dist/apps/shared/relay/SilenceMeter.js +55 -0
  79. package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
  80. package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
  81. package/dist/apps/shared/signaling/SignalingClient.js +278 -0
  82. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
  83. package/dist/apps/shared/webrtc/MeshConnectionManager.js +28 -9
  84. package/dist/apps/shared/webrtc/types.d.ts +104 -26
  85. package/dist/apps/shared/webrtc/types.js +3 -4
  86. package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
  87. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  88. package/dist/lib/util/RelayHub.js +15 -2
  89. package/dist/lib/util/RelayHub.js.map +1 -1
  90. package/dist/lib/util/SignalingHub.js +339 -0
  91. package/dist/lib/util/SignalingHub.js.map +1 -0
  92. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
  93. package/dist/types/util/RelayHub.d.ts +5 -2
  94. package/dist/types/util/SignalingHub.d.ts +112 -0
  95. package/package.json +208 -208
  96. package/apps/shared/push/GuestSignalingClient.ts +0 -359
  97. package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
  98. package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
@@ -2,11 +2,38 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
+ import type { RelayReceiveDiagnostics, RelayReceiveStats } from "../webrtc/types.js";
5
6
  import { AudioPlayer } from "./AudioPlayer.js";
6
- import { decodeFragment, FrameReassembler, KIND_AUDIO } from "./frames.js";
7
+ import { decodeFragment, type Frame, FrameReassembler, KIND_AUDIO, seqIsNewer } from "./frames.js";
7
8
  import type { AudioContextLike, RelayEnv } from "./relayEnv.js";
8
9
  import { VideoPlayer } from "./VideoPlayer.js";
9
10
 
11
+ const SEQ_MODULUS = 0x10000;
12
+ /** A jump in sequence numbers bigger than this is taken for the sender restarting its counter, not for that many
13
+ * lost frames. Ten seconds of 20 ms audio packets - far more than any real outage the stream would survive. */
14
+ const MAX_COUNTED_SEQ_GAP = 500;
15
+
16
+ /** Counts one kind's frames as they complete, and the ones the sequence numbers say never did. */
17
+ class ReceiveCounter {
18
+ readonly stats: RelayReceiveStats = { framesReceived: 0, framesLost: 0, bytesReceived: 0 };
19
+ private lastSeq: number | undefined;
20
+
21
+ fragment(bytes: number): void {
22
+ this.stats.bytesReceived += bytes;
23
+ }
24
+
25
+ frame(frame: Frame): void {
26
+ this.stats.framesReceived += 1;
27
+ if (this.lastSeq !== undefined && seqIsNewer(frame.seq, this.lastSeq)) {
28
+ const missing = ((frame.seq - this.lastSeq + SEQ_MODULUS) % SEQ_MODULUS) - 1;
29
+ if (missing <= MAX_COUNTED_SEQ_GAP) {
30
+ this.stats.framesLost += missing;
31
+ }
32
+ }
33
+ this.lastSeq = frame.seq;
34
+ }
35
+ }
36
+
10
37
  /** The canvas is sized to the first decoded picture; until then it is this size. */
11
38
  const INITIAL_CANVAS_WIDTH = 320;
12
39
  const INITIAL_CANVAS_HEIGHT = 240;
@@ -25,6 +52,8 @@ export class RelayReceiver {
25
52
  readonly stream: MediaStream;
26
53
  private readonly audioFrames = new FrameReassembler();
27
54
  private readonly videoFrames = new FrameReassembler();
55
+ private readonly audioCounter = new ReceiveCounter();
56
+ private readonly videoCounter = new ReceiveCounter();
28
57
  private readonly audio: AudioPlayer;
29
58
  private readonly video: VideoPlayer | undefined;
30
59
 
@@ -50,10 +79,14 @@ export class RelayReceiver {
50
79
  if (!fragment) {
51
80
  return;
52
81
  }
53
- const frame = (fragment.kind === KIND_AUDIO ? this.audioFrames : this.videoFrames).push(fragment);
82
+ const isAudio = fragment.kind === KIND_AUDIO;
83
+ const counter = isAudio ? this.audioCounter : this.videoCounter;
84
+ counter.fragment(payload.length);
85
+ const frame = (isAudio ? this.audioFrames : this.videoFrames).push(fragment);
54
86
  if (!frame) {
55
87
  return;
56
88
  }
89
+ counter.frame(frame);
57
90
  if (frame.kind === KIND_AUDIO) {
58
91
  this.audio.push(frame);
59
92
  } else {
@@ -61,6 +94,14 @@ export class RelayReceiver {
61
94
  }
62
95
  }
63
96
 
97
+ /** A snapshot of what has been received from this peer - see `RelayReceiveDiagnostics`. */
98
+ diagnostics(): RelayReceiveDiagnostics {
99
+ return {
100
+ audio: { ...this.audioCounter.stats, ...this.audio.stats() },
101
+ video: { ...this.videoCounter.stats },
102
+ };
103
+ }
104
+
64
105
  /** Releases the decoders and ends the stream's tracks. */
65
106
  close(): void {
66
107
  this.audio.close();
@@ -2,6 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
+ import type { RelaySendDiagnostics, RelaySendStats } from "../webrtc/types.js";
5
6
  import { AudioSender } from "./AudioSender.js";
6
7
  import { DEFAULT_MESSAGE_BYTES, fragmentFrame, type FrameKind, KIND_AUDIO, KIND_VIDEO } from "./frames.js";
7
8
  import type { RelayEnv } from "./relayEnv.js";
@@ -15,6 +16,11 @@ const SEQ_MODULUS = 0x10000;
15
16
  * otherwise treat the restarted stream as older than what it has already seen. */
16
17
  export class FramePublisher {
17
18
  private readonly seqs: Record<FrameKind, number> = { [KIND_AUDIO]: 0, [KIND_VIDEO]: 0 };
19
+ /** What happened to each kind's frames - for the diagnostics panel. */
20
+ readonly stats: Record<FrameKind, RelaySendStats> = {
21
+ [KIND_AUDIO]: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
22
+ [KIND_VIDEO]: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
23
+ };
18
24
 
19
25
  /** `send` returns whether one message was accepted by the socket. */
20
26
  /** `maxMessageBytes` is the largest message the socket may send right now (it changes when the server's `ready`
@@ -29,15 +35,20 @@ export class FramePublisher {
29
35
  publish: PublishFrame = (kind, keyFrame, data, timestampMs) => {
30
36
  const seq = this.seqs[kind];
31
37
  this.seqs[kind] = (seq + 1) % SEQ_MODULUS;
38
+ const stats = this.stats[kind];
32
39
  const fragments = fragmentFrame(kind, keyFrame, seq, timestampMs, data, this.maxMessageBytes());
33
40
  if (fragments.length === 0) {
41
+ stats.framesDropped += 1;
34
42
  return false;
35
43
  }
36
44
  for (const fragment of fragments) {
37
45
  if (!this.send(fragment)) {
46
+ stats.framesDropped += 1;
38
47
  return false;
39
48
  }
49
+ stats.bytesSent += fragment.length;
40
50
  }
51
+ stats.framesSent += 1;
41
52
  return true;
42
53
  };
43
54
  }
@@ -52,15 +63,26 @@ export interface RelaySenderOptions {
52
63
 
53
64
  /** The publishing half of the relay: the two capture paths plus the frame numbering they share. */
54
65
  export class RelaySender {
66
+ private readonly publisher: FramePublisher;
55
67
  private readonly audio: AudioSender;
56
68
  private readonly video: VideoSender;
57
69
 
58
70
  constructor(options: RelaySenderOptions) {
59
- const deps = { env: options.env, publish: new FramePublisher(options.send, options.maxMessageBytes).publish, canSend: options.canSend };
71
+ this.publisher = new FramePublisher(options.send, options.maxMessageBytes);
72
+ const deps = { env: options.env, publish: this.publisher.publish, canSend: options.canSend };
60
73
  this.audio = new AudioSender(deps);
61
74
  this.video = new VideoSender(deps);
62
75
  }
63
76
 
77
+ /** A snapshot of the sending counters - see `RelaySendDiagnostics`. */
78
+ diagnostics(): RelaySendDiagnostics {
79
+ return {
80
+ ...this.audio.captureStats(),
81
+ audio: { ...this.publisher.stats[KIND_AUDIO] },
82
+ video: { ...this.publisher.stats[KIND_VIDEO] },
83
+ };
84
+ }
85
+
64
86
  /** Starts or stops encoding whatever tracks are set. Stopping releases the encoders and audio nodes. */
65
87
  setActive(active: boolean): void {
66
88
  this.audio.setActive(active);
@@ -17,6 +17,7 @@
17
17
  * negotiation, because both ends are this same code.
18
18
  */
19
19
  import { apiOrigin } from "@rapidmx/web-client/lib/util/api.js";
20
+ import type { RelayDiagnostics } from "../webrtc/types.js";
20
21
  import { PLAYBACK_SAMPLE_RATE } from "./AudioPlayer.js";
21
22
  import { resumeAudioContext } from "./audioResume.js";
22
23
  import { RelayClient } from "./RelayClient.js";
@@ -39,6 +40,9 @@ export interface RelayTransport {
39
40
  /** Same semantics as `MeshConnectionManager.setLocalTrack`: null = send nothing for that kind. Safe to call
40
41
  * before or after `setSending`. */
41
42
  setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void;
43
+ /** The sending counters (shared by every peer) plus what has been received from `peerId`, for the diagnostics
44
+ * panel - cumulative, so a caller wanting a rate keeps the previous sample. */
45
+ diagnostics(peerId: string): RelayDiagnostics;
42
46
  /** Closes the socket, all coders, streams and timers. Idempotent. */
43
47
  close(): void;
44
48
  }
@@ -54,7 +58,8 @@ export interface RelayTransportOptions {
54
58
  }
55
59
 
56
60
  /** The relay WebSocket's URL for a meeting: this deployment's origin (the API origin when the UI is served from a
57
- * different one, else the page's own), `http` becoming `ws` - the same derivation `pushUrl()` uses for `/push`.
61
+ * different one, else the page's own), `http` becoming `ws` - the same derivation `signalingUrl()` uses for the
62
+ * signaling socket.
58
63
  * `undefined` when there is no origin at all (not running in a browser). */
59
64
  export function relayUrl(meetingUid: string): string | undefined {
60
65
  const origin = apiOrigin() || (typeof window !== "undefined" ? window.location.origin : "");
@@ -68,6 +73,16 @@ const UNSUPPORTED_TRANSPORT: RelayTransport = {
68
73
  stopReceivingFrom: () => undefined,
69
74
  setSending: () => undefined,
70
75
  setLocalTrack: () => undefined,
76
+ diagnostics: () => ({
77
+ send: {
78
+ audioCapturedMs: 0,
79
+ audioCaptureWallMs: 0,
80
+ audioSilentMs: 0,
81
+ audioEncoderSkippedMs: 0,
82
+ audio: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
83
+ video: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
84
+ },
85
+ }),
71
86
  close: () => undefined,
72
87
  };
73
88
 
@@ -90,6 +105,7 @@ class WebSocketRelayTransport implements RelayTransport {
90
105
  peerId,
91
106
  createSocket: (target) => env.createSocket(target),
92
107
  random: () => env.random(),
108
+ now: () => env.now(),
93
109
  onMedia: (sender, payload) => this.receivers.get(sender)?.handleMedia(payload),
94
110
  });
95
111
  this.sender = new RelaySender({
@@ -140,6 +156,10 @@ class WebSocketRelayTransport implements RelayTransport {
140
156
  }
141
157
  }
142
158
 
159
+ diagnostics(peerId: string): RelayDiagnostics {
160
+ return { roundTripMs: this.client.roundTripMs, send: this.sender.diagnostics(), receive: this.receivers.get(peerId)?.diagnostics() };
161
+ }
162
+
143
163
  close(): void {
144
164
  if (this.closed) {
145
165
  return;
@@ -0,0 +1,59 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /** A sample this close to zero counts as silent: about -80 dBFS, well under the noise floor of any real microphone
7
+ * (even through noise suppression), so only digital silence - a zero-filled block - falls below it. */
8
+ export const SILENCE_THRESHOLD = 1e-4;
9
+ /** A run of silent samples counts only once it lasts this long, so a waveform merely crossing zero never does. */
10
+ export const MIN_SILENT_RUN_SECONDS = 0.005;
11
+
12
+ /**
13
+ * Measures how much of a PCM stream is digital silence, for the relay's diagnostics. A live microphone always carries
14
+ * some noise, so stretches of exact (or near-exact) zero mean the audio was lost or zero-filled somewhere on its way,
15
+ * not that the room went quiet. Runs are tracked across `feed()` calls, so a gap that straddles two blocks counts in
16
+ * full.
17
+ */
18
+ export class SilenceMeter {
19
+ private totalSeconds = 0;
20
+ private silentSeconds = 0;
21
+ private run = 0;
22
+ private runRate = 0;
23
+
24
+ /** Adds one block of `sampleRate` Hz samples. */
25
+ feed(samples: Float32Array, sampleRate: number): void {
26
+ if (sampleRate !== this.runRate) {
27
+ this.endRun();
28
+ this.runRate = sampleRate;
29
+ }
30
+ for (const sample of samples) {
31
+ if (Math.abs(sample) < SILENCE_THRESHOLD) {
32
+ this.run += 1;
33
+ } else if (this.run > 0) {
34
+ this.endRun();
35
+ }
36
+ }
37
+ this.totalSeconds += samples.length / sampleRate;
38
+ }
39
+
40
+ /** Milliseconds of audio fed so far. */
41
+ get totalMs(): number {
42
+ return this.totalSeconds * 1000;
43
+ }
44
+
45
+ /** Milliseconds of it that were silent - including a silent run still in progress once it is long enough. */
46
+ get silentMs(): number {
47
+ return (this.silentSeconds + this.runSeconds(this.run)) * 1000;
48
+ }
49
+
50
+ private runSeconds(run: number): number {
51
+ const seconds = this.runRate > 0 ? run / this.runRate : 0;
52
+ return seconds >= MIN_SILENT_RUN_SECONDS ? seconds : 0;
53
+ }
54
+
55
+ private endRun(): void {
56
+ this.silentSeconds += this.runSeconds(this.run);
57
+ this.run = 0;
58
+ }
59
+ }
@@ -134,7 +134,7 @@ export interface VideoElementLike {
134
134
  pause(): void;
135
135
  }
136
136
 
137
- /** The subset of `WebSocket` the relay client uses (the same seam shape `GuestSignalingClient`'s `PushSocket` has). */
137
+ /** The subset of `WebSocket` the relay client uses (the same seam shape `SignalingClient`'s `SignalingSocket` has). */
138
138
  export interface RelaySocket {
139
139
  readyState: number;
140
140
  /** Bytes queued by `send()` that the browser has not yet handed to the network - the backpressure signal. */
@@ -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
+ }