@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.
- package/README.md +35 -55
- package/apps/meet/[token].tsx +341 -341
- package/apps/meet/_CallControls.tsx +508 -483
- package/apps/meet/_CallView.tsx +1060 -924
- package/apps/meet/_ChatPanel.tsx +194 -0
- package/apps/meet/_ColorWheel.tsx +136 -0
- package/apps/meet/_MeetLobby.tsx +224 -224
- package/apps/meet/_ParticipantTile.tsx +243 -242
- package/apps/meet/_Whiteboard.tsx +577 -0
- package/apps/meet/_icons.tsx +135 -90
- package/apps/meet/_meetApi.ts +213 -212
- package/apps/shared/collab/CollabSession.ts +675 -0
- package/apps/shared/collab/WhiteboardModel.ts +216 -0
- package/apps/shared/collab/chatMarkdown.tsx +375 -0
- package/apps/shared/collab/color.ts +67 -0
- package/apps/shared/collab/protocol.ts +321 -0
- package/apps/shared/collab/whiteboardRender.ts +311 -0
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
- package/apps/shared/media/filters/mlModels.ts +174 -174
- package/apps/shared/relay/RelayClient.ts +2 -1
- package/apps/shared/relay/RelayTransport.ts +2 -1
- package/apps/shared/relay/relayEnv.ts +1 -1
- package/apps/shared/signaling/SignalingClient.ts +335 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +971 -971
- package/apps/shared/webrtc/types.ts +335 -316
- package/dist/apps/meet/[token].js +1 -1
- package/dist/apps/meet/_CallControls.d.ts +12 -4
- package/dist/apps/meet/_CallControls.js +5 -5
- package/dist/apps/meet/_CallView.d.ts +19 -4
- package/dist/apps/meet/_CallView.js +100 -11
- package/dist/apps/meet/_ChatPanel.d.ts +24 -0
- package/dist/apps/meet/_ChatPanel.js +116 -0
- package/dist/apps/meet/_ColorWheel.d.ts +14 -0
- package/dist/apps/meet/_ColorWheel.js +82 -0
- package/dist/apps/meet/_ParticipantTile.d.ts +2 -1
- package/dist/apps/meet/_ParticipantTile.js +1 -1
- package/dist/apps/meet/_Whiteboard.d.ts +64 -0
- package/dist/apps/meet/_Whiteboard.js +312 -0
- package/dist/apps/meet/_icons.d.ts +12 -0
- package/dist/apps/meet/_icons.js +12 -0
- package/dist/apps/meet/_meetApi.d.ts +8 -7
- package/dist/apps/meet/_meetApi.js +2 -2
- package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
- package/dist/apps/shared/collab/CollabSession.js +559 -0
- package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
- package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
- package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
- package/dist/apps/shared/collab/chatMarkdown.js +311 -0
- package/dist/apps/shared/collab/color.d.ts +18 -0
- package/dist/apps/shared/collab/color.js +59 -0
- package/dist/apps/shared/collab/protocol.d.ts +157 -0
- package/dist/apps/shared/collab/protocol.js +222 -0
- package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
- package/dist/apps/shared/collab/whiteboardRender.js +268 -0
- package/dist/apps/shared/relay/RelayClient.js +2 -1
- package/dist/apps/shared/relay/RelayTransport.d.ts +2 -1
- package/dist/apps/shared/relay/RelayTransport.js +2 -1
- package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
- package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
- package/dist/apps/shared/signaling/SignalingClient.js +278 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +8 -8
- package/dist/apps/shared/webrtc/types.d.ts +38 -24
- package/dist/apps/shared/webrtc/types.js +3 -4
- package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/SignalingHub.js +339 -0
- package/dist/lib/util/SignalingHub.js.map +1 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
- package/dist/types/util/SignalingHub.d.ts +112 -0
- package/package.json +208 -208
- package/apps/shared/push/GuestSignalingClient.ts +0 -359
- package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
- 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
|
|
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 (
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
*
|
|
40
|
-
*
|
|
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
|
|
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 (
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
*
|
|
44
|
-
*
|
|
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 (
|
|
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/
|
|
2
|
-
* kept in their own module so a test can depend on the shapes alone. `
|
|
3
|
-
*
|
|
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,
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
40
|
-
* point-to-point (`to` is the intended peer's
|
|
41
|
-
*
|
|
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
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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/
|
|
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
|
|
94
|
-
*
|
|
95
|
-
* rather than relying on the transport to). Returns the function
|
|
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/
|
|
6
|
-
* kept in their own module so a test can depend on the shapes alone. `
|
|
7
|
-
*
|
|
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 = ["👍", "👏", "❤️", "🎉", "😂", "😮", "😢", "👋"];
|