@rapidmx/meet-plugin 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +55 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +463 -394
  4. package/apps/meet/_CallView.tsx +891 -848
  5. package/apps/meet/_DiagnosticsWindow.tsx +80 -0
  6. package/apps/meet/_MeetLobby.tsx +224 -224
  7. package/apps/meet/_ParticipantTile.tsx +169 -169
  8. package/apps/meet/_ParticipantsDrawer.tsx +10 -104
  9. package/apps/meet/_SettingsModal.tsx +152 -0
  10. package/apps/meet/_icons.tsx +80 -82
  11. package/apps/meet/_meetApi.ts +212 -212
  12. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  13. package/apps/shared/media/filters/mlModels.ts +174 -174
  14. package/apps/shared/push/GuestSignalingClient.ts +359 -359
  15. package/apps/shared/relay/RelayClient.ts +7 -2
  16. package/apps/shared/webrtc/MeshConnectionManager.ts +949 -795
  17. package/apps/shared/webrtc/types.ts +247 -222
  18. package/dist/apps/meet/_CallControls.d.ts +19 -9
  19. package/dist/apps/meet/_CallControls.js +28 -10
  20. package/dist/apps/meet/_CallView.d.ts +14 -2
  21. package/dist/apps/meet/_CallView.js +27 -2
  22. package/dist/apps/meet/_DiagnosticsWindow.d.ts +25 -0
  23. package/dist/apps/meet/_DiagnosticsWindow.js +45 -0
  24. package/dist/apps/meet/_ParticipantsDrawer.d.ts +10 -21
  25. package/dist/apps/meet/_ParticipantsDrawer.js +9 -39
  26. package/dist/apps/meet/_SettingsModal.d.ts +28 -0
  27. package/dist/apps/meet/_SettingsModal.js +59 -0
  28. package/dist/apps/meet/_icons.d.ts +1 -1
  29. package/dist/apps/meet/_icons.js +1 -1
  30. package/dist/apps/shared/relay/RelayClient.d.ts +6 -1
  31. package/dist/apps/shared/relay/RelayClient.js +7 -2
  32. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +55 -2
  33. package/dist/apps/shared/webrtc/MeshConnectionManager.js +153 -9
  34. package/dist/apps/shared/webrtc/types.d.ts +25 -1
  35. package/dist/lib/util/RedisRelayBus.js +61 -5
  36. package/dist/lib/util/RedisRelayBus.js.map +1 -1
  37. package/dist/lib/util/RelayHub.js +10 -3
  38. package/dist/lib/util/RelayHub.js.map +1 -1
  39. package/dist/types/util/RedisRelayBus.d.ts +45 -6
  40. package/dist/types/util/RelayHub.d.ts +9 -2
  41. package/package.json +208 -208
