@rapidmx/meet-plugin 0.13.1 → 0.14.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 +55 -55
- package/apps/meet/[token].tsx +341 -341
- package/apps/meet/_CallControls.tsx +366 -394
- package/apps/meet/_CallView.tsx +987 -848
- package/apps/meet/_DiagnosticsWindow.tsx +80 -0
- package/apps/meet/_MeetLobby.tsx +224 -224
- package/apps/meet/_ParticipantTile.tsx +169 -169
- package/apps/meet/_ParticipantsDrawer.tsx +10 -104
- package/apps/meet/_SettingsModal.tsx +152 -0
- package/apps/meet/_icons.tsx +80 -82
- package/apps/meet/_meetApi.ts +212 -212
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
- package/apps/shared/media/filters/mlModels.ts +174 -174
- package/apps/shared/push/GuestSignalingClient.ts +359 -359
- package/apps/shared/relay/RelayClient.ts +7 -2
- package/apps/shared/webrtc/MeshConnectionManager.ts +949 -795
- package/apps/shared/webrtc/types.ts +247 -222
- package/dist/apps/meet/_CallControls.d.ts +3 -8
- package/dist/apps/meet/_CallControls.js +3 -10
- package/dist/apps/meet/_CallView.d.ts +15 -2
- package/dist/apps/meet/_CallView.js +70 -4
- package/dist/apps/meet/_DiagnosticsWindow.d.ts +25 -0
- package/dist/apps/meet/_DiagnosticsWindow.js +45 -0
- package/dist/apps/meet/_ParticipantsDrawer.d.ts +10 -21
- package/dist/apps/meet/_ParticipantsDrawer.js +9 -39
- package/dist/apps/meet/_SettingsModal.d.ts +28 -0
- package/dist/apps/meet/_SettingsModal.js +59 -0
- package/dist/apps/meet/_icons.d.ts +1 -1
- package/dist/apps/meet/_icons.js +1 -1
- package/dist/apps/shared/relay/RelayClient.d.ts +6 -1
- package/dist/apps/shared/relay/RelayClient.js +7 -2
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +55 -2
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +153 -9
- package/dist/apps/shared/webrtc/types.d.ts +25 -1
- package/dist/lib/util/RedisRelayBus.js +61 -5
- package/dist/lib/util/RedisRelayBus.js.map +1 -1
- package/dist/lib/util/RelayHub.js +10 -3
- package/dist/lib/util/RelayHub.js.map +1 -1
- package/dist/types/util/RedisRelayBus.d.ts +45 -6
- package/dist/types/util/RelayHub.d.ts +9 -2
- 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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
*
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
type
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
this.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
this.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
this.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
this.
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
this.
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
this.
|
|
317
|
-
this.
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
this.
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
if (peer
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
this.
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
private
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
peer.
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
private
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
if (
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
peer.
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
this.
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
}
|
|
747
|
-
if (
|
|
748
|
-
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
if (
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
+
}
|