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