@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
@@ -1,3 +1,5 @@
1
+ import type { RelaySendDiagnostics, RelaySendStats } from "../webrtc/types.js";
2
+ import { type FrameKind } from "./frames.js";
1
3
  import type { RelayEnv } from "./relayEnv.js";
2
4
  import type { PublishFrame } from "./senderCommon.js";
3
5
  /** Numbers and fragments the frames of one sender. The counters belong to the transport rather than to a capture
@@ -7,6 +9,8 @@ export declare class FramePublisher {
7
9
  private readonly send;
8
10
  private readonly maxMessageBytes;
9
11
  private readonly seqs;
12
+ /** What happened to each kind's frames - for the diagnostics panel. */
13
+ readonly stats: Record<FrameKind, RelaySendStats>;
10
14
  /** `send` returns whether one message was accepted by the socket. */
11
15
  /** `maxMessageBytes` is the largest message the socket may send right now (it changes when the server's `ready`
12
16
  * announces a limit), so it is read for every frame rather than fixed at construction. */
@@ -24,9 +28,12 @@ export interface RelaySenderOptions {
24
28
  }
25
29
  /** The publishing half of the relay: the two capture paths plus the frame numbering they share. */
26
30
  export declare class RelaySender {
31
+ private readonly publisher;
27
32
  private readonly audio;
28
33
  private readonly video;
29
34
  constructor(options: RelaySenderOptions);
35
+ /** A snapshot of the sending counters - see `RelaySendDiagnostics`. */
36
+ diagnostics(): RelaySendDiagnostics;
30
37
  /** Starts or stops encoding whatever tracks are set. Stopping releases the encoders and audio nodes. */
31
38
  setActive(active: boolean): void;
32
39
  /** `null` sends nothing for that kind. Safe before or after `setActive`. */
@@ -1,7 +1,3 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
1
  import { AudioSender } from "./AudioSender.js";
6
2
  import { DEFAULT_MESSAGE_BYTES, fragmentFrame, KIND_AUDIO, KIND_VIDEO } from "./frames.js";
7
3
  import { VideoSender } from "./VideoSender.js";
@@ -17,20 +13,30 @@ export class FramePublisher {
17
13
  this.send = send;
18
14
  this.maxMessageBytes = maxMessageBytes;
19
15
  this.seqs = { [KIND_AUDIO]: 0, [KIND_VIDEO]: 0 };
16
+ /** What happened to each kind's frames - for the diagnostics panel. */
17
+ this.stats = {
18
+ [KIND_AUDIO]: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
19
+ [KIND_VIDEO]: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
20
+ };
20
21
  /** Fragments and sends one frame, stopping at the first fragment the socket refuses (the rest could not be
21
22
  * used without it). Returns whether the whole frame went out. */
22
23
  this.publish = (kind, keyFrame, data, timestampMs) => {
23
24
  const seq = this.seqs[kind];
24
25
  this.seqs[kind] = (seq + 1) % SEQ_MODULUS;
26
+ const stats = this.stats[kind];
25
27
  const fragments = fragmentFrame(kind, keyFrame, seq, timestampMs, data, this.maxMessageBytes());
26
28
  if (fragments.length === 0) {
29
+ stats.framesDropped += 1;
27
30
  return false;
28
31
  }
29
32
  for (const fragment of fragments) {
30
33
  if (!this.send(fragment)) {
34
+ stats.framesDropped += 1;
31
35
  return false;
32
36
  }
37
+ stats.bytesSent += fragment.length;
33
38
  }
39
+ stats.framesSent += 1;
34
40
  return true;
35
41
  };
36
42
  }
@@ -38,10 +44,19 @@ export class FramePublisher {
38
44
  /** The publishing half of the relay: the two capture paths plus the frame numbering they share. */
39
45
  export class RelaySender {
40
46
  constructor(options) {
41
- const deps = { env: options.env, publish: new FramePublisher(options.send, options.maxMessageBytes).publish, canSend: options.canSend };
47
+ this.publisher = new FramePublisher(options.send, options.maxMessageBytes);
48
+ const deps = { env: options.env, publish: this.publisher.publish, canSend: options.canSend };
42
49
  this.audio = new AudioSender(deps);
43
50
  this.video = new VideoSender(deps);
44
51
  }
52
+ /** A snapshot of the sending counters - see `RelaySendDiagnostics`. */
53
+ diagnostics() {
54
+ return {
55
+ ...this.audio.captureStats(),
56
+ audio: { ...this.publisher.stats[KIND_AUDIO] },
57
+ video: { ...this.publisher.stats[KIND_VIDEO] },
58
+ };
59
+ }
45
60
  /** Starts or stops encoding whatever tracks are set. Stopping releases the encoders and audio nodes. */
46
61
  setActive(active) {
47
62
  this.audio.setActive(active);
@@ -1,3 +1,4 @@
1
+ import type { RelayDiagnostics } from "../webrtc/types.js";
1
2
  import { type RelayEnv } from "./relayEnv.js";
2
3
  export interface RelayTransport {
3
4
  /** false when this browser lacks WebSocket, AudioEncoder/AudioDecoder/VideoEncoder/VideoDecoder, VideoFrame,
@@ -14,6 +15,9 @@ export interface RelayTransport {
14
15
  /** Same semantics as `MeshConnectionManager.setLocalTrack`: null = send nothing for that kind. Safe to call
15
16
  * before or after `setSending`. */
16
17
  setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void;
18
+ /** The sending counters (shared by every peer) plus what has been received from `peerId`, for the diagnostics
19
+ * panel - cumulative, so a caller wanting a rate keeps the previous sample. */
20
+ diagnostics(peerId: string): RelayDiagnostics;
17
21
  /** Closes the socket, all coders, streams and timers. Idempotent. */
18
22
  close(): void;
19
23
  }
@@ -27,7 +31,8 @@ export interface RelayTransportOptions {
27
31
  env?: RelayEnv;
28
32
  }
29
33
  /** The relay WebSocket's URL for a meeting: this deployment's origin (the API origin when the UI is served from a
30
- * different one, else the page's own), `http` becoming `ws` - the same derivation `pushUrl()` uses for `/push`.
34
+ * different one, else the page's own), `http` becoming `ws` - the same derivation `signalingUrl()` uses for the
35
+ * signaling socket.
31
36
  * `undefined` when there is no origin at all (not running in a browser). */
32
37
  export declare function relayUrl(meetingUid: string): string | undefined;
33
38
  /**
@@ -24,7 +24,8 @@ import { detectRelayEnv } from "./relayEnv.js";
24
24
  import { RelayReceiver } from "./RelayReceiver.js";
25
25
  import { RelaySender } from "./RelaySender.js";
26
26
  /** The relay WebSocket's URL for a meeting: this deployment's origin (the API origin when the UI is served from a
27
- * different one, else the page's own), `http` becoming `ws` - the same derivation `pushUrl()` uses for `/push`.
27
+ * different one, else the page's own), `http` becoming `ws` - the same derivation `signalingUrl()` uses for the
28
+ * signaling socket.
28
29
  * `undefined` when there is no origin at all (not running in a browser). */
29
30
  export function relayUrl(meetingUid) {
30
31
  const origin = apiOrigin() || (typeof window !== "undefined" ? window.location.origin : "");
@@ -37,6 +38,16 @@ const UNSUPPORTED_TRANSPORT = {
37
38
  stopReceivingFrom: () => undefined,
38
39
  setSending: () => undefined,
39
40
  setLocalTrack: () => undefined,
41
+ diagnostics: () => ({
42
+ send: {
43
+ audioCapturedMs: 0,
44
+ audioCaptureWallMs: 0,
45
+ audioSilentMs: 0,
46
+ audioEncoderSkippedMs: 0,
47
+ audio: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
48
+ video: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
49
+ },
50
+ }),
40
51
  close: () => undefined,
41
52
  };
42
53
  class WebSocketRelayTransport {
@@ -50,6 +61,7 @@ class WebSocketRelayTransport {
50
61
  peerId,
51
62
  createSocket: (target) => env.createSocket(target),
52
63
  random: () => env.random(),
64
+ now: () => env.now(),
53
65
  onMedia: (sender, payload) => this.receivers.get(sender)?.handleMedia(payload),
54
66
  });
55
67
  this.sender = new RelaySender({
@@ -95,6 +107,9 @@ class WebSocketRelayTransport {
95
107
  this.sender.setTrack(kind, track);
96
108
  }
97
109
  }
110
+ diagnostics(peerId) {
111
+ return { roundTripMs: this.client.roundTripMs, send: this.sender.diagnostics(), receive: this.receivers.get(peerId)?.diagnostics() };
112
+ }
98
113
  close() {
99
114
  if (this.closed) {
100
115
  return;
@@ -0,0 +1,25 @@
1
+ /** A sample this close to zero counts as silent: about -80 dBFS, well under the noise floor of any real microphone
2
+ * (even through noise suppression), so only digital silence - a zero-filled block - falls below it. */
3
+ export declare const SILENCE_THRESHOLD = 0.0001;
4
+ /** A run of silent samples counts only once it lasts this long, so a waveform merely crossing zero never does. */
5
+ export declare const MIN_SILENT_RUN_SECONDS = 0.005;
6
+ /**
7
+ * Measures how much of a PCM stream is digital silence, for the relay's diagnostics. A live microphone always carries
8
+ * some noise, so stretches of exact (or near-exact) zero mean the audio was lost or zero-filled somewhere on its way,
9
+ * not that the room went quiet. Runs are tracked across `feed()` calls, so a gap that straddles two blocks counts in
10
+ * full.
11
+ */
12
+ export declare class SilenceMeter {
13
+ private totalSeconds;
14
+ private silentSeconds;
15
+ private run;
16
+ private runRate;
17
+ /** Adds one block of `sampleRate` Hz samples. */
18
+ feed(samples: Float32Array, sampleRate: number): void;
19
+ /** Milliseconds of audio fed so far. */
20
+ get totalMs(): number;
21
+ /** Milliseconds of it that were silent - including a silent run still in progress once it is long enough. */
22
+ get silentMs(): number;
23
+ private runSeconds;
24
+ private endRun;
25
+ }
@@ -0,0 +1,55 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** A sample this close to zero counts as silent: about -80 dBFS, well under the noise floor of any real microphone
6
+ * (even through noise suppression), so only digital silence - a zero-filled block - falls below it. */
7
+ export const SILENCE_THRESHOLD = 1e-4;
8
+ /** A run of silent samples counts only once it lasts this long, so a waveform merely crossing zero never does. */
9
+ export const MIN_SILENT_RUN_SECONDS = 0.005;
10
+ /**
11
+ * Measures how much of a PCM stream is digital silence, for the relay's diagnostics. A live microphone always carries
12
+ * some noise, so stretches of exact (or near-exact) zero mean the audio was lost or zero-filled somewhere on its way,
13
+ * not that the room went quiet. Runs are tracked across `feed()` calls, so a gap that straddles two blocks counts in
14
+ * full.
15
+ */
16
+ export class SilenceMeter {
17
+ constructor() {
18
+ this.totalSeconds = 0;
19
+ this.silentSeconds = 0;
20
+ this.run = 0;
21
+ this.runRate = 0;
22
+ }
23
+ /** Adds one block of `sampleRate` Hz samples. */
24
+ feed(samples, sampleRate) {
25
+ if (sampleRate !== this.runRate) {
26
+ this.endRun();
27
+ this.runRate = sampleRate;
28
+ }
29
+ for (const sample of samples) {
30
+ if (Math.abs(sample) < SILENCE_THRESHOLD) {
31
+ this.run += 1;
32
+ }
33
+ else if (this.run > 0) {
34
+ this.endRun();
35
+ }
36
+ }
37
+ this.totalSeconds += samples.length / sampleRate;
38
+ }
39
+ /** Milliseconds of audio fed so far. */
40
+ get totalMs() {
41
+ return this.totalSeconds * 1000;
42
+ }
43
+ /** Milliseconds of it that were silent - including a silent run still in progress once it is long enough. */
44
+ get silentMs() {
45
+ return (this.silentSeconds + this.runSeconds(this.run)) * 1000;
46
+ }
47
+ runSeconds(run) {
48
+ const seconds = this.runRate > 0 ? run / this.runRate : 0;
49
+ return seconds >= MIN_SILENT_RUN_SECONDS ? seconds : 0;
50
+ }
51
+ endRun() {
52
+ this.silentSeconds += this.runSeconds(this.run);
53
+ this.run = 0;
54
+ }
55
+ }
@@ -122,7 +122,7 @@ export interface VideoElementLike {
122
122
  play(): Promise<void> | undefined;
123
123
  pause(): void;
124
124
  }
125
- /** The subset of `WebSocket` the relay client uses (the same seam shape `GuestSignalingClient`'s `PushSocket` has). */
125
+ /** The subset of `WebSocket` the relay client uses (the same seam shape `SignalingClient`'s `SignalingSocket` has). */
126
126
  export interface RelaySocket {
127
127
  readyState: number;
128
128
  /** Bytes queued by `send()` that the browser has not yet handed to the network - the backpressure signal. */
@@ -0,0 +1,95 @@
1
+ import type { SignalMessage, SignalingChannel } from "../webrtc/types.js";
2
+ /** The version of the signaling wire protocol this client speaks - `SignalingHub`'s `SIGNALING_PROTOCOL_VERSION`. */
3
+ export declare const SIGNALING_PROTOCOL_VERSION = 1;
4
+ /** The first reconnect waits about this long, doubling on each further failure up to `SIGNALING_BACKOFF_MAX_MS`. */
5
+ export declare const SIGNALING_BACKOFF_BASE_MS = 1000;
6
+ export declare const SIGNALING_BACKOFF_MAX_MS = 30000;
7
+ /** How often an idle socket is pinged. */
8
+ export declare const SIGNALING_KEEPALIVE_MS = 25000;
9
+ /** The most messages held while the socket is not ready. */
10
+ export declare const SIGNALING_MAX_QUEUED = 256;
11
+ /** What the server allows per message until it says otherwise in `ready` - its smallest possible limit. */
12
+ export declare const SIGNALING_DEFAULT_MAX_MESSAGE_BYTES: number;
13
+ /** The reason the server closes the socket with once the participant is removed from the meeting - `SignalingHub`'s
14
+ * `SIGNALING_EVICTED_REASON` (`apps/` cannot import from the backend's `src/`, so this mirrors it). */
15
+ export declare const SIGNALING_EVICTED_REASON = "Removed from the meeting.";
16
+ /** The subset of the browser's `WebSocket` this client uses, so a test's fake can be a plain object. */
17
+ export interface SignalingSocket {
18
+ readyState: number;
19
+ send(data: string): void;
20
+ close(code?: number, reason?: string): void;
21
+ onopen: ((event: unknown) => void) | null;
22
+ onmessage: ((event: {
23
+ data: unknown;
24
+ }) => void) | null;
25
+ onclose: ((event: {
26
+ code?: number;
27
+ reason?: string;
28
+ }) => void) | null;
29
+ onerror: ((event: unknown) => void) | null;
30
+ }
31
+ export type SignalingSocketFactory = (url: string) => SignalingSocket;
32
+ /** The signaling WebSocket's URL for a meeting - the same origin derivation as the relay's `relayUrl()`. `undefined`
33
+ * when there is no origin at all (not running in a browser). */
34
+ export declare function signalingUrl(meetingUid: string): string | undefined;
35
+ export interface SignalingClientOptions {
36
+ /** The meeting's uid. */
37
+ meetingUid: string;
38
+ /** This tab's peer id (`<uid>~<random>` - see `_CallView.tsx`'s `newPeerId()`), announced in `hello`. */
39
+ peerId: string;
40
+ /** The guest JWT from `VideoMeetingJoinResult.token`; omitted for a caller already signed in - see this module's
41
+ * doc comment on authentication. */
42
+ token?: string;
43
+ /** Called once when the server closes the socket for good after `connect()` resolved, with its reason. */
44
+ onClosed?: (reason: string) => void;
45
+ /** Overrides `signalingUrl(meetingUid)`. */
46
+ url?: string;
47
+ createSocket?: SignalingSocketFactory;
48
+ /** Overrides `document`, for a test - see `applyAuthCookie()`. */
49
+ documentRef?: Pick<Document, "cookie"> & {
50
+ location?: Pick<Location, "protocol">;
51
+ };
52
+ random?: () => number;
53
+ }
54
+ export declare class SignalingClient implements SignalingChannel {
55
+ private readonly options;
56
+ private socket;
57
+ private ready;
58
+ private closed;
59
+ private everReady;
60
+ private attempt;
61
+ private reconnectTimer;
62
+ private keepaliveTimer;
63
+ private cookieApplied;
64
+ private maxBytes;
65
+ private readonly queue;
66
+ private readonly listeners;
67
+ private pendingConnect;
68
+ constructor(options: SignalingClientOptions);
69
+ /** The largest message (the whole `signal` envelope, in bytes) the server accepts - what its `ready` said. */
70
+ get maxMessageBytes(): number;
71
+ /** Opens the socket and resolves once the server is ready; rejects when the server refuses it, or there is no way
72
+ * to open one at all (no `WebSocket`, no origin). Calling it on a closed client rejects at once. */
73
+ connect(): Promise<void>;
74
+ /** Closes the socket, stops reconnecting and clears the guest auth cookie this client applied - idempotent. */
75
+ close(): void;
76
+ onMessage(handler: (message: SignalMessage) => void): () => void;
77
+ /** Sends `message` now, or once the socket is ready again - see this module's doc comment. A no-op once closed. */
78
+ send(message: SignalMessage): void;
79
+ private openSocket;
80
+ private handleFrame;
81
+ private handleReady;
82
+ /** Writes `text` to the open socket; `false` when there is none or it threw. */
83
+ private write;
84
+ private startKeepalive;
85
+ private stopKeepalive;
86
+ private scheduleReconnect;
87
+ /** The server closed the socket for good - see this module's doc comment. */
88
+ private refused;
89
+ private fail;
90
+ /** Sets the `jwt` cookie to the guest `token`, so the browser attaches it to the upgrade this client is about to
91
+ * open - see this module's doc comment. A no-op without a token or a document. */
92
+ private applyAuthCookie;
93
+ /** Expires the cookie `applyAuthCookie()` set; a no-op when it set none. */
94
+ private clearAuthCookie;
95
+ }
@@ -0,0 +1,278 @@
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
+ /** The version of the signaling wire protocol this client speaks - `SignalingHub`'s `SIGNALING_PROTOCOL_VERSION`. */
38
+ export const SIGNALING_PROTOCOL_VERSION = 1;
39
+ /** The first reconnect waits about this long, doubling on each further failure up to `SIGNALING_BACKOFF_MAX_MS`. */
40
+ export const SIGNALING_BACKOFF_BASE_MS = 1000;
41
+ export const SIGNALING_BACKOFF_MAX_MS = 30000;
42
+ /** How often an idle socket is pinged. */
43
+ export const SIGNALING_KEEPALIVE_MS = 25000;
44
+ /** The most messages held while the socket is not ready. */
45
+ export const SIGNALING_MAX_QUEUED = 256;
46
+ /** What the server allows per message until it says otherwise in `ready` - its smallest possible limit. */
47
+ export const SIGNALING_DEFAULT_MAX_MESSAGE_BYTES = 16 * 1024;
48
+ /** The reason the server closes the socket with once the participant is removed from the meeting - `SignalingHub`'s
49
+ * `SIGNALING_EVICTED_REASON` (`apps/` cannot import from the backend's `src/`, so this mirrors it). */
50
+ export const SIGNALING_EVICTED_REASON = "Removed from the meeting.";
51
+ const SOCKET_OPEN = 1;
52
+ /** The cookie name `JWTStrategyOptions.cookieName` defaults to (and this deployment always uses). */
53
+ const AUTH_COOKIE_NAME = "jwt";
54
+ /** The signaling WebSocket's URL for a meeting - the same origin derivation as the relay's `relayUrl()`. `undefined`
55
+ * when there is no origin at all (not running in a browser). */
56
+ export function signalingUrl(meetingUid) {
57
+ const origin = apiOrigin() || (typeof window !== "undefined" ? window.location.origin : "");
58
+ return origin ? `${origin.replace(/^http/i, "ws")}/api/mail/video-meetings/signal/${encodeURIComponent(meetingUid)}` : undefined;
59
+ }
60
+ function defaultSocketFactory() {
61
+ return typeof WebSocket === "undefined" ? undefined : (url) => new WebSocket(url);
62
+ }
63
+ export class SignalingClient {
64
+ constructor(options) {
65
+ this.options = options;
66
+ this.ready = false;
67
+ this.closed = false;
68
+ this.everReady = false;
69
+ this.attempt = 0;
70
+ this.cookieApplied = false;
71
+ this.maxBytes = SIGNALING_DEFAULT_MAX_MESSAGE_BYTES;
72
+ this.queue = [];
73
+ this.listeners = new Set();
74
+ }
75
+ /** The largest message (the whole `signal` envelope, in bytes) the server accepts - what its `ready` said. */
76
+ get maxMessageBytes() {
77
+ return this.maxBytes;
78
+ }
79
+ /** Opens the socket and resolves once the server is ready; rejects when the server refuses it, or there is no way
80
+ * to open one at all (no `WebSocket`, no origin). Calling it on a closed client rejects at once. */
81
+ connect() {
82
+ if (this.closed) {
83
+ return Promise.reject(new Error("This signaling client has already been closed."));
84
+ }
85
+ return new Promise((resolve, reject) => {
86
+ this.pendingConnect = { resolve, reject };
87
+ this.openSocket();
88
+ });
89
+ }
90
+ /** Closes the socket, stops reconnecting and clears the guest auth cookie this client applied - idempotent. */
91
+ close() {
92
+ if (this.closed) {
93
+ return;
94
+ }
95
+ this.closed = true;
96
+ this.ready = false;
97
+ clearTimeout(this.reconnectTimer);
98
+ this.stopKeepalive();
99
+ const socket = this.socket;
100
+ this.socket = undefined;
101
+ if (socket) {
102
+ socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null;
103
+ try {
104
+ socket.close(1000, "closing");
105
+ }
106
+ catch {
107
+ // Already gone.
108
+ }
109
+ }
110
+ this.clearAuthCookie();
111
+ this.listeners.clear();
112
+ this.queue.length = 0;
113
+ this.pendingConnect?.reject(new Error("Closed before the signaling channel connected."));
114
+ this.pendingConnect = undefined;
115
+ }
116
+ onMessage(handler) {
117
+ this.listeners.add(handler);
118
+ return () => this.listeners.delete(handler);
119
+ }
120
+ /** Sends `message` now, or once the socket is ready again - see this module's doc comment. A no-op once closed. */
121
+ send(message) {
122
+ if (this.closed) {
123
+ return;
124
+ }
125
+ const text = JSON.stringify({ op: "signal", data: message });
126
+ // Straight out only when nothing is waiting, so a message never overtakes one queued before it.
127
+ if (this.ready && this.queue.length === 0 && this.write(text)) {
128
+ return;
129
+ }
130
+ if (this.queue.length >= SIGNALING_MAX_QUEUED) {
131
+ this.queue.shift();
132
+ }
133
+ this.queue.push(text);
134
+ }
135
+ openSocket() {
136
+ const url = this.options.url ?? signalingUrl(this.options.meetingUid);
137
+ const createSocket = this.options.createSocket ?? defaultSocketFactory();
138
+ if (!url || !createSocket) {
139
+ this.fail(new Error("This browser (or this page's connection) doesn't support the signaling channel."));
140
+ return;
141
+ }
142
+ this.applyAuthCookie();
143
+ let socket;
144
+ try {
145
+ socket = createSocket(url);
146
+ }
147
+ catch {
148
+ this.scheduleReconnect();
149
+ return;
150
+ }
151
+ this.socket = socket;
152
+ socket.onopen = () => this.write(JSON.stringify({ op: "hello", v: SIGNALING_PROTOCOL_VERSION, peer: this.options.peerId }));
153
+ socket.onmessage = (event) => this.handleFrame(event.data);
154
+ socket.onclose = (event) => {
155
+ if (this.socket !== socket) {
156
+ return;
157
+ }
158
+ this.socket = undefined;
159
+ this.ready = false;
160
+ this.stopKeepalive();
161
+ if (event?.code === 1008) {
162
+ this.refused(event.reason || "The meeting refused the connection.");
163
+ return;
164
+ }
165
+ this.scheduleReconnect();
166
+ };
167
+ // An error is always followed by a close, which decides what happens next.
168
+ socket.onerror = () => undefined;
169
+ }
170
+ handleFrame(raw) {
171
+ if (typeof raw !== "string") {
172
+ return;
173
+ }
174
+ let frame;
175
+ try {
176
+ const parsed = JSON.parse(raw);
177
+ if (!parsed || typeof parsed !== "object") {
178
+ return;
179
+ }
180
+ frame = parsed;
181
+ }
182
+ catch {
183
+ return;
184
+ }
185
+ if (frame.op === "ready") {
186
+ this.handleReady(frame.maxMessageBytes);
187
+ }
188
+ else if (frame.op === "signal" && frame.data && typeof frame.data === "object") {
189
+ for (const listener of [...this.listeners]) {
190
+ listener(frame.data);
191
+ }
192
+ }
193
+ }
194
+ handleReady(maxMessageBytes) {
195
+ if (typeof maxMessageBytes === "number" && Number.isFinite(maxMessageBytes) && maxMessageBytes > 0) {
196
+ this.maxBytes = maxMessageBytes;
197
+ }
198
+ this.ready = true;
199
+ this.everReady = true;
200
+ this.attempt = 0;
201
+ this.startKeepalive();
202
+ while (this.queue.length > 0 && this.write(this.queue[0])) {
203
+ this.queue.shift();
204
+ }
205
+ this.pendingConnect?.resolve();
206
+ this.pendingConnect = undefined;
207
+ }
208
+ /** Writes `text` to the open socket; `false` when there is none or it threw. */
209
+ write(text) {
210
+ const socket = this.socket;
211
+ if (!socket || socket.readyState !== SOCKET_OPEN) {
212
+ return false;
213
+ }
214
+ try {
215
+ socket.send(text);
216
+ return true;
217
+ }
218
+ catch {
219
+ return false;
220
+ }
221
+ }
222
+ startKeepalive() {
223
+ this.stopKeepalive();
224
+ this.keepaliveTimer = setInterval(() => this.write(JSON.stringify({ op: "ping", t: Date.now() })), SIGNALING_KEEPALIVE_MS);
225
+ }
226
+ stopKeepalive() {
227
+ clearInterval(this.keepaliveTimer);
228
+ this.keepaliveTimer = undefined;
229
+ }
230
+ scheduleReconnect() {
231
+ if (this.closed) {
232
+ return;
233
+ }
234
+ const ceiling = Math.min(SIGNALING_BACKOFF_MAX_MS, SIGNALING_BACKOFF_BASE_MS * 2 ** Math.min(this.attempt, 10));
235
+ const delay = ceiling / 2 + ((this.options.random ?? Math.random)() * ceiling) / 2;
236
+ this.attempt += 1;
237
+ this.reconnectTimer = setTimeout(() => {
238
+ this.reconnectTimer = undefined;
239
+ this.openSocket();
240
+ }, delay);
241
+ }
242
+ /** The server closed the socket for good - see this module's doc comment. */
243
+ refused(reason) {
244
+ if (this.pendingConnect) {
245
+ this.fail(new Error(reason));
246
+ return;
247
+ }
248
+ if (this.everReady) {
249
+ this.options.onClosed?.(reason);
250
+ }
251
+ }
252
+ fail(err) {
253
+ this.pendingConnect?.reject(err);
254
+ this.pendingConnect = undefined;
255
+ }
256
+ /** Sets the `jwt` cookie to the guest `token`, so the browser attaches it to the upgrade this client is about to
257
+ * open - see this module's doc comment. A no-op without a token or a document. */
258
+ applyAuthCookie() {
259
+ if (!this.options.token) {
260
+ return;
261
+ }
262
+ const doc = this.options.documentRef ?? (typeof document === "undefined" ? undefined : document);
263
+ if (!doc) {
264
+ return;
265
+ }
266
+ this.cookieApplied = true;
267
+ const secure = (doc.location ?? (typeof location === "undefined" ? undefined : location))?.protocol === "https:" ? "; Secure" : "";
268
+ doc.cookie = `${AUTH_COOKIE_NAME}=${encodeURIComponent(this.options.token)}; path=/; SameSite=Lax${secure}`;
269
+ }
270
+ /** Expires the cookie `applyAuthCookie()` set; a no-op when it set none. */
271
+ clearAuthCookie() {
272
+ if (!this.cookieApplied) {
273
+ return;
274
+ }
275
+ const doc = this.options.documentRef ?? (typeof document === "undefined" ? undefined : document);
276
+ doc.cookie = `${AUTH_COOKIE_NAME}=; path=/; Max-Age=0`;
277
+ }
278
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Full-mesh WebRTC connection management: one direct `RTCPeerConnection` per other participant, signaled entirely
3
- * over the meeting's own push channel (`SignalingChannel` - see `types.ts`) as this plugin's Phase 2
3
+ * over the meeting's own signaling channel (`SignalingChannel` - see `types.ts`) as this plugin's Phase 2
4
4
  * `.claude/NOTES.md` entry lays out. Pure state machine, no JSX/DOM rendering - `apps/meet/_CallView.tsx` owns one
5
5
  * instance per call and mirrors its events into React state.
6
6
  *
@@ -27,17 +27,17 @@
27
27
  *
28
28
  * ## Join announcement and roster discovery
29
29
  *
30
- * The channel has no history (a push event published while a socket was down is never replayed - see
31
- * `@rapidmx/web-client`'s `lib/mail/pushClient.ts` doc comment for the identical guarantee on the mail push channel this
32
- * one shares its transport with), so a newcomer's `hello` would only reach participants who happened to already be
33
- * subscribed *and* who joined before them - never the other way around. Every participant who learns of a
30
+ * The channel has no history (the server delivers a message only to the sockets connected at that moment, and never
31
+ * replays one to a socket that connects later - see the backend's `SignalingHub`), so a newcomer's `hello` would only
32
+ * reach participants who happened to already be connected *and* who joined before them - never the other way around. Every participant who learns of a
34
33
  * genuinely new peer (from any message, not just `hello`) therefore echoes its own `hello` right back, once, the
35
34
  * first time it becomes aware of that peer - so within one extra round trip everyone converges on the same full
36
35
  * roster regardless of join order. This is naturally bounded (each participant echoes at most once per peer it
37
36
  * ever discovers) rather than a risk of runaway flooding.
38
37
  *
39
- * Each message is its own `POST`, so nothing guarantees they arrive in the order they were sent. Two consequences
40
- * are handled here: an `offer` that beats its sender's `hello` creates the peer under a placeholder name that the
38
+ * Nothing in `SignalingChannel` itself promises that messages arrive in the order they were sent (one socket keeps
39
+ * one sender's messages in order today, but the transport this replaced sent each message as its own `POST`, and
40
+ * this manager does not depend on which transport it has). Two consequences are handled here: an `offer` that beats its sender's `hello` creates the peer under a placeholder name that the
41
41
  * `hello` then replaces (`participant-updated`), and an ICE candidate that beats its offer is held until the peer
42
42
  * exists rather than dropped.
43
43
  *