@rapidmx/meet-plugin 0.1.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/LICENSE +382 -0
- package/README.md +38 -0
- package/apps/meet/[token].tsx +144 -0
- package/apps/meet/_CallControls.tsx +69 -0
- package/apps/meet/_CallView.tsx +338 -0
- package/apps/meet/_MeetChrome.tsx +30 -0
- package/apps/meet/_MeetLobby.tsx +223 -0
- package/apps/meet/_ParticipantTile.tsx +66 -0
- package/apps/meet/_layout.tsx +34 -0
- package/apps/meet/_meetApi.ts +66 -0
- package/apps/meet/index.tsx +22 -0
- package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +140 -0
- package/apps/settings-video-conferencing/_layout.tsx +47 -0
- package/apps/settings-video-conferencing/index.tsx +192 -0
- package/apps/shared/media/activeSpeaker.ts +45 -0
- package/apps/shared/media/deviceMedia.ts +141 -0
- package/apps/shared/media/levelMeter.ts +91 -0
- package/apps/shared/push/GuestSignalingClient.ts +338 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +350 -0
- package/apps/shared/webrtc/realPeerConnection.ts +37 -0
- package/apps/shared/webrtc/types.ts +90 -0
- package/dist/apps/meet/[token].d.ts +33 -0
- package/dist/apps/meet/[token].js +101 -0
- package/dist/apps/meet/_CallControls.d.ts +18 -0
- package/dist/apps/meet/_CallControls.js +6 -0
- package/dist/apps/meet/_CallView.d.ts +41 -0
- package/dist/apps/meet/_CallView.js +225 -0
- package/dist/apps/meet/_MeetChrome.d.ts +13 -0
- package/dist/apps/meet/_MeetChrome.js +10 -0
- package/dist/apps/meet/_MeetLobby.d.ts +29 -0
- package/dist/apps/meet/_MeetLobby.js +120 -0
- package/dist/apps/meet/_ParticipantTile.d.ts +18 -0
- package/dist/apps/meet/_ParticipantTile.js +25 -0
- package/dist/apps/meet/_layout.d.ts +9 -0
- package/dist/apps/meet/_layout.js +8 -0
- package/dist/apps/meet/_meetApi.d.ts +47 -0
- package/dist/apps/meet/_meetApi.js +23 -0
- package/dist/apps/meet/index.d.ts +5 -0
- package/dist/apps/meet/index.js +8 -0
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +24 -0
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +77 -0
- package/dist/apps/settings-video-conferencing/_layout.d.ts +16 -0
- package/dist/apps/settings-video-conferencing/_layout.js +8 -0
- package/dist/apps/settings-video-conferencing/index.d.ts +16 -0
- package/dist/apps/settings-video-conferencing/index.js +92 -0
- package/dist/apps/shared/media/activeSpeaker.d.ts +20 -0
- package/dist/apps/shared/media/activeSpeaker.js +39 -0
- package/dist/apps/shared/media/deviceMedia.d.ts +65 -0
- package/dist/apps/shared/media/deviceMedia.js +110 -0
- package/dist/apps/shared/media/levelMeter.d.ts +40 -0
- package/dist/apps/shared/media/levelMeter.js +61 -0
- package/dist/apps/shared/push/GuestSignalingClient.d.ts +89 -0
- package/dist/apps/shared/push/GuestSignalingClient.js +291 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +97 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +262 -0
- package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
- package/dist/apps/shared/webrtc/realPeerConnection.js +26 -0
- package/dist/apps/shared/webrtc/types.d.ts +94 -0
- package/dist/apps/shared/webrtc/types.js +9 -0
- package/dist/lib/index.js +25 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js +87 -0
- package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js.map +1 -0
- package/dist/lib/models/mongo/VideoMeetingMongo.js +126 -0
- package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -0
- package/dist/lib/models/sql/VideoMeetingInviteeSQL.js +87 -0
- package/dist/lib/models/sql/VideoMeetingInviteeSQL.js.map +1 -0
- package/dist/lib/models/sql/VideoMeetingSQL.js +127 -0
- package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -0
- package/dist/lib/models/types.js +19 -0
- package/dist/lib/models/types.js.map +1 -0
- package/dist/lib/mongo.js +13 -0
- package/dist/lib/mongo.js.map +1 -0
- package/dist/lib/routes/BaseVideoMeetingRoute.js +743 -0
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -0
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +31 -0
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -0
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +31 -0
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -0
- package/dist/lib/sql.js +13 -0
- package/dist/lib/sql.js.map +1 -0
- package/dist/lib/util/BookingIntegrationUtils.js +115 -0
- package/dist/lib/util/BookingIntegrationUtils.js.map +1 -0
- package/dist/lib/util/IceServerUtils.js +68 -0
- package/dist/lib/util/IceServerUtils.js.map +1 -0
- package/dist/lib/util/PublicUrlUtils.js +37 -0
- package/dist/lib/util/PublicUrlUtils.js.map +1 -0
- package/dist/lib/util/RouteAccessUtils.js +32 -0
- package/dist/lib/util/RouteAccessUtils.js.map +1 -0
- package/dist/lib/util/TokenUtils.js +33 -0
- package/dist/lib/util/TokenUtils.js.map +1 -0
- package/dist/types/index.d.ts +20 -0
- package/dist/types/models/mongo/VideoMeetingInviteeMongo.d.ts +16 -0
- package/dist/types/models/mongo/VideoMeetingMongo.d.ts +23 -0
- package/dist/types/models/sql/VideoMeetingInviteeSQL.d.ts +16 -0
- package/dist/types/models/sql/VideoMeetingSQL.d.ts +23 -0
- package/dist/types/models/types.d.ts +131 -0
- package/dist/types/mongo.d.ts +8 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +368 -0
- package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +10 -0
- package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +10 -0
- package/dist/types/sql.d.ts +8 -0
- package/dist/types/util/BookingIntegrationUtils.d.ts +51 -0
- package/dist/types/util/IceServerUtils.d.ts +57 -0
- package/dist/types/util/PublicUrlUtils.d.ts +11 -0
- package/dist/types/util/RouteAccessUtils.d.ts +8 -0
- package/dist/types/util/TokenUtils.d.ts +23 -0
- package/package.json +181 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { SignalMessage, SignalingChannel } from "../webrtc/types.js";
|
|
2
|
+
/** The subset of the browser's `WebSocket` this client uses - matches `PushClient`'s own identical seam, so a
|
|
3
|
+
* test's fake can be a plain object with no real socket behind it. */
|
|
4
|
+
export interface PushSocket {
|
|
5
|
+
readyState: number;
|
|
6
|
+
send(data: string): void;
|
|
7
|
+
close(code?: number, reason?: string): void;
|
|
8
|
+
onopen: ((event: unknown) => void) | null;
|
|
9
|
+
onmessage: ((event: {
|
|
10
|
+
data: unknown;
|
|
11
|
+
}) => void) | null;
|
|
12
|
+
onclose: ((event: unknown) => void) | null;
|
|
13
|
+
onerror: ((event: unknown) => void) | null;
|
|
14
|
+
}
|
|
15
|
+
export type PushSocketFactory = (url: string) => PushSocket;
|
|
16
|
+
export interface GuestSignalingClientOptions {
|
|
17
|
+
/** The meeting's own uid - both its push channel and the `POST /push/:id` target. */
|
|
18
|
+
channel: string;
|
|
19
|
+
/** The guest JWT from `VideoMeetingJoinResult.token`. Omitted when the caller already authenticated as a real
|
|
20
|
+
* RapidMX identity (`VideoMeetingJoinResult.authenticated`) - this client then writes no cookie and sends no
|
|
21
|
+
* `Authorization` header at all, relying entirely on the browser's own already-existing `jwt` session cookie
|
|
22
|
+
* for both the WebSocket upgrade and each `POST /push/:id` publish - see this module's doc comment. */
|
|
23
|
+
token?: string;
|
|
24
|
+
/** Defaults to `@rapidmx/react-shared`'s `pushUrl()` - the exact URL `PushClient` itself connects to. */
|
|
25
|
+
url?: () => string | undefined;
|
|
26
|
+
createSocket?: PushSocketFactory;
|
|
27
|
+
fetchImpl?: typeof fetch;
|
|
28
|
+
/** Overrides `document`, for a test - see `applyAuthCookie()`. */
|
|
29
|
+
documentRef?: Pick<Document, "cookie"> & {
|
|
30
|
+
location?: Pick<Location, "protocol">;
|
|
31
|
+
};
|
|
32
|
+
random?: () => number;
|
|
33
|
+
}
|
|
34
|
+
/** The first reconnect waits about this long, doubling on each further failure, matching `PushClient`'s own
|
|
35
|
+
* constants (kept independent rather than imported, since this module deliberately doesn't depend on
|
|
36
|
+
* `pushClient.ts` beyond its `pushUrl()` helper - see this module's doc comment on why it isn't `PushClient` itself). */
|
|
37
|
+
export declare const SIGNALING_BACKOFF_BASE_MS = 1000;
|
|
38
|
+
export declare const SIGNALING_BACKOFF_MAX_MS = 30000;
|
|
39
|
+
export declare class GuestSignalingClient implements SignalingChannel {
|
|
40
|
+
private readonly options;
|
|
41
|
+
private socket;
|
|
42
|
+
private closed;
|
|
43
|
+
private nextRequestId;
|
|
44
|
+
private attempt;
|
|
45
|
+
private timer;
|
|
46
|
+
private cookieApplied;
|
|
47
|
+
private readonly listeners;
|
|
48
|
+
private pendingConnect;
|
|
49
|
+
constructor(options: GuestSignalingClientOptions);
|
|
50
|
+
/** Connects, authenticates (see this module's doc comment) and subscribes to the meeting's own channel.
|
|
51
|
+
* Resolves once genuinely ready to send/receive; rejects if the channel is refused (an invalid/expired token,
|
|
52
|
+
* or - now fixed rather than merely possible, see the FIXED LIMITATION section above - a stale grant) or there
|
|
53
|
+
* is nothing to connect with at all (no `WebSocket`, e.g. a non-HTTPS context in some browsers - the lobby's
|
|
54
|
+
* own device-support checks already cover the more common case of no `mediaDevices`, but this is an
|
|
55
|
+
* independent capability). Calling this a second time on an already-closed client rejects immediately. */
|
|
56
|
+
connect(): Promise<void>;
|
|
57
|
+
/** Closes the socket, stops reconnecting and clears the guest auth cookie this client applied (see
|
|
58
|
+
* `applyAuthCookie()`) - idempotent. */
|
|
59
|
+
close(): void;
|
|
60
|
+
onMessage(handler: (message: SignalMessage) => void): () => void;
|
|
61
|
+
/** Publishes over `POST /push/:id`, with `token` as a bearer credential when present - see this module's doc
|
|
62
|
+
* comment on why this, unlike subscribing, needs no cookie workaround at all, and on the omitted-`token` case
|
|
63
|
+
* (an already-authenticated real caller), which relies on the browser's own real `jwt` cookie instead.
|
|
64
|
+
* Fire-and-forget: a failure is swallowed (there is nothing a signaling message's own sender can usefully do
|
|
65
|
+
* about a dropped publish beyond what the mesh's own `hello` re-announcement/renegotiation already tolerates -
|
|
66
|
+
* see `MeshConnectionManager`'s doc comment on fire-and-forget delivery). */
|
|
67
|
+
send(message: SignalMessage): void;
|
|
68
|
+
private openSocket;
|
|
69
|
+
private scheduleReconnect;
|
|
70
|
+
private handleFrame;
|
|
71
|
+
private handleSubscribed;
|
|
72
|
+
private send0;
|
|
73
|
+
private failConnect;
|
|
74
|
+
private emit;
|
|
75
|
+
/** Sets the `jwt` cookie to `token` for this origin, so the browser attaches it (as `PushClient`'s own doc
|
|
76
|
+
* comment describes for a real session) to the WebSocket upgrade this client is about to open - see this
|
|
77
|
+
* module's doc comment for the full reasoning. A no-op when `token` is omitted (the caller already
|
|
78
|
+
* authenticated as a real RapidMX identity - see the FIXED LIMITATION section above): there is then no guest
|
|
79
|
+
* token to apply, and writing nothing leaves the browser's own real `jwt` session cookie, whatever it is,
|
|
80
|
+
* untouched and free to authenticate the connection normally. */
|
|
81
|
+
private applyAuthCookie;
|
|
82
|
+
/** Expires the cookie `applyAuthCookie()` set - a no-op if that write was itself silently blocked, or never
|
|
83
|
+
* attempted at all because `token` was omitted (see `applyAuthCookie()`'s own doc comment), either way leaving
|
|
84
|
+
* whatever real session cookie was already there untouched. `cookieApplied` is only ever set once
|
|
85
|
+
* `applyAuthCookie()` has already confirmed both a `token` to write and a `doc` to write it to, and neither
|
|
86
|
+
* `this.options.token`, `this.options.documentRef` nor the real global `document` can change mid-call, so
|
|
87
|
+
* resolving it here is guaranteed to succeed too - no second `!doc` guard needed. */
|
|
88
|
+
private clearAuthCookie;
|
|
89
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* A standalone `SignalingChannel` (`../webrtc/types.js`) over `@rapidrest/service-core`'s `/push` WebSocket -
|
|
7
|
+
* built from scratch rather than reusing `@rapidmx/react-shared`'s `PushClient`/`getPushClient()`.
|
|
8
|
+
*
|
|
9
|
+
* ## Why not `PushClient`
|
|
10
|
+
*
|
|
11
|
+
* `PushClient` was investigated first, as this plugin's Phase 2 `.claude/NOTES.md` entry required. Its `connect()`
|
|
12
|
+
* opens a bare `new WebSocket(url)` and relies entirely on the browser automatically attaching this deployment's
|
|
13
|
+
* `jwt` `HttpOnly` cookie to the upgrade request - it never sends a token of its own (see its own doc comment:
|
|
14
|
+
* "a script can't read that cookie, so there is no token to send"). That fits the webmail tab's own authenticated
|
|
15
|
+
* session perfectly, but this plugin's anonymous guest has no such cookie at all - only the bearer `token`
|
|
16
|
+
* `BaseVideoMeetingRoute.join()` minted, handed to this client explicitly. `PushClient`'s API has no parameter
|
|
17
|
+
* anywhere to supply that token, and its shared-singleton design (`getPushClient()`, one client per tab) is itself
|
|
18
|
+
* the wrong shape here too: a guest's signaling identity must never be confused with (or silently share a socket
|
|
19
|
+
* with) whatever webmail session, if any, happens to already be open in the same browser. So this module
|
|
20
|
+
* implements the same wire protocol independently instead - see `../../.claude/NOTES.md` and
|
|
21
|
+
* `BasePushRoute`/`JWTStrategy` (`@rapidrest/service-core`) for the protocol this matches:
|
|
22
|
+
*
|
|
23
|
+
* - `wss://<origin>/push` - authenticated at the WebSocket upgrade. A real browser `WebSocket` cannot attach a
|
|
24
|
+
* custom `Authorization` header to that upgrade request at all (a WHATWG API limitation, not something any
|
|
25
|
+
* client-side code can work around) - `JWTStrategy.getAuthToken()`'s only other source is the same `jwt` cookie
|
|
26
|
+
* `PushClient` already relies on. When `token` is supplied (the common anonymous/guest case -
|
|
27
|
+
* `VideoMeetingJoinResult.authenticated` is `false`), this client sets that cookie itself, to `token`, immediately
|
|
28
|
+
* before connecting (`applyAuthCookie()`). When `token` is omitted (the caller already authenticated as a real
|
|
29
|
+
* RapidMX identity in `join()` - `authenticated: true` - see the FIXED LIMITATION below), this client writes no
|
|
30
|
+
* cookie at all and simply lets the browser attach whatever real `jwt` session cookie already exists for this
|
|
31
|
+
* origin, exactly as `PushClient` does for an ordinary webmail session.
|
|
32
|
+
* - Right after connecting, the server sends `{ id: 0, type: "SUBSCRIBED", data: [...] }` (channels the socket
|
|
33
|
+
* already holds - the caller's own uid channel, never this meeting's). `connect()` then explicitly `SUBSCRIBE`s to
|
|
34
|
+
* the one meeting channel and waits for the matching `{ id, type: "SUBSCRIBED", data }` reply, resolving once
|
|
35
|
+
* `channel` is actually in that reply's `data` (rejecting otherwise - an unauthorized/expired token, or the
|
|
36
|
+
* meeting's ACL grant somehow missing).
|
|
37
|
+
* - Publishing (`send()`) is **not** a WebSocket message at all - `BasePushRoute.send()` is `POST /push/:id`, a
|
|
38
|
+
* perfectly ordinary authenticated HTTP endpoint. When `token` is supplied it's sent there as a normal
|
|
39
|
+
* `Authorization: Bearer` header (no browser limitation applies to a plain `fetch()`); when it's omitted, the
|
|
40
|
+
* request carries no `Authorization` header at all and instead relies on `fetch()`'s own default same-origin
|
|
41
|
+
* credentials mode, which already attaches the browser's real `jwt` cookie to a same-origin request with zero
|
|
42
|
+
* extra code - `JWTStrategy.getAuthToken()`'s cookie fallback authenticates it exactly as it would any other
|
|
43
|
+
* same-origin authenticated call. Fire-and-forget either way, matching every other push publish in this codebase -
|
|
44
|
+
* a failed send is logged, never thrown, since a caller (`MeshConnectionManager`) has no meaningful per-message
|
|
45
|
+
* retry of its own.
|
|
46
|
+
* - An incoming meeting message arrives wrapped `{ type: "MESSAGE", channel, data: <the posted body> }` - this
|
|
47
|
+
* client unwraps it and delivers `data` (expected to be a `SignalMessage`) to `onMessage()` listeners.
|
|
48
|
+
*
|
|
49
|
+
* ## FIXED: the browser-session-collision limitation this module used to carry
|
|
50
|
+
*
|
|
51
|
+
* Phase 2 originally always wrote the guest `token` as the `jwt` cookie before connecting, unconditionally. If the
|
|
52
|
+
* visiting browser already held a *real*, `HttpOnly` `jwt` session cookie for this exact origin (a logged-in
|
|
53
|
+
* RapidMX user, e.g. with webmail open), that write was silently blocked by the browser's own "script cannot
|
|
54
|
+
* override an `HttpOnly` cookie of the same name" protection - the WebSocket then authenticated as that real
|
|
55
|
+
* session instead of the intended guest identity, and since the meeting's `AccessControlList` grant only named the
|
|
56
|
+
* guest uid, the real session's `SUBSCRIBE` was simply refused. Net effect: a logged-in user could never actually
|
|
57
|
+
* join a call at all (safe - never a cross-identity leak - but broken).
|
|
58
|
+
*
|
|
59
|
+
* The fix is in `BaseVideoMeetingRoute.join()` (backend): when the caller already presents a valid session for a
|
|
60
|
+
* real RapidMX identity, it grants *that* identity's own uid on the meeting's channel directly and returns
|
|
61
|
+
* `authenticated: true` with no guest `token` at all - see `VideoMeetingJoinResult`'s doc comment. This module's
|
|
62
|
+
* own half of the fix is simply to stop writing a cookie (and stop sending a bearer header) whenever `token` is
|
|
63
|
+
* absent, as described above - the browser's real cookie was always going to win that write anyway, so the
|
|
64
|
+
* correct behavior is to not fight it and let it authenticate normally instead.
|
|
65
|
+
*/
|
|
66
|
+
import { apiOrigin } from "@rapidmx/react-shared/util/api.js";
|
|
67
|
+
import { pushUrl } from "@rapidmx/react-shared/mail/pushClient.js";
|
|
68
|
+
const SOCKET_OPEN = 1;
|
|
69
|
+
function defaultSocketFactory() {
|
|
70
|
+
return typeof WebSocket === "undefined" ? undefined : (url) => new WebSocket(url);
|
|
71
|
+
}
|
|
72
|
+
/** The cookie name `JWTStrategyOptions.cookieName` defaults to (and this deployment always uses - see
|
|
73
|
+
* `BaseVideoMeetingRoute`'s guest JWT doc comment, signed with the same `auth` config every other token is). */
|
|
74
|
+
const AUTH_COOKIE_NAME = "jwt";
|
|
75
|
+
/** The first reconnect waits about this long, doubling on each further failure, matching `PushClient`'s own
|
|
76
|
+
* constants (kept independent rather than imported, since this module deliberately doesn't depend on
|
|
77
|
+
* `pushClient.ts` beyond its `pushUrl()` helper - see this module's doc comment on why it isn't `PushClient` itself). */
|
|
78
|
+
export const SIGNALING_BACKOFF_BASE_MS = 1000;
|
|
79
|
+
export const SIGNALING_BACKOFF_MAX_MS = 30000;
|
|
80
|
+
export class GuestSignalingClient {
|
|
81
|
+
constructor(options) {
|
|
82
|
+
this.options = options;
|
|
83
|
+
this.closed = false;
|
|
84
|
+
this.nextRequestId = 1;
|
|
85
|
+
this.attempt = 0;
|
|
86
|
+
this.cookieApplied = false;
|
|
87
|
+
this.listeners = new Set();
|
|
88
|
+
}
|
|
89
|
+
/** Connects, authenticates (see this module's doc comment) and subscribes to the meeting's own channel.
|
|
90
|
+
* Resolves once genuinely ready to send/receive; rejects if the channel is refused (an invalid/expired token,
|
|
91
|
+
* or - now fixed rather than merely possible, see the FIXED LIMITATION section above - a stale grant) or there
|
|
92
|
+
* is nothing to connect with at all (no `WebSocket`, e.g. a non-HTTPS context in some browsers - the lobby's
|
|
93
|
+
* own device-support checks already cover the more common case of no `mediaDevices`, but this is an
|
|
94
|
+
* independent capability). Calling this a second time on an already-closed client rejects immediately. */
|
|
95
|
+
connect() {
|
|
96
|
+
if (this.closed) {
|
|
97
|
+
return Promise.reject(new Error("This signaling client has already been closed."));
|
|
98
|
+
}
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
this.pendingConnect = { resolve, reject };
|
|
101
|
+
this.openSocket();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** Closes the socket, stops reconnecting and clears the guest auth cookie this client applied (see
|
|
105
|
+
* `applyAuthCookie()`) - idempotent. */
|
|
106
|
+
close() {
|
|
107
|
+
if (this.closed) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.closed = true;
|
|
111
|
+
clearTimeout(this.timer);
|
|
112
|
+
this.timer = undefined;
|
|
113
|
+
const socket = this.socket;
|
|
114
|
+
this.socket = undefined;
|
|
115
|
+
if (socket) {
|
|
116
|
+
socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null;
|
|
117
|
+
try {
|
|
118
|
+
socket.close(1000, "closing");
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Already gone.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
this.clearAuthCookie();
|
|
125
|
+
this.listeners.clear();
|
|
126
|
+
this.pendingConnect?.reject(new Error("Closed before the signaling channel connected."));
|
|
127
|
+
this.pendingConnect = undefined;
|
|
128
|
+
}
|
|
129
|
+
onMessage(handler) {
|
|
130
|
+
this.listeners.add(handler);
|
|
131
|
+
return () => this.listeners.delete(handler);
|
|
132
|
+
}
|
|
133
|
+
/** Publishes over `POST /push/:id`, with `token` as a bearer credential when present - see this module's doc
|
|
134
|
+
* comment on why this, unlike subscribing, needs no cookie workaround at all, and on the omitted-`token` case
|
|
135
|
+
* (an already-authenticated real caller), which relies on the browser's own real `jwt` cookie instead.
|
|
136
|
+
* Fire-and-forget: a failure is swallowed (there is nothing a signaling message's own sender can usefully do
|
|
137
|
+
* about a dropped publish beyond what the mesh's own `hello` re-announcement/renegotiation already tolerates -
|
|
138
|
+
* see `MeshConnectionManager`'s doc comment on fire-and-forget delivery). */
|
|
139
|
+
send(message) {
|
|
140
|
+
const fetchImpl = this.options.fetchImpl ?? (typeof fetch === "undefined" ? undefined : fetch);
|
|
141
|
+
if (!fetchImpl) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const origin = apiOrigin() || (typeof window !== "undefined" ? window.location.origin : "");
|
|
145
|
+
const headers = { "Content-Type": "application/json" };
|
|
146
|
+
if (this.options.token) {
|
|
147
|
+
headers.Authorization = `Bearer ${this.options.token}`;
|
|
148
|
+
}
|
|
149
|
+
void fetchImpl(`${origin}/push/${encodeURIComponent(this.options.channel)}`, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers,
|
|
152
|
+
body: JSON.stringify(message),
|
|
153
|
+
}).catch(() => {
|
|
154
|
+
// Best-effort - see this method's doc comment.
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
openSocket() {
|
|
158
|
+
const url = (this.options.url ?? pushUrl)();
|
|
159
|
+
const createSocket = this.options.createSocket ?? defaultSocketFactory();
|
|
160
|
+
if (!url || !createSocket) {
|
|
161
|
+
this.failConnect(new Error("This browser (or this page's connection) doesn't support the signaling channel."));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.applyAuthCookie();
|
|
165
|
+
let socket;
|
|
166
|
+
try {
|
|
167
|
+
socket = createSocket(url);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
this.scheduleReconnect();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.socket = socket;
|
|
174
|
+
socket.onmessage = (event) => this.handleFrame(event.data);
|
|
175
|
+
socket.onclose = () => {
|
|
176
|
+
// Guards against a stale socket's belated close event: `close()` always sets `this.socket = undefined`
|
|
177
|
+
// synchronously (before this handler could possibly be detached asynchronously), so once this client
|
|
178
|
+
// is closed, `this.socket` can never equal `socket` again and this always returns here first.
|
|
179
|
+
if (this.socket !== socket) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
this.socket = undefined;
|
|
183
|
+
this.scheduleReconnect();
|
|
184
|
+
};
|
|
185
|
+
socket.onerror = () => undefined;
|
|
186
|
+
}
|
|
187
|
+
scheduleReconnect() {
|
|
188
|
+
const ceiling = Math.min(SIGNALING_BACKOFF_MAX_MS, SIGNALING_BACKOFF_BASE_MS * 2 ** Math.min(this.attempt, 10));
|
|
189
|
+
const delay = ceiling / 2 + ((this.options.random ?? Math.random)() * ceiling) / 2;
|
|
190
|
+
this.attempt += 1;
|
|
191
|
+
this.timer = setTimeout(() => {
|
|
192
|
+
this.timer = undefined;
|
|
193
|
+
this.openSocket();
|
|
194
|
+
}, delay);
|
|
195
|
+
}
|
|
196
|
+
handleFrame(raw) {
|
|
197
|
+
if (typeof raw !== "string") {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
let frame;
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(raw);
|
|
203
|
+
if (!parsed || typeof parsed !== "object") {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
frame = parsed;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (frame.type === "SUBSCRIBED") {
|
|
212
|
+
this.handleSubscribed(frame);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (frame.type === "MESSAGE" && frame.channel === this.options.channel) {
|
|
216
|
+
const data = frame.data;
|
|
217
|
+
if (data && typeof data === "object" && data.type === "video-meeting-signal") {
|
|
218
|
+
this.emit(data);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
handleSubscribed(frame) {
|
|
223
|
+
const channels = Array.isArray(frame.data) ? frame.data.filter((c) => typeof c === "string") : [];
|
|
224
|
+
if (frame.id === 0) {
|
|
225
|
+
// The connect-time greeting: now actually subscribe to the one channel this client cares about.
|
|
226
|
+
const id = this.nextRequestId++;
|
|
227
|
+
this.send0({ id, type: "SUBSCRIBE", data: [this.options.channel] });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (channels.includes(this.options.channel)) {
|
|
231
|
+
this.attempt = 0;
|
|
232
|
+
this.pendingConnect?.resolve();
|
|
233
|
+
this.pendingConnect = undefined;
|
|
234
|
+
}
|
|
235
|
+
else if (this.pendingConnect) {
|
|
236
|
+
this.failConnect(new Error("This meeting's signaling channel refused the subscription."));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
send0(message) {
|
|
240
|
+
const socket = this.socket;
|
|
241
|
+
if (!socket || socket.readyState !== SOCKET_OPEN) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
socket.send(JSON.stringify(message));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// The close that follows a dead socket reconnects and re-subscribes.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
failConnect(err) {
|
|
252
|
+
this.pendingConnect?.reject(err);
|
|
253
|
+
this.pendingConnect = undefined;
|
|
254
|
+
}
|
|
255
|
+
emit(message) {
|
|
256
|
+
for (const listener of [...this.listeners]) {
|
|
257
|
+
listener(message);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** Sets the `jwt` cookie to `token` for this origin, so the browser attaches it (as `PushClient`'s own doc
|
|
261
|
+
* comment describes for a real session) to the WebSocket upgrade this client is about to open - see this
|
|
262
|
+
* module's doc comment for the full reasoning. A no-op when `token` is omitted (the caller already
|
|
263
|
+
* authenticated as a real RapidMX identity - see the FIXED LIMITATION section above): there is then no guest
|
|
264
|
+
* token to apply, and writing nothing leaves the browser's own real `jwt` session cookie, whatever it is,
|
|
265
|
+
* untouched and free to authenticate the connection normally. */
|
|
266
|
+
applyAuthCookie() {
|
|
267
|
+
if (!this.options.token) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const doc = this.options.documentRef ?? (typeof document === "undefined" ? undefined : document);
|
|
271
|
+
if (!doc) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
this.cookieApplied = true;
|
|
275
|
+
const secure = (doc.location ?? (typeof location === "undefined" ? undefined : location))?.protocol === "https:" ? "; Secure" : "";
|
|
276
|
+
doc.cookie = `${AUTH_COOKIE_NAME}=${encodeURIComponent(this.options.token)}; path=/; SameSite=Lax${secure}`;
|
|
277
|
+
}
|
|
278
|
+
/** Expires the cookie `applyAuthCookie()` set - a no-op if that write was itself silently blocked, or never
|
|
279
|
+
* attempted at all because `token` was omitted (see `applyAuthCookie()`'s own doc comment), either way leaving
|
|
280
|
+
* whatever real session cookie was already there untouched. `cookieApplied` is only ever set once
|
|
281
|
+
* `applyAuthCookie()` has already confirmed both a `token` to write and a `doc` to write it to, and neither
|
|
282
|
+
* `this.options.token`, `this.options.documentRef` nor the real global `document` can change mid-call, so
|
|
283
|
+
* resolving it here is guaranteed to succeed too - no second `!doc` guard needed. */
|
|
284
|
+
clearAuthCookie() {
|
|
285
|
+
if (!this.cookieApplied) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const doc = this.options.documentRef ?? (typeof document === "undefined" ? undefined : document);
|
|
289
|
+
doc.cookie = `${AUTH_COOKIE_NAME}=; path=/; Max-Age=0`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-mesh WebRTC connection management: one direct `RTCPeerConnection` per other participant, signaled entirely
|
|
3
|
+
* over the meeting's own push channel (`SignalingChannel` - see `types.ts`) as this plugin's Phase 2
|
|
4
|
+
* `.claude/NOTES.md` entry lays out. Pure state machine, no JSX/DOM rendering - `apps/meet/_CallView.tsx` owns one
|
|
5
|
+
* instance per call and mirrors its events into React state.
|
|
6
|
+
*
|
|
7
|
+
* ## Who calls whom
|
|
8
|
+
*
|
|
9
|
+
* On learning of another participant (via `hello`), a peer connection for that pair is created by whichever side
|
|
10
|
+
* has the lexicographically smaller uid (`isOfferer()`) - a well-known, deterministic, race-free rule for a mesh:
|
|
11
|
+
* both sides independently compute the same answer with no coordination round trip, and exactly one side ever
|
|
12
|
+
* calls `createOffer()` for a given pair.
|
|
13
|
+
*
|
|
14
|
+
* ## Join announcement and roster discovery
|
|
15
|
+
*
|
|
16
|
+
* The channel has no history (a push event published while a socket was down is never replayed - see
|
|
17
|
+
* `@rapidmx/react-shared`'s `pushClient.ts` doc comment for the identical guarantee on the mail push channel this
|
|
18
|
+
* one shares its transport with), so a newcomer's `hello` would only reach participants who happened to already be
|
|
19
|
+
* subscribed *and* who joined before them - never the other way around. Every participant who learns of a
|
|
20
|
+
* genuinely new peer (from any message, not just `hello`) therefore echoes its own `hello` right back, once, the
|
|
21
|
+
* first time it becomes aware of that peer - so within one extra round trip everyone converges on the same full
|
|
22
|
+
* roster regardless of join order. This is naturally bounded (each participant echoes at most once per peer it
|
|
23
|
+
* ever discovers) rather than a risk of runaway flooding.
|
|
24
|
+
*
|
|
25
|
+
* ## Presenter (single-writer screen share)
|
|
26
|
+
*
|
|
27
|
+
* `presenter-claim` is only ever sent locally when `presenterUid` is unset (`claimPresenter()` refuses otherwise).
|
|
28
|
+
* A claim is applied optimistically and locally at once; every recipient (including the claimant) applies the
|
|
29
|
+
* *first* claim it sees. If two claims genuinely race (both sent before either claimant heard the other's), every
|
|
30
|
+
* participant converges on the same winner by resolving the collision deterministically: the lexicographically
|
|
31
|
+
* smaller uid wins, and the losing claimant self-revokes (stops its own share locally and sends
|
|
32
|
+
* `presenter-release`) once it observes the winning claim. See `handlePresenterClaim()`.
|
|
33
|
+
*
|
|
34
|
+
* ## Presentation mode's screen share
|
|
35
|
+
*
|
|
36
|
+
* Sharing a screen never adds a second video track or renegotiates a connection - `replaceLocalVideoTrack()`
|
|
37
|
+
* simply swaps each peer connection's existing outgoing video `RTCRtpSender`'s track (`sender.replaceTrack()`),
|
|
38
|
+
* the same track object already flowing to everyone from the moment they joined. Stopping a share replaces it
|
|
39
|
+
* back to the camera track the same way.
|
|
40
|
+
*/
|
|
41
|
+
import type { MeshEvent, MeshParticipant, RTCPeerConnectionFactory, SignalingChannel } from "./types.js";
|
|
42
|
+
/** The lexicographically smaller uid is always the offerer for that pair - see this module's doc comment. */
|
|
43
|
+
export declare function isOfferer(selfUid: string, peerUid: string): boolean;
|
|
44
|
+
export interface MeshConnectionManagerOptions {
|
|
45
|
+
selfUid: string;
|
|
46
|
+
selfName: string;
|
|
47
|
+
iceServers: RTCIceServer[];
|
|
48
|
+
channel: SignalingChannel;
|
|
49
|
+
createPeerConnection: RTCPeerConnectionFactory;
|
|
50
|
+
/** The local camera/microphone stream, attached to every new peer connection as it's created. */
|
|
51
|
+
localStream: MediaStream;
|
|
52
|
+
}
|
|
53
|
+
export declare class MeshConnectionManager {
|
|
54
|
+
private readonly options;
|
|
55
|
+
private readonly peers;
|
|
56
|
+
private readonly listeners;
|
|
57
|
+
private unsubscribe;
|
|
58
|
+
private started;
|
|
59
|
+
private stopped;
|
|
60
|
+
private currentPresenterUid;
|
|
61
|
+
constructor(options: MeshConnectionManagerOptions);
|
|
62
|
+
get participants(): MeshParticipant[];
|
|
63
|
+
get presenterUid(): string | undefined;
|
|
64
|
+
onEvent(listener: (event: MeshEvent) => void): () => void;
|
|
65
|
+
/** Subscribes to the channel and announces this participant. Idempotent - a second call is a no-op. */
|
|
66
|
+
start(): void;
|
|
67
|
+
/** Announces departure, closes every peer connection and unsubscribes - idempotent, and safe to call whether
|
|
68
|
+
* or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping `localStream`'s
|
|
69
|
+
* own tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns that
|
|
70
|
+
* stream's lifecycle, only attaches it. */
|
|
71
|
+
stop(): void;
|
|
72
|
+
/** Swaps every peer connection's outgoing video track (presentation mode) - see this module's doc comment.
|
|
73
|
+
* `track` is `null` to stop sending video at all (never used by this plugin's UI today, which always has a
|
|
74
|
+
* camera-or-screen track to fall back to, but kept correct since `RTCRtpSender.replaceTrack()` itself allows
|
|
75
|
+
* it). */
|
|
76
|
+
replaceLocalVideoTrack(track: MediaStreamTrack | null): void;
|
|
77
|
+
/** Claims presenter status for the local participant. Refuses (returns `false`, sends nothing) when someone
|
|
78
|
+
* else already presents - the caller (`_CallView.tsx`) uses this to disable its own "share screen" control
|
|
79
|
+
* rather than let a claim silently do nothing. */
|
|
80
|
+
claimPresenter(): boolean;
|
|
81
|
+
/** Releases presenter status - a no-op unless the local participant currently holds it. */
|
|
82
|
+
releasePresenter(): void;
|
|
83
|
+
private sendHello;
|
|
84
|
+
private send;
|
|
85
|
+
private emit;
|
|
86
|
+
private handleMessage;
|
|
87
|
+
private handleHello;
|
|
88
|
+
private handleBye;
|
|
89
|
+
private createPeer;
|
|
90
|
+
private initiateOffer;
|
|
91
|
+
private handleOffer;
|
|
92
|
+
private handleAnswer;
|
|
93
|
+
private handleIceCandidate;
|
|
94
|
+
private flushPendingCandidates;
|
|
95
|
+
private handlePresenterClaim;
|
|
96
|
+
private handlePresenterRelease;
|
|
97
|
+
}
|