@@ -1,795 +1,949 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- /**
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
8
- * `.claude/NOTES.md` entry lays out. Pure state machine, no JSX/DOM rendering - `apps/meet/_CallView.tsx` owns one
9
- * instance per call and mirrors its events into React state.
10
- *
11
- * ## Who calls whom
12
- *
13
- * On learning of another participant (via `hello`), a peer connection for that pair is created by whichever side
14
- * has the lexicographically smaller uid (`isOfferer()`) - a well-known, deterministic, race-free rule for a mesh:
15
- * both sides independently compute the same answer with no coordination round trip, and exactly one side ever
16
- * calls `createOffer()` for a given pair.
17
- *
18
- * ## Media: two permanent transceivers, tracks swapped in and out
19
- *
20
- * Every connection carries one send-and-receive audio transceiver and one send-and-receive video transceiver,
21
- * whether or not the local participant has a microphone or camera to send yet. The offerer adds them before its
22
- * offer; the answerer takes the ones the offer created (`claimTransceivers()`) - a browser does not match an offer's
23
- * m-lines to transceivers the answerer added itself, so adding them up front on both sides leaves the answerer
24
- * sending nothing at all. A participant who declined permission, has no camera, or turns the camera on halfway
25
- * through therefore still negotiates both directions from the first offer, and later `setLocalTrack()` calls just
26
- * `replaceTrack()` on the existing senders - never a renegotiation. (Attaching tracks once at connection creation,
27
- * as this manager used to, meant a participant with nothing to send at that moment negotiated no media in either
28
- * direction, for the whole call.)
29
- * The remote side's tracks are collected into one `MediaStream` per peer (`remote-stream` events), rather than
30
- * read from `event.streams[0]`: a transceiver whose sender has no track yet announces no stream to the far end.
31
- *
32
- * ## Join announcement and roster discovery
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
38
- * genuinely new peer (from any message, not just `hello`) therefore echoes its own `hello` right back, once, the
39
- * first time it becomes aware of that peer - so within one extra round trip everyone converges on the same full
40
- * roster regardless of join order. This is naturally bounded (each participant echoes at most once per peer it
41
- * ever discovers) rather than a risk of runaway flooding.
42
- *
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
45
- * `hello` then replaces (`participant-updated`), and an ICE candidate that beats its offer is held until the peer
46
- * exists rather than dropped.
47
- *
48
- * ## Participant state, hands and reactions
49
- *
50
- * What each participant is sending (`audioOn`/`videoOn`) and whether their hand is up travels in `hello` and in a
51
- * `state` message on every change (`setLocalState()`), because a remote track gives no dependable signal that its
52
- * sender stopped. `reaction` messages carry one emoji from `REACTION_EMOJIS`; anything else is ignored.
53
- *
54
- * ## Presenter (single-writer screen share)
55
- *
56
- * `presenter-claim` is only ever sent locally when `presenterUid` is unset (`claimPresenter()` refuses otherwise).
57
- * A claim is applied optimistically and locally at once; every recipient (including the claimant) applies the
58
- * *first* claim it sees. If two claims genuinely race (both sent before either claimant heard the other's), every
59
- * participant converges on the same winner by resolving the collision deterministically: the lexicographically
60
- * smaller uid wins, and the losing claimant self-revokes (stops its own share locally and sends
61
- * `presenter-release`) once it observes the winning claim. See `handlePresenterClaim()`.
62
- *
63
- * Sharing a screen is `setLocalTrack("video", screenTrack)`; stopping is `setLocalTrack("video", cameraTrack)`.
64
- *
65
- * ## Host moderation (`mute-request`, `kicked`)
66
- *
67
- * `sendMuteRequest()`/`sendKick()` send a point-to-point signal to one peer; the manager itself does not decide who
68
- * may send one - a "host" identity exists only in `_CallView.tsx`, which shows the controls that call these
69
- * methods to nobody but the host (see `types.ts`'s `SignalMessage` doc comment for why this is cooperative, not
70
- * enforced, at the signaling layer). Receiving one emits `mute-requested`/`kicked` for the caller to act on; this
71
- * manager takes no action of its own beyond that - it doesn't mute a track or tear itself down, since a kicked
72
- * participant's actual departure is `stop()`, which the caller decides to call.
73
- *
74
- * ## Talking stick (`talking-stick`)
75
- *
76
- * One exclusive "floor" - at most one participant may be unmuted at a time, host-assigned - but unlike presenter,
77
- * there is no claim race to resolve: only the host's own client ever calls `setTalkingStick()` (same trust level as
78
- * `sendMuteRequest()`/`sendKick()` - nothing here checks that), so the manager carries no state of its own and
79
- * simply relays the latest message it sees as `talking-stick-changed`, applying it to itself too via an immediate
80
- * local emit (the same "optimistic, no round trip" shape `claimPresenter()`/`releasePresenter()` use). A holder who
81
- * leaves the call is not specially reassigned - every other participant's existing `participant-left` handling
82
- * already drops them from the roster, and "the stick's holder is no longer in the call" is simply "nobody is holder
83
- * right now" without any manager needing to say so, until the host picks someone (or themselves) again. There is no
84
- * backend route or persisted field for this at all: like `mute-request`/`kicked`, it only matters while people are
85
- * actually in the call together.
86
- *
87
- * ## Three media paths, tried in order
88
- *
89
- * Each pair of participants reaches for the best path that works, and `MeshParticipant.transport` says which one it
90
- * got:
91
- *
92
- * 1. **Direct peer-to-peer** (`"p2p"`). The `RTCPeerConnection` is given the STUN servers and the TURN server
93
- * together, and ICE ranks a direct pair above a relayed one, so this is what a pair gets whenever it can.
94
- * 2. **Relayed through the TURN server** (`"turn"`). The same connection, on the relay candidate ICE falls back to
95
- * when the direct pairs fail - a participant behind a symmetric NAT or a firewall that blocks direct UDP. Which of
96
- * the two ICE picked is read from the selected candidate pair once connected (`RTCPeerConnectionLike.connectionType()`).
97
- * 3. **Proxied by this server over a WebSocket** (`"websocket"`, `apps/shared/relay/`). For a participant whose
98
- * network lets nothing but ordinary HTTPS out, neither of the above ever connects. A pair whose connection has not
99
- * come up within `connectTimeoutMs`, has failed, or has stayed `disconnected` for `disconnectedGraceMs` gives up
100
- * on WebRTC and switches to it, telling the other side with a `relay-fallback` message so both switch even when
101
- * only one of them noticed. Nothing is renegotiated - the pair simply stops using the `RTCPeerConnection` - and the
102
- * relay's stream replaces the one WebRTC would have filled (a `remote-stream` event again). When the relay is
103
- * unavailable (turned off by the operator, or a browser without WebCodecs) the pair is marked `"failed"` and stays
104
- * in the roster, rather than the participant vanishing with no explanation as it used to.
105
- *
106
- * A pair never moves back up: once on the relay it stays there for the rest of the call.
107
- */
108
- import {
109
- type MediaTransport,
110
- type MeshEvent,
111
- type MeshParticipant,
112
- type ParticipantState,
113
- type RTCPeerConnectionFactory,
114
- type RTCPeerConnectionLike,
115
- type RTCRtpSenderLike,
116
- type RelayTransportLike,
117
- type SignalMessage,
118
- type SignalingChannel,
119
- REACTION_EMOJIS,
120
- } from "./types.js";
121
-
122
- /** The lexicographically smaller uid is always the offerer for that pair - see this module's doc comment. */
123
- export function isOfferer(selfId: string, peerId: string): boolean {
124
- return selfId < peerId;
125
- }
126
-
127
- /** How many peers' worth of early ICE candidates are held, and how many candidates per peer - bounds the memory a
128
- * misbehaving participant could make this hold for peers that never materialize. */
129
- const MAX_ORPHAN_PEERS = 32;
130
- const MAX_ORPHAN_CANDIDATES = 64;
131
-
132
- /** How long a connection may stay in `connecting` before the pair gives up on WebRTC. ICE with an unreachable TURN
133
- * server can take the better part of a minute to report `failed`; a person waiting on a call will not. It is still long
134
- * enough for a slow TURN allocation and a few round trips of signaling. */
135
- export const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
136
-
137
- /** How long a connection may stay `disconnected` (ICE lost the path but may still recover) before giving up on it. */
138
- export const DEFAULT_DISCONNECTED_GRACE_MS = 8_000;
139
-
140
- /** How often a connected pair's quality stats (the diagnostics panel) are refreshed - low frequency on purpose:
141
- * this is for a participant occasionally checking why a call feels off, not a live chart, and `getStats()` is not
142
- * free to call every frame. */
143
- export const DEFAULT_DIAGNOSTICS_POLL_MS = 3_000;
144
-
145
- type MediaKind = "audio" | "video";
146
-
147
- interface PeerState extends MeshParticipant {
148
- pc: RTCPeerConnectionLike;
149
- /** Empty on an answerer until the offer arrives - see `handleOffer()`. */
150
- senders: Partial<Record<MediaKind, RTCRtpSenderLike>>;
151
- remoteStream: MediaStream;
152
- remoteDescriptionSet: boolean;
153
- pendingCandidates: RTCIceCandidateInit[];
154
- /** Gives up on WebRTC when the connection is still `connecting` after `connectTimeoutMs`. */
155
- connectTimer: ReturnType<typeof setTimeout> | undefined;
156
- /** Gives up on WebRTC when the connection has stayed `disconnected` for `disconnectedGraceMs`. */
157
- disconnectTimer: ReturnType<typeof setTimeout> | undefined;
158
- /** Refreshes `diagnostics` (`MeshParticipant`) while this pair is on a real `RTCPeerConnection` - started once
159
- * it first connects, stopped the moment it leaves WebRTC for the relay or the pair ends. */
160
- diagnosticsTimer: ReturnType<typeof setInterval> | undefined;
161
- }
162
-
163
- export interface MeshConnectionManagerOptions {
164
- /** The authenticated uid - what `from` must be on the wire (see `SignalMessage.from`). */
165
- selfUid: string;
166
- /** This tab's identity in the call, `<selfUid>~<random>` - defaults to `selfUid`. Everything the manager keys on
167
- * (the roster, who offers, who presents) is a peer id, so two tabs of one account are two participants. */
168
- peerId?: string;
169
- selfName: string;
170
- iceServers: RTCIceServer[];
171
- channel: SignalingChannel;
172
- createPeerConnection: RTCPeerConnectionFactory;
173
- /** The tracks to send from the start - either may be absent (no permission, no device) and supplied or
174
- * replaced later with `setLocalTrack()`. */
175
- localAudioTrack?: MediaStreamTrack | null;
176
- localVideoTrack?: MediaStreamTrack | null;
177
- /** What to announce at first - defaults to "sending whatever tracks were given, hand down". */
178
- localState?: Partial<ParticipantState>;
179
- /** Builds the (initially empty) stream one peer's incoming tracks are gathered into. Defaults to
180
- * `new MediaStream()`; a test supplies a fake. */
181
- createMediaStream?: () => MediaStream;
182
- /** The WebSocket media relay, the last-resort path - see this module's doc comment. Leave unset when the
183
- * operator has turned it off; without one a pair WebRTC cannot connect is marked `"failed"`. */
184
- relay?: RelayTransportLike;
185
- /** Defaults to `DEFAULT_CONNECT_TIMEOUT_MS`. */
186
- connectTimeoutMs?: number;
187
- /** Defaults to `DEFAULT_DISCONNECTED_GRACE_MS`. */
188
- disconnectedGraceMs?: number;
189
- /** Defaults to `DEFAULT_DIAGNOSTICS_POLL_MS`. */
190
- diagnosticsPollMs?: number;
191
- }
192
-
193
- export class MeshConnectionManager {
194
- private readonly peers = new Map<string, PeerState>();
195
- private readonly listeners = new Set<(event: MeshEvent) => void>();
196
- private readonly orphanCandidates = new Map<string, RTCIceCandidateInit[]>();
197
- private readonly localTracks: Record<MediaKind, MediaStreamTrack | null>;
198
- private localState: ParticipantState;
199
- private unsubscribe: (() => void) | undefined;
200
- private started = false;
201
- private stopped = false;
202
- private currentPresenterUid: string | undefined;
203
- private readonly selfId: string;
204
-
205
- constructor(private readonly options: MeshConnectionManagerOptions) {
206
- this.selfId = options.peerId ?? options.selfUid;
207
- this.localTracks = { audio: options.localAudioTrack ?? null, video: options.localVideoTrack ?? null };
208
- this.localState = {
209
- audioOn: !!options.localAudioTrack,
210
- videoOn: !!options.localVideoTrack,
211
- handRaised: false,
212
- ...options.localState,
213
- };
214
- options.relay?.setLocalTrack("audio", this.localTracks.audio);
215
- options.relay?.setLocalTrack("video", this.localTracks.video);
216
- }
217
-
218
- get participants(): MeshParticipant[] {
219
- return [...this.peers.values()].map((peer) => toParticipant(peer));
220
- }
221
-
222
- get presenterUid(): string | undefined {
223
- return this.currentPresenterUid;
224
- }
225
-
226
- onEvent(listener: (event: MeshEvent) => void): () => void {
227
- this.listeners.add(listener);
228
- return () => this.listeners.delete(listener);
229
- }
230
-
231
- /** Subscribes to the channel and announces this participant. Idempotent - a second call is a no-op. */
232
- start(): void {
233
- if (this.started) {
234
- return;
235
- }
236
- this.started = true;
237
- this.unsubscribe = this.options.channel.onMessage((message) => this.handleMessage(message));
238
- this.sendHello();
239
- }
240
-
241
- /** Announces departure, closes every peer connection and unsubscribes - idempotent, and safe to call whether
242
- * or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping the local
243
- * tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns their
244
- * lifecycle, only sends them. */
245
- stop(): void {
246
- if (this.stopped) {
247
- return;
248
- }
249
- this.stopped = true;
250
- if (this.started) {
251
- this.send({ kind: "bye" });
252
- }
253
- this.unsubscribe?.();
254
- this.unsubscribe = undefined;
255
- for (const peer of this.peers.values()) {
256
- this.clearTimers(peer);
257
- this.stopDiagnosticsPolling(peer);
258
- peer.pc.close();
259
- }
260
- this.peers.clear();
261
- this.orphanCandidates.clear();
262
- this.listeners.clear();
263
- this.options.relay?.close();
264
- }
265
-
266
- /** Sets the track sent as `kind` (`null` to send nothing) on every connection, now and for every peer that
267
- * joins later - see this module's doc comment. */
268
- setLocalTrack(kind: MediaKind, track: MediaStreamTrack | null): void {
269
- this.localTracks[kind] = track;
270
- for (const peer of this.peers.values()) {
271
- void peer.senders[kind]?.replaceTrack(track);
272
- }
273
- this.options.relay?.setLocalTrack(kind, track);
274
- }
275
-
276
- /** Updates what this participant announces (`audioOn`/`videoOn`/`handRaised`) and tells everyone if anything
277
- * actually changed. Before `start()` it only records the state, which `hello` then carries. */
278
- setLocalState(partial: Partial<ParticipantState>): void {
279
- const next = { ...this.localState, ...partial };
280
- if (sameState(next, this.localState)) {
281
- return;
282
- }
283
- this.localState = next;
284
- if (this.started && !this.stopped) {
285
- this.send({ kind: "state", state: next });
286
- }
287
- }
288
-
289
- /** Sends one emoji to everyone. Returns `false`, sending nothing, for an emoji outside `REACTION_EMOJIS`. */
290
- sendReaction(emoji: string): boolean {
291
- if (!isReactionEmoji(emoji)) {
292
- return false;
293
- }
294
- this.send({ kind: "reaction", emoji });
295
- return true;
296
- }
297
-
298
- /** Claims presenter status for the local participant. Refuses (returns `false`, sends nothing) when someone
299
- * else already presents - the caller (`_CallView.tsx`) uses this to disable its own "share screen" control
300
- * rather than let a claim silently do nothing. */
301
- claimPresenter(): boolean {
302
- if (this.currentPresenterUid !== undefined && this.currentPresenterUid !== this.selfId) {
303
- return false;
304
- }
305
- this.currentPresenterUid = this.selfId;
306
- this.send({ kind: "presenter-claim" });
307
- this.emit({ type: "presenter-changed", uid: this.currentPresenterUid });
308
- return true;
309
- }
310
-
311
- /** Releases presenter status - a no-op unless the local participant currently holds it. */
312
- releasePresenter(): void {
313
- if (this.currentPresenterUid !== this.selfId) {
314
- return;
315
- }
316
- this.currentPresenterUid = undefined;
317
- this.send({ kind: "presenter-release" });
318
- this.emit({ type: "presenter-changed", uid: undefined });
319
- }
320
-
321
- /** Asks `peerUid`'s participant to mute - a cooperative signal (see this module's doc comment on host
322
- * moderation), not enforcement. Nothing here checks that the local participant is actually the host; the caller
323
- * is responsible for only offering this to one. */
324
- sendMuteRequest(peerUid: string): void {
325
- this.send({ kind: "mute-request", to: peerUid });
326
- }
327
-
328
- /** Tells `peerUid`'s participant they have been removed from the call. Sends only the cooperative signal - it
329
- * does not itself revoke `peerUid`'s server-side channel grant (see `BaseVideoMeetingRoute.revokeChannelGrant()`,
330
- * which the caller is expected to call alongside this for an enforced removal). */
331
- sendKick(peerUid: string): void {
332
- this.send({ kind: "kicked", to: peerUid });
333
- }
334
-
335
- /** Turns talking-stick mode on (naming `holder`, the initial speaker) or off (`active: false`, `holder`
336
- * omitted), or hands an already-active stick to a different `holder` - see this module's doc comment on why
337
- * there is no collision to resolve here, unlike presenter. Broadcasts and applies locally at once, trusting
338
- * there is only ever one sender; the caller (`_CallView.tsx`) is responsible for only offering this to the
339
- * host. */
340
- setTalkingStick(active: boolean, holder?: string): void {
341
- this.send({ kind: "talking-stick", active, holder });
342
- this.emit({ type: "talking-stick-changed", active, holder });
343
- }
344
-
345
- private sendHello(): void {
346
- this.send({ kind: "hello", name: this.options.selfName, state: this.localState });
347
- }
348
-
349
- private send(partial: Omit<SignalMessage, "type" | "from">): void {
350
- this.options.channel.send({ type: "video-meeting-signal", from: this.options.selfUid, peer: this.selfId, ...partial });
351
- }
352
-
353
- private emit(event: MeshEvent): void {
354
- for (const listener of [...this.listeners]) {
355
- listener(event);
356
- }
357
- }
358
-
359
- private handleMessage(raw: SignalMessage): void {
360
- if (raw.type !== "video-meeting-signal") {
361
- return;
362
- }
363
- // From here on `from` is the sender's peer id - see `SignalMessage.peer`.
364
- if (raw.peer !== undefined && raw.peer !== raw.from && !raw.peer.startsWith(`${raw.from}~`)) {
365
- return;
366
- }
367
- const message: SignalMessage = { ...raw, from: raw.peer ?? raw.from };
368
- if (message.from === this.selfId) {
369
- return;
370
- }
371
- if (message.to !== undefined && message.to !== this.selfId) {
372
- return;
373
- }
374
- switch (message.kind) {
375
- case "hello":
376
- this.handleHello(message);
377
- return;
378
- case "bye":
379
- this.handleBye(message.from);
380
- return;
381
- case "offer":
382
- void this.handleOffer(message);
383
- return;
384
- case "answer":
385
- void this.handleAnswer(message);
386
- return;
387
- case "ice-candidate":
388
- void this.handleIceCandidate(message);
389
- return;
390
- case "presenter-claim":
391
- this.handlePresenterClaim(message.from);
392
- return;
393
- case "presenter-release":
394
- this.handlePresenterRelease(message.from);
395
- return;
396
- case "state":
397
- this.handleState(message);
398
- return;
399
- case "reaction":
400
- this.handleReaction(message);
401
- return;
402
- case "relay-fallback":
403
- this.handleRelayFallback(message.from);
404
- return;
405
- case "mute-request":
406
- this.emit({ type: "mute-requested" });
407
- return;
408
- case "kicked":
409
- this.emit({ type: "kicked" });
410
- return;
411
- case "talking-stick":
412
- this.emit({ type: "talking-stick-changed", active: message.active === true, holder: typeof message.holder === "string" ? message.holder : undefined });
413
- return;
414
- }
415
- }
416
-
417
- private handleHello(message: SignalMessage): void {
418
- const known = this.peers.get(message.from);
419
- if (known) {
420
- // An offer that beat this hello created the peer under a placeholder name - fill in the real one.
421
- this.updatePeer(known, message.name, message.state);
422
- return;
423
- }
424
- const offerer = isOfferer(this.selfId, message.from);
425
- const peer = this.createPeer(message.from, message.name ?? message.from, message.state, offerer);
426
- this.emit({ type: "participant-joined", participant: toParticipant(peer) });
427
- // Let a newcomer who couldn't have seen our own original `hello` learn about us too - see this module's
428
- // doc comment on roster discovery.
429
- this.sendHello();
430
- if (offerer) {
431
- void this.initiateOffer(peer);
432
- }
433
- }
434
-
435
- private handleState(message: SignalMessage): void {
436
- const peer = this.peers.get(message.from);
437
- if (peer) {
438
- this.updatePeer(peer, undefined, message.state);
439
- }
440
- }
441
-
442
- private handleReaction(message: SignalMessage): void {
443
- const peer = this.peers.get(message.from);
444
- if (peer && isReactionEmoji(message.emoji)) {
445
- this.emit({ type: "reaction", uid: peer.uid, name: peer.name, emoji: message.emoji });
446
- }
447
- }
448
-
449
- /** Applies a newly learned name and/or state to `peer`, emitting `participant-updated` (and `hand-raised` on a
450
- * hand going up) only when something actually changed. */
451
- private updatePeer(peer: PeerState, name: string | undefined, state: ParticipantState | undefined): void {
452
- const nextName = name ?? peer.name;
453
- const next = state ? sanitizeState(state) : undefined;
454
- const handWentUp = !!next && next.handRaised && !peer.handRaised;
455
- if (nextName === peer.name && (!next || sameState(next, peer))) {
456
- return;
457
- }
458
- peer.name = nextName;
459
- if (next) {
460
- peer.audioOn = next.audioOn;
461
- peer.videoOn = next.videoOn;
462
- peer.handRaised = next.handRaised;
463
- }
464
- this.emit({ type: "participant-updated", participant: toParticipant(peer) });
465
- if (handWentUp) {
466
- this.emit({ type: "hand-raised", uid: peer.uid, name: peer.name });
467
- }
468
- }
469
-
470
- private handleBye(uid: string): void {
471
- this.orphanCandidates.delete(uid);
472
- const peer = this.peers.get(uid);
473
- if (!peer) {
474
- return;
475
- }
476
- this.clearTimers(peer);
477
- this.stopDiagnosticsPolling(peer);
478
- peer.pc.close();
479
- this.peers.delete(uid);
480
- if (peer.transport === "websocket") {
481
- this.options.relay?.stopReceivingFrom(uid);
482
- this.syncRelaySending();
483
- }
484
- this.emit({ type: "participant-left", uid });
485
- if (this.currentPresenterUid === uid) {
486
- this.currentPresenterUid = undefined;
487
- this.emit({ type: "presenter-changed", uid: undefined });
488
- }
489
- }
490
-
491
- private createPeer(uid: string, name: string, state: ParticipantState | undefined, offerer: boolean): PeerState {
492
- const pc = this.options.createPeerConnection({ iceServers: this.options.iceServers });
493
- const peer: PeerState = {
494
- uid,
495
- name,
496
- ...(state ? sanitizeState(state) : { audioOn: false, videoOn: false, handRaised: false }),
497
- pc,
498
- senders: offerer
499
- ? {
500
- audio: pc.addTransceiver("audio", this.localTracks.audio).sender,
501
- video: pc.addTransceiver("video", this.localTracks.video).sender,
502
- }
503
- : {},
504
- remoteStream: (this.options.createMediaStream ?? (() => new MediaStream()))(),
505
- remoteDescriptionSet: false,
506
- pendingCandidates: this.orphanCandidates.get(uid) ?? [],
507
- transport: "connecting",
508
- connectTimer: undefined,
509
- disconnectTimer: undefined,
510
- diagnosticsTimer: undefined,
511
- };
512
- this.orphanCandidates.delete(uid);
513
- this.peers.set(uid, peer);
514
- pc.onicecandidate = (event) => {
515
- if (event.candidate) {
516
- this.send({ kind: "ice-candidate", to: uid, candidate: event.candidate });
517
- }
518
- };
519
- pc.ontrack = (event) => {
520
- if (!peer.remoteStream.getTracks().includes(event.track)) {
521
- peer.remoteStream.addTrack(event.track);
522
- }
523
- this.emit({ type: "remote-stream", uid, stream: peer.remoteStream });
524
- };
525
- pc.onconnectionstatechange = () => this.handleConnectionState(peer);
526
- // Cleared the moment the connection comes up, so this only ever fires for a pair still waiting on WebRTC.
527
- peer.connectTimer = setTimeout(() => this.fallBack(peer), this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
528
- return peer;
529
- }
530
-
531
- /** Reacts to the peer connection's state: notes which path a connected pair got, and gives up on WebRTC for a
532
- * pair that failed or stays disconnected - see `fallBack()`. Ignores a pair that already left WebRTC. */
533
- private handleConnectionState(peer: PeerState): void {
534
- if (peer.transport === "websocket" || this.peers.get(peer.uid) !== peer) {
535
- return;
536
- }
537
- switch (peer.pc.connectionState) {
538
- case "connected":
539
- this.clearTimers(peer);
540
- void this.detectTransport(peer);
541
- return;
542
- case "disconnected":
543
- // ICE often recovers from this on its own, so it is only given up on after a grace period.
544
- peer.disconnectTimer ??= setTimeout(() => this.fallBack(peer), this.options.disconnectedGraceMs ?? DEFAULT_DISCONNECTED_GRACE_MS);
545
- return;
546
- case "failed":
547
- this.fallBack(peer);
548
- return;
549
- case "closed":
550
- this.handleBye(peer.uid);
551
- return;
552
- }
553
- }
554
-
555
- /** Records whether a connected pair is direct, relayed through TURN, or relayed through TURN over TCP (see
556
- * `MediaTransport`'s doc comment), from the candidate pair ICE selected. */
557
- private async detectTransport(peer: PeerState): Promise<void> {
558
- let type: "p2p" | "turn" | "turn-tcp" | "unknown";
559
- try {
560
- type = await peer.pc.connectionType();
561
- } catch {
562
- type = "unknown";
563
- }
564
- // The pair may have moved on (fallen back, left, or dropped again) while the stats were being read.
565
- if (peer.transport !== "websocket" && this.peers.get(peer.uid) === peer && peer.pc.connectionState === "connected") {
566
- this.setTransport(peer, type === "turn" || type === "turn-tcp" ? type : "p2p");
567
- this.startDiagnosticsPolling(peer);
568
- }
569
- }
570
-
571
- /** Idempotent - a pair that reconnects without ever leaving WebRTC (`connected` -> `disconnected` -> `connected`
572
- * again) does not get a second timer. */
573
- private startDiagnosticsPolling(peer: PeerState): void {
574
- if (peer.diagnosticsTimer !== undefined) {
575
- return;
576
- }
577
- const pollMs = this.options.diagnosticsPollMs ?? DEFAULT_DIAGNOSTICS_POLL_MS;
578
- peer.diagnosticsTimer = setInterval(() => void this.pollDiagnostics(peer), pollMs);
579
- void this.pollDiagnostics(peer); // the first sample doesn't wait a full interval
580
- }
581
-
582
- private stopDiagnosticsPolling(peer: PeerState): void {
583
- clearInterval(peer.diagnosticsTimer);
584
- peer.diagnosticsTimer = undefined;
585
- }
586
-
587
- private async pollDiagnostics(peer: PeerState): Promise<void> {
588
- let diagnostics: MeshParticipant["diagnostics"];
589
- try {
590
- diagnostics = await peer.pc.collectDiagnostics();
591
- } catch {
592
- // Leaves whatever was last polled in place rather than blanking a momentary failure.
593
- return;
594
- }
595
- // The pair may have moved on (fallen back, left, or dropped again) while the stats were being read.
596
- if (this.peers.get(peer.uid) === peer && peer.diagnosticsTimer !== undefined) {
597
- peer.diagnostics = diagnostics;
598
- this.emit({ type: "participant-updated", participant: toParticipant(peer) });
599
- }
600
- }
601
-
602
- private setTransport(peer: PeerState, transport: MediaTransport): void {
603
- if (peer.transport !== transport) {
604
- peer.transport = transport;
605
- this.emit({ type: "participant-updated", participant: toParticipant(peer) });
606
- }
607
- }
608
-
609
- private clearTimers(peer: PeerState): void {
610
- clearTimeout(peer.connectTimer);
611
- clearTimeout(peer.disconnectTimer);
612
- peer.connectTimer = undefined;
613
- peer.disconnectTimer = undefined;
614
- }
615
-
616
- /**
617
- * Gives up on WebRTC for `peer` and moves the pair to the WebSocket relay (the third path - see this module's doc
618
- * comment), or marks it `"failed"` when there is no relay to move to. `notify` tells the other side, which may not
619
- * have noticed yet; a pair told by the other side does not tell it back.
620
- */
621
- private fallBack(peer: PeerState, notify = true): void {
622
- if (peer.transport === "websocket" || this.peers.get(peer.uid) !== peer) {
623
- return;
624
- }
625
- this.clearTimers(peer);
626
- this.stopDiagnosticsPolling(peer);
627
- peer.diagnostics = undefined;
628
- const relay = this.options.relay;
629
- if (!relay?.supported) {
630
- this.setTransport(peer, "failed");
631
- return;
632
- }
633
- // Stop listening to the dead connection before closing it, so closing it cannot be mistaken for the peer leaving.
634
- peer.pc.onconnectionstatechange = null;
635
- peer.pc.onicecandidate = null;
636
- peer.pc.ontrack = null;
637
- peer.pc.close();
638
- peer.transport = "websocket";
639
- relay.receiveFrom(peer.uid, (stream) => {
640
- peer.remoteStream = stream;
641
- this.emit({ type: "remote-stream", uid: peer.uid, stream });
642
- });
643
- this.syncRelaySending();
644
- this.emit({ type: "participant-updated", participant: toParticipant(peer) });
645
- if (notify) {
646
- this.send({ kind: "relay-fallback", to: peer.uid });
647
- }
648
- }
649
-
650
- private handleRelayFallback(from: string): void {
651
- const peer = this.peers.get(from);
652
- if (peer) {
653
- this.fallBack(peer, false);
654
- }
655
- }
656
-
657
- /** The relay only encodes and uploads the local media while at least one pair is actually using it. */
658
- private syncRelaySending(): void {
659
- this.options.relay?.setSending([...this.peers.values()].some((peer) => peer.transport === "websocket"));
660
- }
661
-
662
- private async initiateOffer(peer: PeerState): Promise<void> {
663
- const offer = await peer.pc.createOffer();
664
- await peer.pc.setLocalDescription(offer);
665
- this.send({ kind: "offer", to: peer.uid, sdp: offer });
666
- }
667
-
668
- private async handleOffer(message: SignalMessage): Promise<void> {
669
- if (!message.sdp) {
670
- return;
671
- }
672
- let peer = this.peers.get(message.from);
673
- const isNew = !peer;
674
- if (!peer) {
675
- peer = this.createPeer(message.from, message.from, undefined, false);
676
- }
677
- await peer.pc.setRemoteDescription(message.sdp);
678
- this.flushPendingCandidates(peer);
679
- if (!peer.senders.audio && !peer.senders.video) {
680
- // The answerer's half of the media setup - see this module's doc comment.
681
- peer.senders = peer.pc.claimTransceivers();
682
- for (const kind of ["audio", "video"] as const) {
683
- const track = this.localTracks[kind];
684
- if (track) {
685
- await peer.senders[kind]?.replaceTrack(track);
686
- }
687
- }
688
- }
689
- if (isNew) {
690
- this.emit({ type: "participant-joined", participant: toParticipant(peer) });
691
- this.sendHello();
692
- }
693
- const answer = await peer.pc.createAnswer();
694
- await peer.pc.setLocalDescription(answer);
695
- this.send({ kind: "answer", to: peer.uid, sdp: answer });
696
- }
697
-
698
- private async handleAnswer(message: SignalMessage): Promise<void> {
699
- const peer = this.peers.get(message.from);
700
- if (!peer || !message.sdp) {
701
- return;
702
- }
703
- await peer.pc.setRemoteDescription(message.sdp);
704
- this.flushPendingCandidates(peer);
705
- }
706
-
707
- private async handleIceCandidate(message: SignalMessage): Promise<void> {
708
- if (!message.candidate) {
709
- return;
710
- }
711
- const peer = this.peers.get(message.from);
712
- if (!peer) {
713
- // A candidate that raced ahead of its own offer (each message is a separate `POST`) - hold it for
714
- // the peer to claim when it is created. Bounded, see `MAX_ORPHAN_PEERS`.
715
- const held = this.orphanCandidates.get(message.from);
716
- if (held) {
717
- if (held.length < MAX_ORPHAN_CANDIDATES) {
718
- held.push(message.candidate);
719
- }
720
- } else if (this.orphanCandidates.size < MAX_ORPHAN_PEERS) {
721
- this.orphanCandidates.set(message.from, [message.candidate]);
722
- }
723
- return;
724
- }
725
- if (!peer.remoteDescriptionSet) {
726
- peer.pendingCandidates.push(message.candidate);
727
- return;
728
- }
729
- await peer.pc.addIceCandidate(message.candidate);
730
- }
731
-
732
- private flushPendingCandidates(peer: PeerState): void {
733
- peer.remoteDescriptionSet = true;
734
- const pending = peer.pendingCandidates;
735
- peer.pendingCandidates = [];
736
- for (const candidate of pending) {
737
- void peer.pc.addIceCandidate(candidate);
738
- }
739
- }
740
-
741
- private handlePresenterClaim(from: string): void {
742
- if (this.currentPresenterUid === undefined) {
743
- this.currentPresenterUid = from;
744
- this.emit({ type: "presenter-changed", uid: from });
745
- return;
746
- }
747
- if (this.currentPresenterUid === from) {
748
- return;
749
- }
750
- // Collision: a claim from someone other than the presenter we already recorded. Resolve deterministically
751
- // - see this module's doc comment - so every participant converges on the same winner. When the
752
- // already-recorded presenter has the smaller uid, it wins and nothing changes here - including when that
753
- // presenter is this participant itself, whose own optimistic claim simply stays in place.
754
- if (from < this.currentPresenterUid) {
755
- const loser = this.currentPresenterUid;
756
- this.currentPresenterUid = from;
757
- this.emit({ type: "presenter-changed", uid: from });
758
- if (loser === this.selfId) {
759
- this.send({ kind: "presenter-release" });
760
- }
761
- }
762
- }
763
-
764
- private handlePresenterRelease(from: string): void {
765
- if (this.currentPresenterUid === from) {
766
- this.currentPresenterUid = undefined;
767
- this.emit({ type: "presenter-changed", uid: undefined });
768
- }
769
- }
770
- }
771
-
772
- function toParticipant(peer: PeerState): MeshParticipant {
773
- return {
774
- uid: peer.uid,
775
- name: peer.name,
776
- audioOn: peer.audioOn,
777
- videoOn: peer.videoOn,
778
- handRaised: peer.handRaised,
779
- transport: peer.transport,
780
- diagnostics: peer.diagnostics,
781
- };
782
- }
783
-
784
- function sameState(a: ParticipantState, b: ParticipantState): boolean {
785
- return a.audioOn === b.audioOn && a.videoOn === b.videoOn && a.handRaised === b.handRaised;
786
- }
787
-
788
- /** Copies only the three booleans out of a received state, coerced - a message off the wire is untrusted. */
789
- function sanitizeState(state: ParticipantState): ParticipantState {
790
- return { audioOn: state.audioOn === true, videoOn: state.videoOn === true, handRaised: state.handRaised === true };
791
- }
792
-
793
- function isReactionEmoji(emoji: unknown): emoji is (typeof REACTION_EMOJIS)[number] {
794
- return (REACTION_EMOJIS as readonly unknown[]).includes(emoji);
795
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
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
8
+ * `.claude/NOTES.md` entry lays out. Pure state machine, no JSX/DOM rendering - `apps/meet/_CallView.tsx` owns one
9
+ * instance per call and mirrors its events into React state.
10
+ *
11
+ * ## Who calls whom
12
+ *
13
+ * On learning of another participant (via `hello`), a peer connection for that pair is created by whichever side
14
+ * has the lexicographically smaller uid (`isOfferer()`) - a well-known, deterministic, race-free rule for a mesh:
15
+ * both sides independently compute the same answer with no coordination round trip, and exactly one side ever
16
+ * calls `createOffer()` for a given pair.
17
+ *
18
+ * ## Media: two permanent transceivers, tracks swapped in and out
19
+ *
20
+ * Every connection carries one send-and-receive audio transceiver and one send-and-receive video transceiver,
21
+ * whether or not the local participant has a microphone or camera to send yet. The offerer adds them before its
22
+ * offer; the answerer takes the ones the offer created (`claimTransceivers()`) - a browser does not match an offer's
23
+ * m-lines to transceivers the answerer added itself, so adding them up front on both sides leaves the answerer
24
+ * sending nothing at all. A participant who declined permission, has no camera, or turns the camera on halfway
25
+ * through therefore still negotiates both directions from the first offer, and later `setLocalTrack()` calls just
26
+ * `replaceTrack()` on the existing senders - never a renegotiation. (Attaching tracks once at connection creation,
27
+ * as this manager used to, meant a participant with nothing to send at that moment negotiated no media in either
28
+ * direction, for the whole call.)
29
+ * The remote side's tracks are collected into one `MediaStream` per peer (`remote-stream` events), rather than
30
+ * read from `event.streams[0]`: a transceiver whose sender has no track yet announces no stream to the far end.
31
+ *
32
+ * ## Join announcement and roster discovery
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
38
+ * genuinely new peer (from any message, not just `hello`) therefore echoes its own `hello` right back, once, the
39
+ * first time it becomes aware of that peer - so within one extra round trip everyone converges on the same full
40
+ * roster regardless of join order. This is naturally bounded (each participant echoes at most once per peer it
41
+ * ever discovers) rather than a risk of runaway flooding.
42
+ *
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
45
+ * `hello` then replaces (`participant-updated`), and an ICE candidate that beats its offer is held until the peer
46
+ * exists rather than dropped.
47
+ *
48
+ * ## Participant state, hands and reactions
49
+ *
50
+ * What each participant is sending (`audioOn`/`videoOn`) and whether their hand is up travels in `hello` and in a
51
+ * `state` message on every change (`setLocalState()`), because a remote track gives no dependable signal that its
52
+ * sender stopped. `reaction` messages carry one emoji from `REACTION_EMOJIS`; anything else is ignored.
53
+ *
54
+ * ## Presenter (single-writer screen share)
55
+ *
56
+ * `presenter-claim` is only ever sent locally when `presenterUid` is unset (`claimPresenter()` refuses otherwise).
57
+ * A claim is applied optimistically and locally at once; every recipient (including the claimant) applies the
58
+ * *first* claim it sees. If two claims genuinely race (both sent before either claimant heard the other's), every
59
+ * participant converges on the same winner by resolving the collision deterministically: the lexicographically
60
+ * smaller uid wins, and the losing claimant self-revokes (stops its own share locally and sends
61
+ * `presenter-release`) once it observes the winning claim. See `handlePresenterClaim()`.
62
+ *
63
+ * Sharing a screen is `setLocalTrack("video", screenTrack)`; stopping is `setLocalTrack("video", cameraTrack)`.
64
+ *
65
+ * ## Host moderation (`mute-request`, `kicked`)
66
+ *
67
+ * `sendMuteRequest()`/`sendKick()` send a point-to-point signal to one peer; the manager itself does not decide who
68
+ * may send one - a "host" identity exists only in `_CallView.tsx`, which shows the controls that call these
69
+ * methods to nobody but the host (see `types.ts`'s `SignalMessage` doc comment for why this is cooperative, not
70
+ * enforced, at the signaling layer). Receiving one emits `mute-requested`/`kicked` for the caller to act on; this
71
+ * manager takes no action of its own beyond that - it doesn't mute a track or tear itself down, since a kicked
72
+ * participant's actual departure is `stop()`, which the caller decides to call.
73
+ *
74
+ * ## Talking stick (`talking-stick`)
75
+ *
76
+ * One exclusive "floor" - at most one participant may be unmuted at a time, host-assigned - but unlike presenter,
77
+ * there is no claim race to resolve: only the host's own client ever calls `setTalkingStick()` (same trust level as
78
+ * `sendMuteRequest()`/`sendKick()` - nothing here checks that), so the manager carries no state of its own and
79
+ * simply relays the latest message it sees as `talking-stick-changed`, applying it to itself too via an immediate
80
+ * local emit (the same "optimistic, no round trip" shape `claimPresenter()`/`releasePresenter()` use). A holder who
81
+ * leaves the call is not specially reassigned - every other participant's existing `participant-left` handling
82
+ * already drops them from the roster, and "the stick's holder is no longer in the call" is simply "nobody is holder
83
+ * right now" without any manager needing to say so, until the host picks someone (or themselves) again. There is no
84
+ * backend route or persisted field for this at all: like `mute-request`/`kicked`, it only matters while people are
85
+ * actually in the call together.
86
+ *
87
+ * ## Three media paths, tried in order
88
+ *
89
+ * Each pair of participants reaches for the best path that works, and `MeshParticipant.transport` says which one it
90
+ * got:
91
+ *
92
+ * 1. **Direct peer-to-peer** (`"p2p"`). The `RTCPeerConnection` is given the STUN servers and the TURN server
93
+ * together, and ICE ranks a direct pair above a relayed one, so this is what a pair gets whenever it can.
94
+ * 2. **Relayed through the TURN server** (`"turn"`). The same connection, on the relay candidate ICE falls back to
95
+ * when the direct pairs fail - a participant behind a symmetric NAT or a firewall that blocks direct UDP. Which of
96
+ * the two ICE picked is read from the selected candidate pair once connected (`RTCPeerConnectionLike.connectionType()`).
97
+ * 3. **Proxied by this server over a WebSocket** (`"websocket"`, `apps/shared/relay/`). For a participant whose
98
+ * network lets nothing but ordinary HTTPS out, neither of the above ever connects. A pair whose connection has not
99
+ * come up within `connectTimeoutMs`, has failed, or has stayed `disconnected` for `disconnectedGraceMs` gives up
100
+ * on WebRTC and switches to it, telling the other side with a `relay-fallback` message so both switch even when
101
+ * only one of them noticed. Nothing is renegotiated - the pair simply stops using the `RTCPeerConnection` - and the
102
+ * relay's stream replaces the one WebRTC would have filled (a `remote-stream` event again). When the relay is
103
+ * unavailable (turned off by the operator, or a browser without WebCodecs) the pair is marked `"failed"` and stays
104
+ * in the roster, rather than the participant vanishing with no explanation as it used to.
105
+ *
106
+ * A pair never moves back up: once on the relay it stays there for the rest of the call - except that the whole
107
+ * waterfall is itself only what `"auto"` mode does; see the next section.
108
+ *
109
+ * ## Forcing a transport (`setTransportMode()`)
110
+ *
111
+ * `TransportMode` (`types.ts`) lets a participant override the waterfall above for their own tab: `"p2p"` offers
112
+ * ICE no TURN servers at all and never falls back past a failed direct attempt; `"relay"` sets
113
+ * `RTCPeerConnectionLike`'s `iceTransportPolicy: "relay"` (still real WebRTC, just never direct) and likewise never
114
+ * falls back further; `"websocket"` skips WebRTC negotiation entirely and jumps straight to the server relay for
115
+ * every peer, via the very same `fallBack()` the `"auto"` waterfall uses once it gives up (same cooperative
116
+ * `relay-fallback` notification, same "no relay available -> `failed`" handling - `"websocket"` mode just calls it
117
+ * immediately instead of after a timeout). `"p2p"`/`"relay"` skip the waterfall in the other direction:
118
+ * `giveUpOnWebRTC()` is what `createPeer()`'s connect timeout and `handleConnectionState()`'s `disconnected`/
119
+ * `failed` branches call instead of `fallBack()` directly, and it marks the pair `"failed"` outright in forced
120
+ * `"p2p"`/`"relay"` mode rather than degrading to the relay - a participant who explicitly chose "peer-to-peer
121
+ * only" or "TURN relay only" to troubleshoot a connection does not want it quietly becoming something else.
122
+ *
123
+ * Switching modes mid-call (not just at join) rebuilds every current peer connection under the new policy
124
+ * (`restartPeer()`): the old `RTCPeerConnection` is closed and a fresh one created in its place, exactly as
125
+ * `createPeer()` builds one for a brand-new peer, but keeping the existing roster entry (name/audioOn/videoOn/
126
+ * handRaised) and emitting `participant-updated` rather than a `participant-left`/`participant-joined` pair - from
127
+ * the UI's perspective this looks like the existing `"connecting"` state any reconnect already uses, not someone
128
+ * leaving and rejoining. This can't be done unilaterally: a participant cannot rebuild their own half of a pair
129
+ * without the other side rebuilding its matching half at (almost) the same moment, or the two ends negotiate
130
+ * against two different objects that have no idea about each other. `setTransportMode()` therefore also broadcasts
131
+ * `restart-connection` (no payload beyond the envelope - `types.ts`'s `SignalMessage` doc comment), and every
132
+ * recipient calls `restartPeer()` for just that one sender's connection in response - each side always rebuilds
133
+ * using whatever mode *it* currently has configured, never the sender's, since this is a personal, per-tab choice,
134
+ * not something a meeting agrees on together.
135
+ */
136
+ import {
137
+ type MediaTransport,
138
+ type MeshEvent,
139
+ type MeshParticipant,
140
+ type ParticipantState,
141
+ type RTCPeerConnectionFactory,
142
+ type RTCPeerConnectionLike,
143
+ type RTCRtpSenderLike,
144
+ type RelayTransportLike,
145
+ type SignalMessage,
146
+ type SignalingChannel,
147
+ type TransportMode,
148
+ REACTION_EMOJIS,
149
+ } from "./types.js";
150
+
151
+ /** The lexicographically smaller uid is always the offerer for that pair - see this module's doc comment. */
152
+ export function isOfferer(selfId: string, peerId: string): boolean {
153
+ return selfId < peerId;
154
+ }
155
+
156
+ /** How many peers' worth of early ICE candidates are held, and how many candidates per peer - bounds the memory a
157
+ * misbehaving participant could make this hold for peers that never materialize. */
158
+ const MAX_ORPHAN_PEERS = 32;
159
+ const MAX_ORPHAN_CANDIDATES = 64;
160
+
161
+ /** How long a connection may stay in `connecting` before the pair gives up on WebRTC. ICE with an unreachable TURN
162
+ * server can take the better part of a minute to report `failed`; a person waiting on a call will not. It is still long
163
+ * enough for a slow TURN allocation and a few round trips of signaling. */
164
+ export const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
165
+
166
+ /** How long a connection may stay `disconnected` (ICE lost the path but may still recover) before giving up on it. */
167
+ export const DEFAULT_DISCONNECTED_GRACE_MS = 8_000;
168
+
169
+ /** How often a connected pair's quality stats (the diagnostics panel) are refreshed - low frequency on purpose:
170
+ * this is for a participant occasionally checking why a call feels off, not a live chart, and `getStats()` is not
171
+ * free to call every frame. */
172
+ export const DEFAULT_DIAGNOSTICS_POLL_MS = 3_000;
173
+
174
+ type MediaKind = "audio" | "video";
175
+
176
+ interface PeerState extends MeshParticipant {
177
+ pc: RTCPeerConnectionLike;
178
+ /** Empty on an answerer until the offer arrives - see `handleOffer()`. */
179
+ senders: Partial<Record<MediaKind, RTCRtpSenderLike>>;
180
+ remoteStream: MediaStream;
181
+ remoteDescriptionSet: boolean;
182
+ pendingCandidates: RTCIceCandidateInit[];
183
+ /** Gives up on WebRTC when the connection is still `connecting` after `connectTimeoutMs`. */
184
+ connectTimer: ReturnType<typeof setTimeout> | undefined;
185
+ /** Gives up on WebRTC when the connection has stayed `disconnected` for `disconnectedGraceMs`. */
186
+ disconnectTimer: ReturnType<typeof setTimeout> | undefined;
187
+ /** Refreshes `diagnostics` (`MeshParticipant`) while this pair is on a real `RTCPeerConnection` - started once
188
+ * it first connects, stopped the moment it leaves WebRTC for the relay or the pair ends. */
189
+ diagnosticsTimer: ReturnType<typeof setInterval> | undefined;
190
+ }
191
+
192
+ export interface MeshConnectionManagerOptions {
193
+ /** The authenticated uid - what `from` must be on the wire (see `SignalMessage.from`). */
194
+ selfUid: string;
195
+ /** This tab's identity in the call, `<selfUid>~<random>` - defaults to `selfUid`. Everything the manager keys on
196
+ * (the roster, who offers, who presents) is a peer id, so two tabs of one account are two participants. */
197
+ peerId?: string;
198
+ selfName: string;
199
+ iceServers: RTCIceServer[];
200
+ channel: SignalingChannel;
201
+ createPeerConnection: RTCPeerConnectionFactory;
202
+ /** The tracks to send from the start - either may be absent (no permission, no device) and supplied or
203
+ * replaced later with `setLocalTrack()`. */
204
+ localAudioTrack?: MediaStreamTrack | null;
205
+ localVideoTrack?: MediaStreamTrack | null;
206
+ /** What to announce at first - defaults to "sending whatever tracks were given, hand down". */
207
+ localState?: Partial<ParticipantState>;
208
+ /** Builds the (initially empty) stream one peer's incoming tracks are gathered into. Defaults to
209
+ * `new MediaStream()`; a test supplies a fake. */
210
+ createMediaStream?: () => MediaStream;
211
+ /** The WebSocket media relay, the last-resort path - see this module's doc comment. Leave unset when the
212
+ * operator has turned it off; without one a pair WebRTC cannot connect is marked `"failed"`. */
213
+ relay?: RelayTransportLike;
214
+ /** Defaults to `DEFAULT_CONNECT_TIMEOUT_MS`. */
215
+ connectTimeoutMs?: number;
216
+ /** Defaults to `DEFAULT_DISCONNECTED_GRACE_MS`. */
217
+ disconnectedGraceMs?: number;
218
+ /** Defaults to `DEFAULT_DIAGNOSTICS_POLL_MS`. */
219
+ diagnosticsPollMs?: number;
220
+ /** Defaults to `"auto"` - see this module's doc comment on `setTransportMode()`. */
221
+ transportMode?: TransportMode;
222
+ }
223
+
224
+ export class MeshConnectionManager {
225
+ private readonly peers = new Map<string, PeerState>();
226
+ private readonly listeners = new Set<(event: MeshEvent) => void>();
227
+ private readonly orphanCandidates = new Map<string, RTCIceCandidateInit[]>();
228
+ private readonly localTracks: Record<MediaKind, MediaStreamTrack | null>;
229
+ private localState: ParticipantState;
230
+ private unsubscribe: (() => void) | undefined;
231
+ private started = false;
232
+ private stopped = false;
233
+ private currentPresenterUid: string | undefined;
234
+ private currentTransportMode: TransportMode;
235
+ private readonly selfId: string;
236
+
237
+ constructor(private readonly options: MeshConnectionManagerOptions) {
238
+ this.selfId = options.peerId ?? options.selfUid;
239
+ this.currentTransportMode = options.transportMode ?? "auto";
240
+ this.localTracks = { audio: options.localAudioTrack ?? null, video: options.localVideoTrack ?? null };
241
+ this.localState = {
242
+ audioOn: !!options.localAudioTrack,
243
+ videoOn: !!options.localVideoTrack,
244
+ handRaised: false,
245
+ ...options.localState,
246
+ };
247
+ options.relay?.setLocalTrack("audio", this.localTracks.audio);
248
+ options.relay?.setLocalTrack("video", this.localTracks.video);
249
+ }
250
+
251
+ get participants(): MeshParticipant[] {
252
+ return [...this.peers.values()].map((peer) => toParticipant(peer));
253
+ }
254
+
255
+ get presenterUid(): string | undefined {
256
+ return this.currentPresenterUid;
257
+ }
258
+
259
+ get transportMode(): TransportMode {
260
+ return this.currentTransportMode;
261
+ }
262
+
263
+ onEvent(listener: (event: MeshEvent) => void): () => void {
264
+ this.listeners.add(listener);
265
+ return () => this.listeners.delete(listener);
266
+ }
267
+
268
+ /** Subscribes to the channel and announces this participant. Idempotent - a second call is a no-op. */
269
+ start(): void {
270
+ if (this.started) {
271
+ return;
272
+ }
273
+ this.started = true;
274
+ this.unsubscribe = this.options.channel.onMessage((message) => this.handleMessage(message));
275
+ this.sendHello();
276
+ }
277
+
278
+ /** Announces departure, closes every peer connection and unsubscribes - idempotent, and safe to call whether
279
+ * or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping the local
280
+ * tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns their
281
+ * lifecycle, only sends them. */
282
+ stop(): void {
283
+ if (this.stopped) {
284
+ return;
285
+ }
286
+ this.stopped = true;
287
+ if (this.started) {
288
+ this.send({ kind: "bye" });
289
+ }
290
+ this.unsubscribe?.();
291
+ this.unsubscribe = undefined;
292
+ for (const peer of this.peers.values()) {
293
+ this.clearTimers(peer);
294
+ this.stopDiagnosticsPolling(peer);
295
+ peer.pc.close();
296
+ }
297
+ this.peers.clear();
298
+ this.orphanCandidates.clear();
299
+ this.listeners.clear();
300
+ this.options.relay?.close();
301
+ }
302
+
303
+ /** Sets the track sent as `kind` (`null` to send nothing) on every connection, now and for every peer that
304
+ * joins later - see this module's doc comment. */
305
+ setLocalTrack(kind: MediaKind, track: MediaStreamTrack | null): void {
306
+ this.localTracks[kind] = track;
307
+ for (const peer of this.peers.values()) {
308
+ void peer.senders[kind]?.replaceTrack(track);
309
+ }
310
+ this.options.relay?.setLocalTrack(kind, track);
311
+ }
312
+
313
+ /** Updates what this participant announces (`audioOn`/`videoOn`/`handRaised`) and tells everyone if anything
314
+ * actually changed. Before `start()` it only records the state, which `hello` then carries. */
315
+ setLocalState(partial: Partial<ParticipantState>): void {
316
+ const next = { ...this.localState, ...partial };
317
+ if (sameState(next, this.localState)) {
318
+ return;
319
+ }
320
+ this.localState = next;
321
+ if (this.started && !this.stopped) {
322
+ this.send({ kind: "state", state: next });
323
+ }
324
+ }
325
+
326
+ /** Sends one emoji to everyone. Returns `false`, sending nothing, for an emoji outside `REACTION_EMOJIS`. */
327
+ sendReaction(emoji: string): boolean {
328
+ if (!isReactionEmoji(emoji)) {
329
+ return false;
330
+ }
331
+ this.send({ kind: "reaction", emoji });
332
+ return true;
333
+ }
334
+
335
+ /** Claims presenter status for the local participant. Refuses (returns `false`, sends nothing) when someone
336
+ * else already presents - the caller (`_CallView.tsx`) uses this to disable its own "share screen" control
337
+ * rather than let a claim silently do nothing. */
338
+ claimPresenter(): boolean {
339
+ if (this.currentPresenterUid !== undefined && this.currentPresenterUid !== this.selfId) {
340
+ return false;
341
+ }
342
+ this.currentPresenterUid = this.selfId;
343
+ this.send({ kind: "presenter-claim" });
344
+ this.emit({ type: "presenter-changed", uid: this.currentPresenterUid });
345
+ return true;
346
+ }
347
+
348
+ /** Releases presenter status - a no-op unless the local participant currently holds it. */
349
+ releasePresenter(): void {
350
+ if (this.currentPresenterUid !== this.selfId) {
351
+ return;
352
+ }
353
+ this.currentPresenterUid = undefined;
354
+ this.send({ kind: "presenter-release" });
355
+ this.emit({ type: "presenter-changed", uid: undefined });
356
+ }
357
+
358
+ /** Asks `peerUid`'s participant to mute - a cooperative signal (see this module's doc comment on host
359
+ * moderation), not enforcement. Nothing here checks that the local participant is actually the host; the caller
360
+ * is responsible for only offering this to one. */
361
+ sendMuteRequest(peerUid: string): void {
362
+ this.send({ kind: "mute-request", to: peerUid });
363
+ }
364
+
365
+ /** Tells `peerUid`'s participant they have been removed from the call. Sends only the cooperative signal - it
366
+ * does not itself revoke `peerUid`'s server-side channel grant (see `BaseVideoMeetingRoute.revokeChannelGrant()`,
367
+ * which the caller is expected to call alongside this for an enforced removal). */
368
+ sendKick(peerUid: string): void {
369
+ this.send({ kind: "kicked", to: peerUid });
370
+ }
371
+
372
+ /** Turns talking-stick mode on (naming `holder`, the initial speaker) or off (`active: false`, `holder`
373
+ * omitted), or hands an already-active stick to a different `holder` - see this module's doc comment on why
374
+ * there is no collision to resolve here, unlike presenter. Broadcasts and applies locally at once, trusting
375
+ * there is only ever one sender; the caller (`_CallView.tsx`) is responsible for only offering this to the
376
+ * host. */
377
+ setTalkingStick(active: boolean, holder?: string): void {
378
+ this.send({ kind: "talking-stick", active, holder });
379
+ this.emit({ type: "talking-stick-changed", active, holder });
380
+ }
381
+
382
+ /** Switches this tab's own transport policy - see this module's doc comment on `TransportMode`. A no-op for the
383
+ * mode it already has. Rebuilds every current peer connection under the new policy and, once `start()` has
384
+ * run, tells every peer to rebuild their matching half too (`restart-connection`) - see `restartPeer()`. */
385
+ setTransportMode(mode: TransportMode): void {
386
+ if (this.currentTransportMode === mode) {
387
+ return;
388
+ }
389
+ this.currentTransportMode = mode;
390
+ if (this.started && !this.stopped) {
391
+ this.send({ kind: "restart-connection" });
392
+ }
393
+ for (const uid of [...this.peers.keys()]) {
394
+ this.restartPeer(uid);
395
+ }
396
+ }
397
+
398
+ private sendHello(): void {
399
+ this.send({ kind: "hello", name: this.options.selfName, state: this.localState });
400
+ }
401
+
402
+ private send(partial: Omit<SignalMessage, "type" | "from">): void {
403
+ this.options.channel.send({ type: "video-meeting-signal", from: this.options.selfUid, peer: this.selfId, ...partial });
404
+ }
405
+
406
+ private emit(event: MeshEvent): void {
407
+ for (const listener of [...this.listeners]) {
408
+ listener(event);
409
+ }
410
+ }
411
+
412
+ private handleMessage(raw: SignalMessage): void {
413
+ if (raw.type !== "video-meeting-signal") {
414
+ return;
415
+ }
416
+ // From here on `from` is the sender's peer id - see `SignalMessage.peer`.
417
+ if (raw.peer !== undefined && raw.peer !== raw.from && !raw.peer.startsWith(`${raw.from}~`)) {
418
+ return;
419
+ }
420
+ const message: SignalMessage = { ...raw, from: raw.peer ?? raw.from };
421
+ if (message.from === this.selfId) {
422
+ return;
423
+ }
424
+ if (message.to !== undefined && message.to !== this.selfId) {
425
+ return;
426
+ }
427
+ switch (message.kind) {
428
+ case "hello":
429
+ this.handleHello(message);
430
+ return;
431
+ case "bye":
432
+ this.handleBye(message.from);
433
+ return;
434
+ case "offer":
435
+ void this.handleOffer(message);
436
+ return;
437
+ case "answer":
438
+ void this.handleAnswer(message);
439
+ return;
440
+ case "ice-candidate":
441
+ void this.handleIceCandidate(message);
442
+ return;
443
+ case "presenter-claim":
444
+ this.handlePresenterClaim(message.from);
445
+ return;
446
+ case "presenter-release":
447
+ this.handlePresenterRelease(message.from);
448
+ return;
449
+ case "state":
450
+ this.handleState(message);
451
+ return;
452
+ case "reaction":
453
+ this.handleReaction(message);
454
+ return;
455
+ case "relay-fallback":
456
+ this.handleRelayFallback(message.from);
457
+ return;
458
+ case "mute-request":
459
+ this.emit({ type: "mute-requested" });
460
+ return;
461
+ case "kicked":
462
+ this.emit({ type: "kicked" });
463
+ return;
464
+ case "talking-stick":
465
+ this.emit({ type: "talking-stick-changed", active: message.active === true, holder: typeof message.holder === "string" ? message.holder : undefined });
466
+ return;
467
+ case "restart-connection":
468
+ this.restartPeer(message.from);
469
+ return;
470
+ }
471
+ }
472
+
473
+ private handleHello(message: SignalMessage): void {
474
+ const known = this.peers.get(message.from);
475
+ if (known) {
476
+ // An offer that beat this hello created the peer under a placeholder name - fill in the real one.
477
+ this.updatePeer(known, message.name, message.state);
478
+ return;
479
+ }
480
+ const offerer = isOfferer(this.selfId, message.from);
481
+ const peer = this.createPeer(message.from, message.name ?? message.from, message.state, offerer);
482
+ this.emit({ type: "participant-joined", participant: toParticipant(peer) });
483
+ // Let a newcomer who couldn't have seen our own original `hello` learn about us too - see this module's
484
+ // doc comment on roster discovery.
485
+ this.sendHello();
486
+ if (this.currentTransportMode === "websocket") {
487
+ // Forced relay-only: don't negotiate WebRTC at all - see this module's doc comment on `setTransportMode()`.
488
+ this.fallBack(peer, true);
489
+ return;
490
+ }
491
+ if (offerer) {
492
+ void this.initiateOffer(peer);
493
+ }
494
+ }
495
+
496
+ private handleState(message: SignalMessage): void {
497
+ const peer = this.peers.get(message.from);
498
+ if (peer) {
499
+ this.updatePeer(peer, undefined, message.state);
500
+ }
501
+ }
502
+
503
+ private handleReaction(message: SignalMessage): void {
504
+ const peer = this.peers.get(message.from);
505
+ if (peer && isReactionEmoji(message.emoji)) {
506
+ this.emit({ type: "reaction", uid: peer.uid, name: peer.name, emoji: message.emoji });
507
+ }
508
+ }
509
+
510
+ /** Applies a newly learned name and/or state to `peer`, emitting `participant-updated` (and `hand-raised` on a
511
+ * hand going up) only when something actually changed. */
512
+ private updatePeer(peer: PeerState, name: string | undefined, state: ParticipantState | undefined): void {
513
+ const nextName = name ?? peer.name;
514
+ const next = state ? sanitizeState(state) : undefined;
515
+ const handWentUp = !!next && next.handRaised && !peer.handRaised;
516
+ if (nextName === peer.name && (!next || sameState(next, peer))) {
517
+ return;
518
+ }
519
+ peer.name = nextName;
520
+ if (next) {
521
+ peer.audioOn = next.audioOn;
522
+ peer.videoOn = next.videoOn;
523
+ peer.handRaised = next.handRaised;
524
+ }
525
+ this.emit({ type: "participant-updated", participant: toParticipant(peer) });
526
+ if (handWentUp) {
527
+ this.emit({ type: "hand-raised", uid: peer.uid, name: peer.name });
528
+ }
529
+ }
530
+
531
+ private handleBye(uid: string): void {
532
+ this.orphanCandidates.delete(uid);
533
+ const peer = this.peers.get(uid);
534
+ if (!peer) {
535
+ return;
536
+ }
537
+ this.clearTimers(peer);
538
+ this.stopDiagnosticsPolling(peer);
539
+ peer.pc.close();
540
+ this.peers.delete(uid);
541
+ if (peer.transport === "websocket") {
542
+ this.options.relay?.stopReceivingFrom(uid);
543
+ this.syncRelaySending();
544
+ }
545
+ this.emit({ type: "participant-left", uid });
546
+ if (this.currentPresenterUid === uid) {
547
+ this.currentPresenterUid = undefined;
548
+ this.emit({ type: "presenter-changed", uid: undefined });
549
+ }
550
+ }
551
+
552
+ private createPeer(uid: string, name: string, state: ParticipantState | undefined, offerer: boolean): PeerState {
553
+ const pc = this.options.createPeerConnection(this.pcConfig());
554
+ const peer: PeerState = {
555
+ uid,
556
+ name,
557
+ ...(state ? sanitizeState(state) : { audioOn: false, videoOn: false, handRaised: false }),
558
+ pc,
559
+ senders: offerer
560
+ ? {
561
+ audio: pc.addTransceiver("audio", this.localTracks.audio).sender,
562
+ video: pc.addTransceiver("video", this.localTracks.video).sender,
563
+ }
564
+ : {},
565
+ remoteStream: (this.options.createMediaStream ?? (() => new MediaStream()))(),
566
+ remoteDescriptionSet: false,
567
+ pendingCandidates: this.orphanCandidates.get(uid) ?? [],
568
+ transport: "connecting",
569
+ connectTimer: undefined,
570
+ disconnectTimer: undefined,
571
+ diagnosticsTimer: undefined,
572
+ };
573
+ this.orphanCandidates.delete(uid);
574
+ this.peers.set(uid, peer);
575
+ pc.onicecandidate = (event) => {
576
+ if (event.candidate) {
577
+ this.send({ kind: "ice-candidate", to: uid, candidate: event.candidate });
578
+ }
579
+ };
580
+ pc.ontrack = (event) => {
581
+ if (!peer.remoteStream.getTracks().includes(event.track)) {
582
+ peer.remoteStream.addTrack(event.track);
583
+ }
584
+ this.emit({ type: "remote-stream", uid, stream: peer.remoteStream });
585
+ };
586
+ pc.onconnectionstatechange = () => this.handleConnectionState(peer);
587
+ // Cleared the moment the connection comes up, so this only ever fires for a pair still waiting on WebRTC.
588
+ peer.connectTimer = setTimeout(() => this.giveUpOnWebRTC(peer), this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
589
+ return peer;
590
+ }
591
+
592
+ /** The `RTCPeerConnection` config for a peer created right now - `this.options.iceServers` as given for `"auto"`
593
+ * and `"websocket"` (the latter never actually uses them - see `handleHello()`/`handleOffer()`), STUN-only (no
594
+ * TURN offered to ICE at all) for `"p2p"`, and the same servers with `iceTransportPolicy: "relay"` for
595
+ * `"relay"` - see this module's doc comment on `TransportMode`. */
596
+ private pcConfig(): { iceServers: RTCIceServer[]; iceTransportPolicy?: "all" | "relay" } {
597
+ switch (this.currentTransportMode) {
598
+ case "p2p":
599
+ return { iceServers: stunOnlyServers(this.options.iceServers) };
600
+ case "relay":
601
+ return { iceServers: this.options.iceServers, iceTransportPolicy: "relay" };
602
+ default:
603
+ return { iceServers: this.options.iceServers };
604
+ }
605
+ }
606
+
607
+ /** Reacts to the peer connection's state: notes which path a connected pair got, and gives up on WebRTC for a
608
+ * pair that failed or stays disconnected - see `fallBack()`. Ignores a pair that already left WebRTC. */
609
+ private handleConnectionState(peer: PeerState): void {
610
+ if (peer.transport === "websocket" || this.peers.get(peer.uid) !== peer) {
611
+ return;
612
+ }
613
+ switch (peer.pc.connectionState) {
614
+ case "connected":
615
+ this.clearTimers(peer);
616
+ void this.detectTransport(peer);
617
+ return;
618
+ case "disconnected":
619
+ // ICE often recovers from this on its own, so it is only given up on after a grace period.
620
+ peer.disconnectTimer ??= setTimeout(() => this.giveUpOnWebRTC(peer), this.options.disconnectedGraceMs ?? DEFAULT_DISCONNECTED_GRACE_MS);
621
+ return;
622
+ case "failed":
623
+ this.giveUpOnWebRTC(peer);
624
+ return;
625
+ case "closed":
626
+ this.handleBye(peer.uid);
627
+ return;
628
+ }
629
+ }
630
+
631
+ /** Records whether a connected pair is direct, relayed through TURN, or relayed through TURN over TCP (see
632
+ * `MediaTransport`'s doc comment), from the candidate pair ICE selected. */
633
+ private async detectTransport(peer: PeerState): Promise<void> {
634
+ let type: "p2p" | "turn" | "turn-tcp" | "unknown";
635
+ try {
636
+ type = await peer.pc.connectionType();
637
+ } catch {
638
+ type = "unknown";
639
+ }
640
+ // The pair may have moved on (fallen back, left, or dropped again) while the stats were being read.
641
+ if (peer.transport !== "websocket" && this.peers.get(peer.uid) === peer && peer.pc.connectionState === "connected") {
642
+ this.setTransport(peer, type === "turn" || type === "turn-tcp" ? type : "p2p");
643
+ this.startDiagnosticsPolling(peer);
644
+ }
645
+ }
646
+
647
+ /** Idempotent - a pair that reconnects without ever leaving WebRTC (`connected` -> `disconnected` -> `connected`
648
+ * again) does not get a second timer. */
649
+ private startDiagnosticsPolling(peer: PeerState): void {
650
+ if (peer.diagnosticsTimer !== undefined) {
651
+ return;
652
+ }
653
+ const pollMs = this.options.diagnosticsPollMs ?? DEFAULT_DIAGNOSTICS_POLL_MS;
654
+ peer.diagnosticsTimer = setInterval(() => void this.pollDiagnostics(peer), pollMs);
655
+ void this.pollDiagnostics(peer); // the first sample doesn't wait a full interval
656
+ }
657
+
658
+ private stopDiagnosticsPolling(peer: PeerState): void {
659
+ clearInterval(peer.diagnosticsTimer);
660
+ peer.diagnosticsTimer = undefined;
661
+ }
662
+
663
+ private async pollDiagnostics(peer: PeerState): Promise<void> {
664
+ let diagnostics: MeshParticipant["diagnostics"];
665
+ try {
666
+ diagnostics = await peer.pc.collectDiagnostics();
667
+ } catch {
668
+ // Leaves whatever was last polled in place rather than blanking a momentary failure.
669
+ return;
670
+ }
671
+ // The pair may have moved on (fallen back, left, or dropped again) while the stats were being read.
672
+ if (this.peers.get(peer.uid) === peer && peer.diagnosticsTimer !== undefined) {
673
+ peer.diagnostics = diagnostics;
674
+ this.emit({ type: "participant-updated", participant: toParticipant(peer) });
675
+ }
676
+ }
677
+
678
+ private setTransport(peer: PeerState, transport: MediaTransport): void {
679
+ if (peer.transport !== transport) {
680
+ peer.transport = transport;
681
+ this.emit({ type: "participant-updated", participant: toParticipant(peer) });
682
+ }
683
+ }
684
+
685
+ private clearTimers(peer: PeerState): void {
686
+ clearTimeout(peer.connectTimer);
687
+ clearTimeout(peer.disconnectTimer);
688
+ peer.connectTimer = undefined;
689
+ peer.disconnectTimer = undefined;
690
+ }
691
+
692
+ /** What a connect timeout or a `"disconnected"`/`"failed"` connection state means for `peer`: `fallBack()` to
693
+ * the relay for `"auto"` mode (unchanged from before `TransportMode` existed), but `"failed"` outright for a
694
+ * forced `"p2p"`/`"relay"` mode - see this module's doc comment on why those two never degrade further.
695
+ * `"websocket"` mode never reaches here at all: `handleHello()`/`handleOffer()` call `fallBack()` immediately
696
+ * on peer creation, well before a connect timeout could ever fire. */
697
+ private giveUpOnWebRTC(peer: PeerState): void {
698
+ if (this.currentTransportMode !== "p2p" && this.currentTransportMode !== "relay") {
699
+ this.fallBack(peer);
700
+ return;
701
+ }
702
+ /* v8 ignore if -- unreachable via real usage: unlike `fallBack()` (also callable directly from
703
+ `handleRelayFallback()`, which is how its own identical-looking guard is actually exercised),
704
+ `giveUpOnWebRTC()`'s only callers are `createPeer()`'s connect timer and `handleConnectionState()`'s
705
+ disconnect timer/`"failed"` case - and every transition that could make either side of this true
706
+ (`fallBack()` moving a peer to `"websocket"`, `restartPeer()` replacing the map entry) clears this
707
+ peer's pending timers first, so a stale timer referencing this exact peer object never fires. Kept for
708
+ the same reason `fallBack()`'s guard is - a future caller that doesn't pre-clear timers should fail
709
+ safe, not corrupt an already-superseded peer. */
710
+ if (peer.transport === "websocket" || this.peers.get(peer.uid) !== peer) {
711
+ return;
712
+ }
713
+ this.clearTimers(peer);
714
+ this.stopDiagnosticsPolling(peer);
715
+ this.setTransport(peer, "failed");
716
+ }
717
+
718
+ /**
719
+ * Gives up on WebRTC for `peer` and moves the pair to the WebSocket relay (the third path - see this module's doc
720
+ * comment), or marks it `"failed"` when there is no relay to move to. `notify` tells the other side, which may not
721
+ * have noticed yet; a pair told by the other side does not tell it back.
722
+ */
723
+ private fallBack(peer: PeerState, notify = true): void {
724
+ if (peer.transport === "websocket" || this.peers.get(peer.uid) !== peer) {
725
+ return;
726
+ }
727
+ this.clearTimers(peer);
728
+ this.stopDiagnosticsPolling(peer);
729
+ peer.diagnostics = undefined;
730
+ const relay = this.options.relay;
731
+ if (!relay?.supported) {
732
+ this.setTransport(peer, "failed");
733
+ return;
734
+ }
735
+ // Stop listening to the dead connection before closing it, so closing it cannot be mistaken for the peer leaving.
736
+ peer.pc.onconnectionstatechange = null;
737
+ peer.pc.onicecandidate = null;
738
+ peer.pc.ontrack = null;
739
+ peer.pc.close();
740
+ peer.transport = "websocket";
741
+ relay.receiveFrom(peer.uid, (stream) => {
742
+ peer.remoteStream = stream;
743
+ this.emit({ type: "remote-stream", uid: peer.uid, stream });
744
+ });
745
+ this.syncRelaySending();
746
+ this.emit({ type: "participant-updated", participant: toParticipant(peer) });
747
+ if (notify) {
748
+ this.send({ kind: "relay-fallback", to: peer.uid });
749
+ }
750
+ }
751
+
752
+ private handleRelayFallback(from: string): void {
753
+ const peer = this.peers.get(from);
754
+ if (peer) {
755
+ this.fallBack(peer, false);
756
+ }
757
+ }
758
+
759
+ /** The relay only encodes and uploads the local media while at least one pair is actually using it. */
760
+ private syncRelaySending(): void {
761
+ this.options.relay?.setSending([...this.peers.values()].some((peer) => peer.transport === "websocket"));
762
+ }
763
+
764
+ /** Rebuilds `uid`'s connection from scratch under the current `TransportMode` - see this module's doc comment
765
+ * on `setTransportMode()`. A no-op if `uid` isn't a known peer (already left). Keeps the roster entry (name and
766
+ * announced state) and emits `participant-updated`, not a `participant-left`/`participant-joined` pair - from
767
+ * the UI's perspective this is the same `"connecting"` state any other reconnect already shows, not someone
768
+ * leaving and rejoining. */
769
+ private restartPeer(uid: string): void {
770
+ const old = this.peers.get(uid);
771
+ if (!old) {
772
+ return;
773
+ }
774
+ this.clearTimers(old);
775
+ this.stopDiagnosticsPolling(old);
776
+ // Stop listening to the dying connection before closing it - same ordering `fallBack()` uses, and for the
777
+ // same reason: closing it must never be mistaken for the peer leaving.
778
+ old.pc.onconnectionstatechange = null;
779
+ old.pc.onicecandidate = null;
780
+ old.pc.ontrack = null;
781
+ old.pc.close();
782
+ if (old.transport === "websocket") {
783
+ this.options.relay?.stopReceivingFrom(uid);
784
+ }
785
+ const offerer = isOfferer(this.selfId, uid);
786
+ const fresh = this.createPeer(uid, old.name, { audioOn: old.audioOn, videoOn: old.videoOn, handRaised: old.handRaised }, offerer);
787
+ if (this.currentTransportMode === "websocket") {
788
+ this.fallBack(fresh, true);
789
+ return;
790
+ }
791
+ // In case `old` was the relay's only remaining user - `fresh` starts on WebRTC, not the relay.
792
+ this.syncRelaySending();
793
+ this.emit({ type: "participant-updated", participant: toParticipant(fresh) });
794
+ if (offerer) {
795
+ void this.initiateOffer(fresh);
796
+ }
797
+ }
798
+
799
+ private async initiateOffer(peer: PeerState): Promise<void> {
800
+ const offer = await peer.pc.createOffer();
801
+ await peer.pc.setLocalDescription(offer);
802
+ this.send({ kind: "offer", to: peer.uid, sdp: offer });
803
+ }
804
+
805
+ private async handleOffer(message: SignalMessage): Promise<void> {
806
+ if (!message.sdp) {
807
+ return;
808
+ }
809
+ let peer = this.peers.get(message.from);
810
+ const isNew = !peer;
811
+ if (!peer) {
812
+ peer = this.createPeer(message.from, message.from, undefined, false);
813
+ }
814
+ if (isNew) {
815
+ this.emit({ type: "participant-joined", participant: toParticipant(peer) });
816
+ this.sendHello();
817
+ }
818
+ if (this.currentTransportMode === "websocket") {
819
+ // Forced relay-only: don't bother negotiating this offer at all - see this module's doc comment on
820
+ // `setTransportMode()`. Also tells the offerer (`relay-fallback`), so they don't wait out their own
821
+ // connect timeout for an answer that was never coming.
822
+ this.fallBack(peer, true);
823
+ return;
824
+ }
825
+ await peer.pc.setRemoteDescription(message.sdp);
826
+ this.flushPendingCandidates(peer);
827
+ if (!peer.senders.audio && !peer.senders.video) {
828
+ // The answerer's half of the media setup - see this module's doc comment.
829
+ peer.senders = peer.pc.claimTransceivers();
830
+ for (const kind of ["audio", "video"] as const) {
831
+ const track = this.localTracks[kind];
832
+ if (track) {
833
+ await peer.senders[kind]?.replaceTrack(track);
834
+ }
835
+ }
836
+ }
837
+ const answer = await peer.pc.createAnswer();
838
+ await peer.pc.setLocalDescription(answer);
839
+ this.send({ kind: "answer", to: peer.uid, sdp: answer });
840
+ }
841
+
842
+ private async handleAnswer(message: SignalMessage): Promise<void> {
843
+ const peer = this.peers.get(message.from);
844
+ if (!peer || !message.sdp) {
845
+ return;
846
+ }
847
+ await peer.pc.setRemoteDescription(message.sdp);
848
+ this.flushPendingCandidates(peer);
849
+ }
850
+
851
+ private async handleIceCandidate(message: SignalMessage): Promise<void> {
852
+ if (!message.candidate) {
853
+ return;
854
+ }
855
+ const peer = this.peers.get(message.from);
856
+ if (!peer) {
857
+ // A candidate that raced ahead of its own offer (each message is a separate `POST`) - hold it for
858
+ // the peer to claim when it is created. Bounded, see `MAX_ORPHAN_PEERS`.
859
+ const held = this.orphanCandidates.get(message.from);
860
+ if (held) {
861
+ if (held.length < MAX_ORPHAN_CANDIDATES) {
862
+ held.push(message.candidate);
863
+ }
864
+ } else if (this.orphanCandidates.size < MAX_ORPHAN_PEERS) {
865
+ this.orphanCandidates.set(message.from, [message.candidate]);
866
+ }
867
+ return;
868
+ }
869
+ if (!peer.remoteDescriptionSet) {
870
+ peer.pendingCandidates.push(message.candidate);
871
+ return;
872
+ }
873
+ await peer.pc.addIceCandidate(message.candidate);
874
+ }
875
+
876
+ private flushPendingCandidates(peer: PeerState): void {
877
+ peer.remoteDescriptionSet = true;
878
+ const pending = peer.pendingCandidates;
879
+ peer.pendingCandidates = [];
880
+ for (const candidate of pending) {
881
+ void peer.pc.addIceCandidate(candidate);
882
+ }
883
+ }
884
+
885
+ private handlePresenterClaim(from: string): void {
886
+ if (this.currentPresenterUid === undefined) {
887
+ this.currentPresenterUid = from;
888
+ this.emit({ type: "presenter-changed", uid: from });
889
+ return;
890
+ }
891
+ if (this.currentPresenterUid === from) {
892
+ return;
893
+ }
894
+ // Collision: a claim from someone other than the presenter we already recorded. Resolve deterministically
895
+ // - see this module's doc comment - so every participant converges on the same winner. When the
896
+ // already-recorded presenter has the smaller uid, it wins and nothing changes here - including when that
897
+ // presenter is this participant itself, whose own optimistic claim simply stays in place.
898
+ if (from < this.currentPresenterUid) {
899
+ const loser = this.currentPresenterUid;
900
+ this.currentPresenterUid = from;
901
+ this.emit({ type: "presenter-changed", uid: from });
902
+ if (loser === this.selfId) {
903
+ this.send({ kind: "presenter-release" });
904
+ }
905
+ }
906
+ }
907
+
908
+ private handlePresenterRelease(from: string): void {
909
+ if (this.currentPresenterUid === from) {
910
+ this.currentPresenterUid = undefined;
911
+ this.emit({ type: "presenter-changed", uid: undefined });
912
+ }
913
+ }
914
+ }
915
+
916
+ function toParticipant(peer: PeerState): MeshParticipant {
917
+ return {
918
+ uid: peer.uid,
919
+ name: peer.name,
920
+ audioOn: peer.audioOn,
921
+ videoOn: peer.videoOn,
922
+ handRaised: peer.handRaised,
923
+ transport: peer.transport,
924
+ diagnostics: peer.diagnostics,
925
+ };
926
+ }
927
+
928
+ function sameState(a: ParticipantState, b: ParticipantState): boolean {
929
+ return a.audioOn === b.audioOn && a.videoOn === b.videoOn && a.handRaised === b.handRaised;
930
+ }
931
+
932
+ /** Copies only the three booleans out of a received state, coerced - a message off the wire is untrusted. */
933
+ function sanitizeState(state: ParticipantState): ParticipantState {
934
+ return { audioOn: state.audioOn === true, videoOn: state.videoOn === true, handRaised: state.handRaised === true };
935
+ }
936
+
937
+ function isReactionEmoji(emoji: unknown): emoji is (typeof REACTION_EMOJIS)[number] {
938
+ return (REACTION_EMOJIS as readonly unknown[]).includes(emoji);
939
+ }
940
+
941
+ /** `servers` with every entry that names so much as one `turn:`/`turns:` url dropped - what `"p2p"` mode gives ICE,
942
+ * so it has no TURN relay candidate to ever produce. A `stun:`/`stuns:`-only entry (the normal shape this app's own
943
+ * `buildIceServers()` produces - one STUN entry, one TURN entry) is kept as-is. */
944
+ function stunOnlyServers(servers: RTCIceServer[]): RTCIceServer[] {
945
+ return servers.filter((server) => {
946
+ const urls = Array.isArray(server.urls) ? server.urls : [server.urls];
947
+ return urls.every((url) => url.startsWith("stun:") || url.startsWith("stuns:"));
948
+ });
949
+ }