@rapidmx/meet-plugin 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +35 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +508 -483
  4. package/apps/meet/_CallView.tsx +1060 -924
  5. package/apps/meet/_ChatPanel.tsx +194 -0
  6. package/apps/meet/_ColorWheel.tsx +136 -0
  7. package/apps/meet/_MeetLobby.tsx +224 -224
  8. package/apps/meet/_ParticipantTile.tsx +243 -242
  9. package/apps/meet/_Whiteboard.tsx +577 -0
  10. package/apps/meet/_icons.tsx +135 -90
  11. package/apps/meet/_meetApi.ts +213 -212
  12. package/apps/shared/collab/CollabSession.ts +675 -0
  13. package/apps/shared/collab/WhiteboardModel.ts +216 -0
  14. package/apps/shared/collab/chatMarkdown.tsx +375 -0
  15. package/apps/shared/collab/color.ts +67 -0
  16. package/apps/shared/collab/protocol.ts +321 -0
  17. package/apps/shared/collab/whiteboardRender.ts +311 -0
  18. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  19. package/apps/shared/media/filters/mlModels.ts +174 -174
  20. package/apps/shared/relay/RelayClient.ts +2 -1
  21. package/apps/shared/relay/RelayTransport.ts +2 -1
  22. package/apps/shared/relay/relayEnv.ts +1 -1
  23. package/apps/shared/signaling/SignalingClient.ts +335 -0
  24. package/apps/shared/webrtc/MeshConnectionManager.ts +971 -971
  25. package/apps/shared/webrtc/types.ts +335 -316
  26. package/dist/apps/meet/[token].js +1 -1
  27. package/dist/apps/meet/_CallControls.d.ts +12 -4
  28. package/dist/apps/meet/_CallControls.js +5 -5
  29. package/dist/apps/meet/_CallView.d.ts +19 -4
  30. package/dist/apps/meet/_CallView.js +100 -11
  31. package/dist/apps/meet/_ChatPanel.d.ts +24 -0
  32. package/dist/apps/meet/_ChatPanel.js +116 -0
  33. package/dist/apps/meet/_ColorWheel.d.ts +14 -0
  34. package/dist/apps/meet/_ColorWheel.js +82 -0
  35. package/dist/apps/meet/_ParticipantTile.d.ts +2 -1
  36. package/dist/apps/meet/_ParticipantTile.js +1 -1
  37. package/dist/apps/meet/_Whiteboard.d.ts +64 -0
  38. package/dist/apps/meet/_Whiteboard.js +312 -0
  39. package/dist/apps/meet/_icons.d.ts +12 -0
  40. package/dist/apps/meet/_icons.js +12 -0
  41. package/dist/apps/meet/_meetApi.d.ts +8 -7
  42. package/dist/apps/meet/_meetApi.js +2 -2
  43. package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
  44. package/dist/apps/shared/collab/CollabSession.js +559 -0
  45. package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
  46. package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
  47. package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
  48. package/dist/apps/shared/collab/chatMarkdown.js +311 -0
  49. package/dist/apps/shared/collab/color.d.ts +18 -0
  50. package/dist/apps/shared/collab/color.js +59 -0
  51. package/dist/apps/shared/collab/protocol.d.ts +157 -0
  52. package/dist/apps/shared/collab/protocol.js +222 -0
  53. package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
  54. package/dist/apps/shared/collab/whiteboardRender.js +268 -0
  55. package/dist/apps/shared/relay/RelayClient.js +2 -1
  56. package/dist/apps/shared/relay/RelayTransport.d.ts +2 -1
  57. package/dist/apps/shared/relay/RelayTransport.js +2 -1
  58. package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
  59. package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
  60. package/dist/apps/shared/signaling/SignalingClient.js +278 -0
  61. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
  62. package/dist/apps/shared/webrtc/MeshConnectionManager.js +8 -8
  63. package/dist/apps/shared/webrtc/types.d.ts +38 -24
  64. package/dist/apps/shared/webrtc/types.js +3 -4
  65. package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
  66. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  67. package/dist/lib/util/SignalingHub.js +339 -0
  68. package/dist/lib/util/SignalingHub.js.map +1 -0
  69. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
  70. package/dist/types/util/SignalingHub.d.ts +112 -0
  71. package/package.json +208 -208
  72. package/apps/shared/push/GuestSignalingClient.ts +0 -359
  73. package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
  74. package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
@@ -0,0 +1,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
  *
@@ -4,7 +4,7 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  /**
6
6
  * Full-mesh WebRTC connection management: one direct `RTCPeerConnection` per other participant, signaled entirely
7
- * over the meeting's own push channel (`SignalingChannel` - see `types.ts`) as this plugin's Phase 2
7
+ * over the meeting's own signaling channel (`SignalingChannel` - see `types.ts`) as this plugin's Phase 2
8
8
  * `.claude/NOTES.md` entry lays out. Pure state machine, no JSX/DOM rendering - `apps/meet/_CallView.tsx` owns one
9
9
  * instance per call and mirrors its events into React state.
10
10
  *
@@ -31,17 +31,17 @@
31
31
  *
32
32
  * ## Join announcement and roster discovery
33
33
  *
34
- * The channel has no history (a push event published while a socket was down is never replayed - see
35
- * `@rapidmx/web-client`'s `lib/mail/pushClient.ts` doc comment for the identical guarantee on the mail push channel this
36
- * one shares its transport with), so a newcomer's `hello` would only reach participants who happened to already be
37
- * subscribed *and* who joined before them - never the other way around. Every participant who learns of a
34
+ * The channel has no history (the server delivers a message only to the sockets connected at that moment, and never
35
+ * replays one to a socket that connects later - see the backend's `SignalingHub`), so a newcomer's `hello` would only
36
+ * reach participants who happened to already be connected *and* who joined before them - never the other way around. Every participant who learns of a
38
37
  * genuinely new peer (from any message, not just `hello`) therefore echoes its own `hello` right back, once, the
39
38
  * first time it becomes aware of that peer - so within one extra round trip everyone converges on the same full
40
39
  * roster regardless of join order. This is naturally bounded (each participant echoes at most once per peer it
41
40
  * ever discovers) rather than a risk of runaway flooding.
42
41
  *
43
- * Each message is its own `POST`, so nothing guarantees they arrive in the order they were sent. Two consequences
44
- * are handled here: an `offer` that beats its sender's `hello` creates the peer under a placeholder name that the
42
+ * Nothing in `SignalingChannel` itself promises that messages arrive in the order they were sent (one socket keeps
43
+ * one sender's messages in order today, but the transport this replaced sent each message as its own `POST`, and
44
+ * 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
45
45
  * `hello` then replaces (`participant-updated`), and an ICE candidate that beats its offer is held until the peer
46
46
  * exists rather than dropped.
47
47
  *
@@ -746,7 +746,7 @@ export class MeshConnectionManager {
746
746
  }
747
747
  const peer = this.peers.get(message.from);
748
748
  if (!peer) {
749
- // A candidate that raced ahead of its own offer (each message is a separate `POST`) - hold it for
749
+ // A candidate that raced ahead of its own offer (see this module's doc comment on ordering) - hold it for
750
750
  // the peer to claim when it is created. Bounded, see `MAX_ORPHAN_PEERS`.
751
751
  const held = this.orphanCandidates.get(message.from);
752
752
  if (held) {
@@ -1,7 +1,6 @@
1
- /** Shared types for `MeshConnectionManager.ts` and its signaling transport (`apps/shared/push/GuestSignalingClient.ts`) -
2
- * kept in their own module so a test can depend on the shapes alone. `GuestSignalingClient` itself now also serves
3
- * an already-authenticated real caller (`VideoMeetingJoinResult.authenticated`), not only a synthetic guest - see
4
- * its own doc comment. */
1
+ /** Shared types for `MeshConnectionManager.ts` and its signaling transport (`apps/shared/signaling/SignalingClient.ts`) -
2
+ * kept in their own module so a test can depend on the shapes alone. `SignalingClient` serves both a guest and an
3
+ * already-authenticated real caller (`VideoMeetingJoinResult.authenticated`) - see its own doc comment. */
5
4
  /** What a participant is sending, and whether their hand is up. Announced in `hello` and re-sent as a `state`
6
5
  * message on every change, so a tile can show a muted microphone, an avatar for a camera that is off, and a raised
7
6
  * hand without inspecting the media itself (the far end of a `replaceTrack(null)` gives no dependable signal that
@@ -29,16 +28,19 @@ export declare const REACTION_EMOJIS: readonly ["👍", "👏", "❤️", "🎉"
29
28
  * every other participant has chosen for their own tab. */
30
29
  export type TransportMode = "auto" | "p2p" | "relay" | "websocket";
31
30
  /**
32
- * One WebRTC signaling message, published as the JSON body of `POST /push/:meetingUid` (`BasePushRoute.send()`)
33
- * and received back over `/push`'s WebSocket, exactly as `BaseVideoMeetingRoute`'s doc comment describes ("SDP
34
- * offers/answers and ICE candidates are ordinary `NotificationUtils.sendMessage()` payloads"). `type` is a fixed
35
- * literal so a listener on the meeting's channel (which will also see other frame shapes if this server ever
36
- * grows other push consumers) can cheaply recognize and skip anything that isn't one of these.
31
+ * One WebRTC signaling message, sent as the `data` of a `{"op":"signal"}` frame over the meeting's own signaling
32
+ * WebSocket (`apps/shared/signaling/SignalingClient.ts`, served by `BaseVideoMeetingRoute.signal()` and the backend's
33
+ * `SignalingHub`) and delivered to the other participants the same way. `type` is a fixed literal so a listener can
34
+ * cheaply recognize and skip anything that isn't one of these.
35
+ *
36
+ * `chat`/`whiteboard` (see `../collab/CollabSession.ts`) are broadcast like `reaction`.
37
37
  *
38
38
  * `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction`/`talking-stick` are broadcast (`to` left
39
- * unset - every participant is relevant); `offer`/`answer`/`ice-candidate`/`mute-request`/`kicked` are
40
- * point-to-point (`to` is the intended peer's uid) - a recipient that isn't `to` (when set) ignores the message,
41
- * since a channel's pub/sub fans every message out to every subscriber, sender included.
39
+ * unset - every other participant receives it); `offer`/`answer`/`ice-candidate`/`mute-request`/`kicked` are
40
+ * point-to-point (`to` is the intended peer's id) - the server delivers one only to the socket of that peer, so
41
+ * nobody else ever sees it. The sender never receives its own messages back either. (A recipient still ignores a
42
+ * message whose `to` isn't itself, and its own messages - harmless with this transport, and kept so
43
+ * `MeshConnectionManager` does not depend on how its transport routes.)
42
44
  *
43
45
  * `mute-request`/`kicked`/`talking-stick` are host moderation signals (see `MeshConnectionManager`'s doc comment) -
44
46
  * purely cooperative, like every other message here: the recipient's own client decides whether to honor one, and
@@ -56,16 +58,17 @@ export type TransportMode = "auto" | "p2p" | "relay" | "websocket";
56
58
  */
57
59
  export interface SignalMessage {
58
60
  type: "video-meeting-signal";
59
- kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release" | "state" | "reaction" | "relay-fallback" | "mute-request" | "kicked" | "talking-stick" | "restart-connection";
61
+ kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release" | "state" | "reaction" | "relay-fallback" | "mute-request" | "kicked" | "talking-stick" | "restart-connection" | "chat" | "whiteboard" | "collab-sync-request" | "collab-sync" | "whiteboard-cursor";
60
62
  /** The sender's authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
61
- * the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server refuses a published message whose
62
- * `from` isn't the authenticated caller's uid (it stops one participant speaking as another), so this must be
63
- * exactly that uid. */
63
+ * the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server overwrites whatever the sender put
64
+ * here with the uid its signaling socket authenticated as (it stops one participant speaking as another), so a
65
+ * received message's `from` is always that uid. */
64
66
  from: string;
65
67
  /** Which of the sender's tabs or devices this is: `<from>~<random>`. The same account joining from two devices is
66
- * two participants, not one that ignores its own messages. A `peer` that doesn't start with `<from>~` is
67
- * ignored, so it can't name someone else's tab. Absent from a sender that predates it (the participant is then
68
- * identified by `from`). */
68
+ * two participants, not one that ignores its own messages. The server overwrites it with the peer id the sender's
69
+ * socket announced in its `hello` (which must be `<from>` or start with `<from>~`), so every received message
70
+ * carries one; a received `peer` that is neither is still ignored, so it can't name someone else's tab, and one
71
+ * that is absent identifies the participant by `from`. */
69
72
  peer?: string;
70
73
  /** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`/`relay-fallback`). */
71
74
  to?: string;
@@ -84,15 +87,26 @@ export interface SignalMessage {
84
87
  sdp?: RTCSessionDescriptionInit;
85
88
  /** `ice-candidate` only. */
86
89
  candidate?: RTCIceCandidateInit;
90
+ /** `chat` only - a `ChatPayload` (`../collab/protocol.ts`), validated by `parseChat()` before use. Typed `unknown`
91
+ * since it is another participant's input until then; the same goes for the next two. */
92
+ chat?: unknown;
93
+ /** `whiteboard` only - a `WhiteboardOp`, validated by `parseWhiteboardOp()`. */
94
+ whiteboard?: unknown;
95
+ /** `collab-sync` only - one `SyncPayload` part of a newcomer's catch-up, validated by `parseSync()`. `collab-sync`
96
+ * and `collab-sync-request` are point-to-point (`to` set) - see `CollabSession`'s doc comment. */
97
+ sync?: unknown;
98
+ /** `whiteboard-cursor` only - a `CursorPayload`, validated by `parseCursor()`; broadcast, never stored. */
99
+ cursor?: unknown;
87
100
  }
88
101
  /** What `MeshConnectionManager` needs from a signaling transport - implemented by
89
- * `apps/shared/push/GuestSignalingClient.ts` for a real call, and by an in-memory fake for `MeshConnectionManager`'s own
90
- * tests (see `test/webrtc/MeshConnectionManager.test.ts`), which never opens a real network connection. */
102
+ * `apps/shared/signaling/SignalingClient.ts` for a real call, and by an in-memory fake for `MeshConnectionManager`'s own
103
+ * tests (see `test/apps/shared/webrtc/MeshConnectionManager.test.ts`), which never opens a real network connection. */
91
104
  export interface SignalingChannel {
92
105
  send(message: SignalMessage): void;
93
- /** Calls `handler` with every message received on the channel (including, per the pub/sub fan-out this
94
- * transport is built on, this same client's own sends - `MeshConnectionManager` filters those out itself
95
- * rather than relying on the transport to). Returns the function that stops listening. */
106
+ /** Calls `handler` with every message received on the channel. `SignalingClient` never delivers this client's own
107
+ * sends, nor a `to`-addressed message meant for another peer, but `MeshConnectionManager` filters both out itself
108
+ * rather than relying on the transport to (its tests' fake echoes everything to everyone). Returns the function
109
+ * that stops listening. */
96
110
  onMessage(handler: (message: SignalMessage) => void): () => void;
97
111
  }
98
112
  /** One kind's RTP stream stats, as of one sample - cumulative counters (`bytesSent`/`bytesReceived`), not a rate,
@@ -2,10 +2,9 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- /** Shared types for `MeshConnectionManager.ts` and its signaling transport (`apps/shared/push/GuestSignalingClient.ts`) -
6
- * kept in their own module so a test can depend on the shapes alone. `GuestSignalingClient` itself now also serves
7
- * an already-authenticated real caller (`VideoMeetingJoinResult.authenticated`), not only a synthetic guest - see
8
- * its own doc comment. */
5
+ /** Shared types for `MeshConnectionManager.ts` and its signaling transport (`apps/shared/signaling/SignalingClient.ts`) -
6
+ * kept in their own module so a test can depend on the shapes alone. `SignalingClient` serves both a guest and an
7
+ * already-authenticated real caller (`VideoMeetingJoinResult.authenticated`) - see its own doc comment. */
9
8
  /** The emoji a participant can send during a call. A received `reaction` outside this list is ignored, so an
10
9
  * arbitrary string from another participant is never rendered. */
11
10
  export const REACTION_EMOJIS = ["👍", "👏", "❤️", "🎉", "😂", "😮", "😢", "👋"];