@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.
Files changed (108) hide show
  1. package/LICENSE +382 -0
  2. package/README.md +38 -0
  3. package/apps/meet/[token].tsx +144 -0
  4. package/apps/meet/_CallControls.tsx +69 -0
  5. package/apps/meet/_CallView.tsx +338 -0
  6. package/apps/meet/_MeetChrome.tsx +30 -0
  7. package/apps/meet/_MeetLobby.tsx +223 -0
  8. package/apps/meet/_ParticipantTile.tsx +66 -0
  9. package/apps/meet/_layout.tsx +34 -0
  10. package/apps/meet/_meetApi.ts +66 -0
  11. package/apps/meet/index.tsx +22 -0
  12. package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +140 -0
  13. package/apps/settings-video-conferencing/_layout.tsx +47 -0
  14. package/apps/settings-video-conferencing/index.tsx +192 -0
  15. package/apps/shared/media/activeSpeaker.ts +45 -0
  16. package/apps/shared/media/deviceMedia.ts +141 -0
  17. package/apps/shared/media/levelMeter.ts +91 -0
  18. package/apps/shared/push/GuestSignalingClient.ts +338 -0
  19. package/apps/shared/webrtc/MeshConnectionManager.ts +350 -0
  20. package/apps/shared/webrtc/realPeerConnection.ts +37 -0
  21. package/apps/shared/webrtc/types.ts +90 -0
  22. package/dist/apps/meet/[token].d.ts +33 -0
  23. package/dist/apps/meet/[token].js +101 -0
  24. package/dist/apps/meet/_CallControls.d.ts +18 -0
  25. package/dist/apps/meet/_CallControls.js +6 -0
  26. package/dist/apps/meet/_CallView.d.ts +41 -0
  27. package/dist/apps/meet/_CallView.js +225 -0
  28. package/dist/apps/meet/_MeetChrome.d.ts +13 -0
  29. package/dist/apps/meet/_MeetChrome.js +10 -0
  30. package/dist/apps/meet/_MeetLobby.d.ts +29 -0
  31. package/dist/apps/meet/_MeetLobby.js +120 -0
  32. package/dist/apps/meet/_ParticipantTile.d.ts +18 -0
  33. package/dist/apps/meet/_ParticipantTile.js +25 -0
  34. package/dist/apps/meet/_layout.d.ts +9 -0
  35. package/dist/apps/meet/_layout.js +8 -0
  36. package/dist/apps/meet/_meetApi.d.ts +47 -0
  37. package/dist/apps/meet/_meetApi.js +23 -0
  38. package/dist/apps/meet/index.d.ts +5 -0
  39. package/dist/apps/meet/index.js +8 -0
  40. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +24 -0
  41. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +77 -0
  42. package/dist/apps/settings-video-conferencing/_layout.d.ts +16 -0
  43. package/dist/apps/settings-video-conferencing/_layout.js +8 -0
  44. package/dist/apps/settings-video-conferencing/index.d.ts +16 -0
  45. package/dist/apps/settings-video-conferencing/index.js +92 -0
  46. package/dist/apps/shared/media/activeSpeaker.d.ts +20 -0
  47. package/dist/apps/shared/media/activeSpeaker.js +39 -0
  48. package/dist/apps/shared/media/deviceMedia.d.ts +65 -0
  49. package/dist/apps/shared/media/deviceMedia.js +110 -0
  50. package/dist/apps/shared/media/levelMeter.d.ts +40 -0
  51. package/dist/apps/shared/media/levelMeter.js +61 -0
  52. package/dist/apps/shared/push/GuestSignalingClient.d.ts +89 -0
  53. package/dist/apps/shared/push/GuestSignalingClient.js +291 -0
  54. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +97 -0
  55. package/dist/apps/shared/webrtc/MeshConnectionManager.js +262 -0
  56. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
  57. package/dist/apps/shared/webrtc/realPeerConnection.js +26 -0
  58. package/dist/apps/shared/webrtc/types.d.ts +94 -0
  59. package/dist/apps/shared/webrtc/types.js +9 -0
  60. package/dist/lib/index.js +25 -0
  61. package/dist/lib/index.js.map +1 -0
  62. package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js +87 -0
  63. package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js.map +1 -0
  64. package/dist/lib/models/mongo/VideoMeetingMongo.js +126 -0
  65. package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -0
  66. package/dist/lib/models/sql/VideoMeetingInviteeSQL.js +87 -0
  67. package/dist/lib/models/sql/VideoMeetingInviteeSQL.js.map +1 -0
  68. package/dist/lib/models/sql/VideoMeetingSQL.js +127 -0
  69. package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -0
  70. package/dist/lib/models/types.js +19 -0
  71. package/dist/lib/models/types.js.map +1 -0
  72. package/dist/lib/mongo.js +13 -0
  73. package/dist/lib/mongo.js.map +1 -0
  74. package/dist/lib/routes/BaseVideoMeetingRoute.js +743 -0
  75. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -0
  76. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +31 -0
  77. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -0
  78. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +31 -0
  79. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -0
  80. package/dist/lib/sql.js +13 -0
  81. package/dist/lib/sql.js.map +1 -0
  82. package/dist/lib/util/BookingIntegrationUtils.js +115 -0
  83. package/dist/lib/util/BookingIntegrationUtils.js.map +1 -0
  84. package/dist/lib/util/IceServerUtils.js +68 -0
  85. package/dist/lib/util/IceServerUtils.js.map +1 -0
  86. package/dist/lib/util/PublicUrlUtils.js +37 -0
  87. package/dist/lib/util/PublicUrlUtils.js.map +1 -0
  88. package/dist/lib/util/RouteAccessUtils.js +32 -0
  89. package/dist/lib/util/RouteAccessUtils.js.map +1 -0
  90. package/dist/lib/util/TokenUtils.js +33 -0
  91. package/dist/lib/util/TokenUtils.js.map +1 -0
  92. package/dist/types/index.d.ts +20 -0
  93. package/dist/types/models/mongo/VideoMeetingInviteeMongo.d.ts +16 -0
  94. package/dist/types/models/mongo/VideoMeetingMongo.d.ts +23 -0
  95. package/dist/types/models/sql/VideoMeetingInviteeSQL.d.ts +16 -0
  96. package/dist/types/models/sql/VideoMeetingSQL.d.ts +23 -0
  97. package/dist/types/models/types.d.ts +131 -0
  98. package/dist/types/mongo.d.ts +8 -0
  99. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +368 -0
  100. package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +10 -0
  101. package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +10 -0
  102. package/dist/types/sql.d.ts +8 -0
  103. package/dist/types/util/BookingIntegrationUtils.d.ts +51 -0
  104. package/dist/types/util/IceServerUtils.d.ts +57 -0
  105. package/dist/types/util/PublicUrlUtils.d.ts +11 -0
  106. package/dist/types/util/RouteAccessUtils.d.ts +8 -0
  107. package/dist/types/util/TokenUtils.d.ts +23 -0
  108. package/package.json +181 -0
@@ -0,0 +1,350 @@
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
+ * ## Join announcement and roster discovery
19
+ *
20
+ * The channel has no history (a push event published while a socket was down is never replayed - see
21
+ * `@rapidmx/react-shared`'s `pushClient.ts` doc comment for the identical guarantee on the mail push channel this
22
+ * one shares its transport with), so a newcomer's `hello` would only reach participants who happened to already be
23
+ * subscribed *and* who joined before them - never the other way around. Every participant who learns of a
24
+ * genuinely new peer (from any message, not just `hello`) therefore echoes its own `hello` right back, once, the
25
+ * first time it becomes aware of that peer - so within one extra round trip everyone converges on the same full
26
+ * roster regardless of join order. This is naturally bounded (each participant echoes at most once per peer it
27
+ * ever discovers) rather than a risk of runaway flooding.
28
+ *
29
+ * ## Presenter (single-writer screen share)
30
+ *
31
+ * `presenter-claim` is only ever sent locally when `presenterUid` is unset (`claimPresenter()` refuses otherwise).
32
+ * A claim is applied optimistically and locally at once; every recipient (including the claimant) applies the
33
+ * *first* claim it sees. If two claims genuinely race (both sent before either claimant heard the other's), every
34
+ * participant converges on the same winner by resolving the collision deterministically: the lexicographically
35
+ * smaller uid wins, and the losing claimant self-revokes (stops its own share locally and sends
36
+ * `presenter-release`) once it observes the winning claim. See `handlePresenterClaim()`.
37
+ *
38
+ * ## Presentation mode's screen share
39
+ *
40
+ * Sharing a screen never adds a second video track or renegotiates a connection - `replaceLocalVideoTrack()`
41
+ * simply swaps each peer connection's existing outgoing video `RTCRtpSender`'s track (`sender.replaceTrack()`),
42
+ * the same track object already flowing to everyone from the moment they joined. Stopping a share replaces it
43
+ * back to the camera track the same way.
44
+ */
45
+ import type { MeshEvent, MeshParticipant, RTCPeerConnectionFactory, RTCPeerConnectionLike, SignalMessage, SignalingChannel } from "./types.js";
46
+
47
+ /** The lexicographically smaller uid is always the offerer for that pair - see this module's doc comment. */
48
+ export function isOfferer(selfUid: string, peerUid: string): boolean {
49
+ return selfUid < peerUid;
50
+ }
51
+
52
+ interface PeerState {
53
+ uid: string;
54
+ name: string;
55
+ pc: RTCPeerConnectionLike;
56
+ remoteDescriptionSet: boolean;
57
+ pendingCandidates: RTCIceCandidateInit[];
58
+ }
59
+
60
+ export interface MeshConnectionManagerOptions {
61
+ selfUid: string;
62
+ selfName: string;
63
+ iceServers: RTCIceServer[];
64
+ channel: SignalingChannel;
65
+ createPeerConnection: RTCPeerConnectionFactory;
66
+ /** The local camera/microphone stream, attached to every new peer connection as it's created. */
67
+ localStream: MediaStream;
68
+ }
69
+
70
+ export class MeshConnectionManager {
71
+ private readonly peers = new Map<string, PeerState>();
72
+ private readonly listeners = new Set<(event: MeshEvent) => void>();
73
+ private unsubscribe: (() => void) | undefined;
74
+ private started = false;
75
+ private stopped = false;
76
+ private currentPresenterUid: string | undefined;
77
+
78
+ constructor(private readonly options: MeshConnectionManagerOptions) {}
79
+
80
+ get participants(): MeshParticipant[] {
81
+ return [...this.peers.values()].map((peer) => ({ uid: peer.uid, name: peer.name }));
82
+ }
83
+
84
+ get presenterUid(): string | undefined {
85
+ return this.currentPresenterUid;
86
+ }
87
+
88
+ onEvent(listener: (event: MeshEvent) => void): () => void {
89
+ this.listeners.add(listener);
90
+ return () => this.listeners.delete(listener);
91
+ }
92
+
93
+ /** Subscribes to the channel and announces this participant. Idempotent - a second call is a no-op. */
94
+ start(): void {
95
+ if (this.started) {
96
+ return;
97
+ }
98
+ this.started = true;
99
+ this.unsubscribe = this.options.channel.onMessage((message) => this.handleMessage(message));
100
+ this.sendHello();
101
+ }
102
+
103
+ /** Announces departure, closes every peer connection and unsubscribes - idempotent, and safe to call whether
104
+ * or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping `localStream`'s
105
+ * own tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns that
106
+ * stream's lifecycle, only attaches it. */
107
+ stop(): void {
108
+ if (this.stopped) {
109
+ return;
110
+ }
111
+ this.stopped = true;
112
+ if (this.started) {
113
+ this.send({ kind: "bye" });
114
+ }
115
+ this.unsubscribe?.();
116
+ this.unsubscribe = undefined;
117
+ for (const peer of this.peers.values()) {
118
+ peer.pc.close();
119
+ }
120
+ this.peers.clear();
121
+ this.listeners.clear();
122
+ }
123
+
124
+ /** Swaps every peer connection's outgoing video track (presentation mode) - see this module's doc comment.
125
+ * `track` is `null` to stop sending video at all (never used by this plugin's UI today, which always has a
126
+ * camera-or-screen track to fall back to, but kept correct since `RTCRtpSender.replaceTrack()` itself allows
127
+ * it). */
128
+ replaceLocalVideoTrack(track: MediaStreamTrack | null): void {
129
+ for (const peer of this.peers.values()) {
130
+ const sender = peer.pc.getSenders().find((s) => s.track?.kind === "video");
131
+ void sender?.replaceTrack(track);
132
+ }
133
+ }
134
+
135
+ /** Claims presenter status for the local participant. Refuses (returns `false`, sends nothing) when someone
136
+ * else already presents - the caller (`_CallView.tsx`) uses this to disable its own "share screen" control
137
+ * rather than let a claim silently do nothing. */
138
+ claimPresenter(): boolean {
139
+ if (this.currentPresenterUid !== undefined && this.currentPresenterUid !== this.options.selfUid) {
140
+ return false;
141
+ }
142
+ this.currentPresenterUid = this.options.selfUid;
143
+ this.send({ kind: "presenter-claim" });
144
+ this.emit({ type: "presenter-changed", uid: this.currentPresenterUid });
145
+ return true;
146
+ }
147
+
148
+ /** Releases presenter status - a no-op unless the local participant currently holds it. */
149
+ releasePresenter(): void {
150
+ if (this.currentPresenterUid !== this.options.selfUid) {
151
+ return;
152
+ }
153
+ this.currentPresenterUid = undefined;
154
+ this.send({ kind: "presenter-release" });
155
+ this.emit({ type: "presenter-changed", uid: undefined });
156
+ }
157
+
158
+ private sendHello(): void {
159
+ this.send({ kind: "hello", name: this.options.selfName });
160
+ }
161
+
162
+ private send(partial: Omit<SignalMessage, "type" | "from">): void {
163
+ this.options.channel.send({ type: "video-meeting-signal", from: this.options.selfUid, ...partial });
164
+ }
165
+
166
+ private emit(event: MeshEvent): void {
167
+ for (const listener of [...this.listeners]) {
168
+ listener(event);
169
+ }
170
+ }
171
+
172
+ private handleMessage(message: SignalMessage): void {
173
+ if (message.type !== "video-meeting-signal" || message.from === this.options.selfUid) {
174
+ return;
175
+ }
176
+ if (message.to !== undefined && message.to !== this.options.selfUid) {
177
+ return;
178
+ }
179
+ switch (message.kind) {
180
+ case "hello":
181
+ this.handleHello(message);
182
+ return;
183
+ case "bye":
184
+ this.handleBye(message.from);
185
+ return;
186
+ case "offer":
187
+ void this.handleOffer(message);
188
+ return;
189
+ case "answer":
190
+ void this.handleAnswer(message);
191
+ return;
192
+ case "ice-candidate":
193
+ void this.handleIceCandidate(message);
194
+ return;
195
+ case "presenter-claim":
196
+ this.handlePresenterClaim(message.from);
197
+ return;
198
+ case "presenter-release":
199
+ this.handlePresenterRelease(message.from);
200
+ return;
201
+ }
202
+ }
203
+
204
+ private handleHello(message: SignalMessage): void {
205
+ if (this.peers.has(message.from)) {
206
+ return;
207
+ }
208
+ const name = message.name ?? message.from;
209
+ const peer = this.createPeer(message.from, name);
210
+ this.emit({ type: "participant-joined", participant: { uid: peer.uid, name: peer.name } });
211
+ // Let a newcomer who couldn't have seen our own original `hello` learn about us too - see this module's
212
+ // doc comment on roster discovery.
213
+ this.sendHello();
214
+ if (isOfferer(this.options.selfUid, message.from)) {
215
+ void this.initiateOffer(peer);
216
+ }
217
+ }
218
+
219
+ private handleBye(uid: string): void {
220
+ const peer = this.peers.get(uid);
221
+ if (!peer) {
222
+ return;
223
+ }
224
+ peer.pc.close();
225
+ this.peers.delete(uid);
226
+ this.emit({ type: "participant-left", uid });
227
+ if (this.currentPresenterUid === uid) {
228
+ this.currentPresenterUid = undefined;
229
+ this.emit({ type: "presenter-changed", uid: undefined });
230
+ }
231
+ }
232
+
233
+ private createPeer(uid: string, name: string): PeerState {
234
+ const pc = this.options.createPeerConnection({ iceServers: this.options.iceServers });
235
+ const peer: PeerState = { uid, name, pc, remoteDescriptionSet: false, pendingCandidates: [] };
236
+ this.peers.set(uid, peer);
237
+ for (const track of this.options.localStream.getTracks()) {
238
+ pc.addTrack(track, this.options.localStream);
239
+ }
240
+ pc.onicecandidate = (event) => {
241
+ if (event.candidate) {
242
+ this.send({ kind: "ice-candidate", to: uid, candidate: event.candidate });
243
+ }
244
+ };
245
+ pc.ontrack = (event) => {
246
+ const stream = event.streams[0];
247
+ if (stream) {
248
+ this.emit({ type: "remote-stream", uid, stream });
249
+ }
250
+ };
251
+ pc.onconnectionstatechange = () => {
252
+ if (pc.connectionState === "failed" || pc.connectionState === "closed") {
253
+ this.handleBye(uid);
254
+ }
255
+ };
256
+ return peer;
257
+ }
258
+
259
+ private async initiateOffer(peer: PeerState): Promise<void> {
260
+ const offer = await peer.pc.createOffer();
261
+ await peer.pc.setLocalDescription(offer);
262
+ this.send({ kind: "offer", to: peer.uid, sdp: offer });
263
+ }
264
+
265
+ private async handleOffer(message: SignalMessage): Promise<void> {
266
+ if (!message.sdp) {
267
+ return;
268
+ }
269
+ let peer = this.peers.get(message.from);
270
+ const isNew = !peer;
271
+ if (!peer) {
272
+ peer = this.createPeer(message.from, message.from);
273
+ }
274
+ await peer.pc.setRemoteDescription(message.sdp);
275
+ this.flushPendingCandidates(peer);
276
+ if (isNew) {
277
+ this.emit({ type: "participant-joined", participant: { uid: peer.uid, name: peer.name } });
278
+ }
279
+ const answer = await peer.pc.createAnswer();
280
+ await peer.pc.setLocalDescription(answer);
281
+ this.send({ kind: "answer", to: peer.uid, sdp: answer });
282
+ }
283
+
284
+ private async handleAnswer(message: SignalMessage): Promise<void> {
285
+ const peer = this.peers.get(message.from);
286
+ if (!peer || !message.sdp) {
287
+ return;
288
+ }
289
+ await peer.pc.setRemoteDescription(message.sdp);
290
+ this.flushPendingCandidates(peer);
291
+ }
292
+
293
+ private async handleIceCandidate(message: SignalMessage): Promise<void> {
294
+ if (!message.candidate) {
295
+ return;
296
+ }
297
+ const peer = this.peers.get(message.from);
298
+ if (!peer) {
299
+ // No connection tracked for this peer yet (a candidate that raced ahead of its own offer/answer) -
300
+ // nothing to buffer it against, and one lost trickle-ICE candidate does not by itself break a
301
+ // connection (there are normally several). See this module's known-limitations note in the Phase 2
302
+ // report.
303
+ return;
304
+ }
305
+ if (!peer.remoteDescriptionSet) {
306
+ peer.pendingCandidates.push(message.candidate);
307
+ return;
308
+ }
309
+ await peer.pc.addIceCandidate(message.candidate);
310
+ }
311
+
312
+ private flushPendingCandidates(peer: PeerState): void {
313
+ peer.remoteDescriptionSet = true;
314
+ const pending = peer.pendingCandidates;
315
+ peer.pendingCandidates = [];
316
+ for (const candidate of pending) {
317
+ void peer.pc.addIceCandidate(candidate);
318
+ }
319
+ }
320
+
321
+ private handlePresenterClaim(from: string): void {
322
+ if (this.currentPresenterUid === undefined) {
323
+ this.currentPresenterUid = from;
324
+ this.emit({ type: "presenter-changed", uid: from });
325
+ return;
326
+ }
327
+ if (this.currentPresenterUid === from) {
328
+ return;
329
+ }
330
+ // Collision: a claim from someone other than the presenter we already recorded. Resolve deterministically
331
+ // - see this module's doc comment - so every participant converges on the same winner. When the
332
+ // already-recorded presenter has the smaller uid, it wins and nothing changes here - including when that
333
+ // presenter is `this.options.selfUid` itself, whose own optimistic claim simply stays in place.
334
+ if (from < this.currentPresenterUid) {
335
+ const loser = this.currentPresenterUid;
336
+ this.currentPresenterUid = from;
337
+ this.emit({ type: "presenter-changed", uid: from });
338
+ if (loser === this.options.selfUid) {
339
+ this.send({ kind: "presenter-release" });
340
+ }
341
+ }
342
+ }
343
+
344
+ private handlePresenterRelease(from: string): void {
345
+ if (this.currentPresenterUid === from) {
346
+ this.currentPresenterUid = undefined;
347
+ this.emit({ type: "presenter-changed", uid: undefined });
348
+ }
349
+ }
350
+ }
@@ -0,0 +1,37 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** Adapts the real browser `RTCPeerConnection` to `RTCPeerConnectionLike` (`types.ts`) - `MeshConnectionManager`'s
6
+ * own tests never use this (they inject a fake `RTCPeerConnectionLike` directly); `apps/meet/_CallView.tsx` is
7
+ * this factory's only real caller. `createBrowserPeerConnection` is a plain function value, never invoked at
8
+ * module scope, so importing this file is safe during this plugin's page SSR, where `RTCPeerConnection` does not
9
+ * exist at all - it would only throw if actually *called* outside a browser. */
10
+ import type { RTCPeerConnectionFactory, RTCPeerConnectionLike } from "./types.js";
11
+
12
+ export const createBrowserPeerConnection: RTCPeerConnectionFactory = (config) => {
13
+ const pc = new RTCPeerConnection(config);
14
+ const like: RTCPeerConnectionLike = {
15
+ addTrack: (track, stream) => pc.addTrack(track, stream),
16
+ getSenders: () => pc.getSenders(),
17
+ createOffer: () => pc.createOffer(),
18
+ createAnswer: () => pc.createAnswer(),
19
+ setLocalDescription: (description) => pc.setLocalDescription(description),
20
+ setRemoteDescription: (description) => pc.setRemoteDescription(description),
21
+ addIceCandidate: (candidate) => pc.addIceCandidate(candidate),
22
+ close: () => pc.close(),
23
+ onicecandidate: null,
24
+ ontrack: null,
25
+ onconnectionstatechange: null,
26
+ get connectionState() {
27
+ return pc.connectionState;
28
+ },
29
+ };
30
+ // Forwards the real browser events to whatever handler `MeshConnectionManager` has currently assigned on
31
+ // `like` - a level of indirection needed because `like`'s own handler properties are reassigned *after* this
32
+ // adapter object is constructed and returned (see `MeshConnectionManager.createPeer()`).
33
+ pc.onicecandidate = (event) => like.onicecandidate?.({ candidate: event.candidate ? event.candidate.toJSON() : null });
34
+ pc.ontrack = (event) => like.ontrack?.({ streams: event.streams });
35
+ pc.onconnectionstatechange = () => like.onconnectionstatechange?.();
36
+ return like;
37
+ };
@@ -0,0 +1,90 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** Shared types for `MeshConnectionManager.ts` and its signaling transport (`apps/shared/push/GuestSignalingClient.ts`) -
6
+ * kept in their own module so a test can depend on the shapes alone. `GuestSignalingClient` itself now also serves
7
+ * an already-authenticated real caller (`VideoMeetingJoinResult.authenticated`), not only a synthetic guest - see
8
+ * its own doc comment. */
9
+
10
+ /**
11
+ * One WebRTC signaling message, published as the JSON body of `POST /push/:meetingUid` (`BasePushRoute.send()`)
12
+ * and received back over `/push`'s WebSocket, exactly as `BaseVideoMeetingRoute`'s doc comment describes ("SDP
13
+ * offers/answers and ICE candidates are ordinary `NotificationUtils.sendMessage()` payloads"). `type` is a fixed
14
+ * literal so a listener on the meeting's channel (which will also see other frame shapes if this server ever
15
+ * grows other push consumers) can cheaply recognize and skip anything that isn't one of these.
16
+ *
17
+ * `hello`/`bye`/`presenter-claim`/`presenter-release` are broadcast (`to` left unset - every participant is
18
+ * relevant); `offer`/`answer`/`ice-candidate` are point-to-point (`to` is the intended peer's uid) - a recipient
19
+ * that isn't `to` (when set) ignores the message, since a channel's pub/sub fans every message out to every
20
+ * subscriber, sender included.
21
+ */
22
+ export interface SignalMessage {
23
+ type: "video-meeting-signal";
24
+ kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release";
25
+ /** The sender's uid - the real, already-authenticated caller's own uid when `join()` returned
26
+ * `authenticated: true`, else the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted (either way, see
27
+ * `VideoMeetingJoinResult.selfUid`). */
28
+ from: string;
29
+ /** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`). */
30
+ to?: string;
31
+ /** `hello` only - the display name the sender chose in the lobby. */
32
+ name?: string;
33
+ /** `offer`/`answer` only. */
34
+ sdp?: RTCSessionDescriptionInit;
35
+ /** `ice-candidate` only. */
36
+ candidate?: RTCIceCandidateInit;
37
+ }
38
+
39
+ /** What `MeshConnectionManager` needs from a signaling transport - implemented by
40
+ * `apps/shared/push/GuestSignalingClient.ts` for a real call, and by an in-memory fake for `MeshConnectionManager`'s own
41
+ * tests (see `test/webrtc/MeshConnectionManager.test.ts`), which never opens a real network connection. */
42
+ export interface SignalingChannel {
43
+ send(message: SignalMessage): void;
44
+ /** Calls `handler` with every message received on the channel (including, per the pub/sub fan-out this
45
+ * transport is built on, this same client's own sends - `MeshConnectionManager` filters those out itself
46
+ * rather than relying on the transport to). Returns the function that stops listening. */
47
+ onMessage(handler: (message: SignalMessage) => void): () => void;
48
+ }
49
+
50
+ /** The subset of `RTCRtpSender` `MeshConnectionManager.replaceLocalVideoTrack()` uses (presentation mode - see
51
+ * this plugin's Phase 2 `.claude/NOTES.md` entry: sharing replaces the outgoing camera track rather than adding a
52
+ * second video track, so no renegotiation is ever needed). A real `RTCRtpSender` already satisfies this shape. */
53
+ export interface RTCRtpSenderLike {
54
+ track: MediaStreamTrack | null;
55
+ replaceTrack(track: MediaStreamTrack | null): Promise<void>;
56
+ }
57
+
58
+ /** The subset of `RTCPeerConnection` `MeshConnectionManager` uses - what a test's fake implements, and what
59
+ * `apps/shared/webrtc/realPeerConnection.ts` adapts the real browser constructor to. Event handlers are plain settable
60
+ * properties (`RTCPeerConnection`'s own `on*` style) rather than `addEventListener`, matching the browser API
61
+ * this stands in for and keeping a test fake to the bare minimum. */
62
+ export interface RTCPeerConnectionLike {
63
+ addTrack(track: MediaStreamTrack, stream: MediaStream): RTCRtpSenderLike;
64
+ getSenders(): RTCRtpSenderLike[];
65
+ createOffer(): Promise<RTCSessionDescriptionInit>;
66
+ createAnswer(): Promise<RTCSessionDescriptionInit>;
67
+ setLocalDescription(description: RTCSessionDescriptionInit): Promise<void>;
68
+ setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void>;
69
+ addIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
70
+ close(): void;
71
+ onicecandidate: ((event: { candidate: RTCIceCandidateInit | null }) => void) | null;
72
+ ontrack: ((event: { streams: readonly MediaStream[] }) => void) | null;
73
+ onconnectionstatechange: (() => void) | null;
74
+ connectionState: string;
75
+ }
76
+
77
+ export type RTCPeerConnectionFactory = (config: { iceServers: RTCIceServer[] }) => RTCPeerConnectionLike;
78
+
79
+ /** One other participant currently known to be in the call - `MeshConnectionManager.participants` never includes
80
+ * the local participant themselves (the UI already knows its own name/uid without asking the manager). */
81
+ export interface MeshParticipant {
82
+ uid: string;
83
+ name: string;
84
+ }
85
+
86
+ export type MeshEvent =
87
+ | { type: "participant-joined"; participant: MeshParticipant }
88
+ | { type: "participant-left"; uid: string }
89
+ | { type: "remote-stream"; uid: string; stream: MediaStream }
90
+ | { type: "presenter-changed"; uid: string | undefined };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The public join/lobby/in-call page, `GET /meet/:token` - resolves an invitee's join token or a public meeting's
3
+ * slug (`BaseVideoMeetingRoute.join()`, both shapes accepted identically - see `_meetApi.ts`), then walks through
4
+ * `loading` -> `lobby` -> `in-call` -> `ended`.
5
+ *
6
+ * ## Session-based name prefill - NOT implemented, and why
7
+ *
8
+ * The spec asks this page to prefill the name field from `Profile.givenName` when the visiting browser already
9
+ * holds a RapidMX session (investigating `@rapidmx/react-shared`'s `profileApi.ts`/`session.ts` was part of this
10
+ * plugin's Phase 2 work). That turned out not to be wireable from here: `profileApi.ts`'s `getMyProfile()` needs
11
+ * an `authServerUrl` to call auth-server (a *different* origin) with, and `session.ts`'s own doc comment confirms
12
+ * `userUid`/`authServerUrl` are supplied only via server-side `fetchProps` on the `www`/admin console hosts
13
+ * (`wwwRoute`/`AdminConsoleRoute` in `@rapidmx/server`) - never on the `public` host this page is mounted on.
14
+ * `PublicPageRoute` (`@rapidmx/server`, this page's actual host route) returns only branding props; it has no
15
+ * concept of the caller's session at all. There is, today, no public-host plugin page anywhere in this codebase
16
+ * that does this, so there was no convention to extend - only two, cross-repo (`@rapidmx/server`/`react-shared`)
17
+ * ways to add one (flagged in this plugin's Phase 2 report rather than built here, both out of this frontend-only
18
+ * phase's scope):
19
+ *
20
+ * 1. Have `PublicPageRoute.fetchProps()` check the incoming `jwt` cookie itself (the way `wwwRoute` already does)
21
+ * and pass `userUid`/`authServerUrl` through like the `www`/admin hosts do.
22
+ * 2. Add a small authenticated "who am I" endpoint on this server's own origin (no `authServerUrl` needed at all),
23
+ * which this page could call with `apiFetch()` the same way any other same-origin authenticated call works.
24
+ *
25
+ * Per the spec, the name field is always left empty and editable instead - correct for the common case (most
26
+ * invitees have no RapidMX account at all), just never prefilled even for a mailbox owner testing their own link.
27
+ */
28
+ import React from "react";
29
+ export default function MeetJoinPage({ params }: {
30
+ params: {
31
+ token: string;
32
+ };
33
+ }): React.JSX.Element;
@@ -0,0 +1,101 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ /**
7
+ * The public join/lobby/in-call page, `GET /meet/:token` - resolves an invitee's join token or a public meeting's
8
+ * slug (`BaseVideoMeetingRoute.join()`, both shapes accepted identically - see `_meetApi.ts`), then walks through
9
+ * `loading` -> `lobby` -> `in-call` -> `ended`.
10
+ *
11
+ * ## Session-based name prefill - NOT implemented, and why
12
+ *
13
+ * The spec asks this page to prefill the name field from `Profile.givenName` when the visiting browser already
14
+ * holds a RapidMX session (investigating `@rapidmx/react-shared`'s `profileApi.ts`/`session.ts` was part of this
15
+ * plugin's Phase 2 work). That turned out not to be wireable from here: `profileApi.ts`'s `getMyProfile()` needs
16
+ * an `authServerUrl` to call auth-server (a *different* origin) with, and `session.ts`'s own doc comment confirms
17
+ * `userUid`/`authServerUrl` are supplied only via server-side `fetchProps` on the `www`/admin console hosts
18
+ * (`wwwRoute`/`AdminConsoleRoute` in `@rapidmx/server`) - never on the `public` host this page is mounted on.
19
+ * `PublicPageRoute` (`@rapidmx/server`, this page's actual host route) returns only branding props; it has no
20
+ * concept of the caller's session at all. There is, today, no public-host plugin page anywhere in this codebase
21
+ * that does this, so there was no convention to extend - only two, cross-repo (`@rapidmx/server`/`react-shared`)
22
+ * ways to add one (flagged in this plugin's Phase 2 report rather than built here, both out of this frontend-only
23
+ * phase's scope):
24
+ *
25
+ * 1. Have `PublicPageRoute.fetchProps()` check the incoming `jwt` cookie itself (the way `wwwRoute` already does)
26
+ * and pass `userUid`/`authServerUrl` through like the `www`/admin hosts do.
27
+ * 2. Add a small authenticated "who am I" endpoint on this server's own origin (no `authServerUrl` needed at all),
28
+ * which this page could call with `apiFetch()` the same way any other same-origin authenticated call works.
29
+ *
30
+ * Per the spec, the name field is always left empty and editable instead - correct for the common case (most
31
+ * invitees have no RapidMX account at all), just never prefilled even for a mailbox owner testing their own link.
32
+ */
33
+ import React, { useState } from "react";
34
+ import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
35
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
36
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
37
+ import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
38
+ import { default as MeetLobby } from "./_MeetLobby.js";
39
+ import CallView from "./_CallView.js";
40
+ import { joinMeeting } from "./_meetApi.js";
41
+ export default function MeetJoinPage({ params }) {
42
+ const { branding } = useBranding();
43
+ return (_jsx(MeetPageShell, { branding: branding, children: _jsx(MeetJoinContent, { token: params.token }) }));
44
+ }
45
+ function MeetJoinContent({ token }) {
46
+ const [phase, setPhase] = useState("loading");
47
+ const [joinResult, setJoinResult] = useState(null);
48
+ const [preferences, setPreferences] = useState(null);
49
+ const [loadError, setLoadError] = useState(null);
50
+ React.useEffect(() => {
51
+ let cancelled = false;
52
+ joinMeeting(token)
53
+ .then((result) => {
54
+ if (!cancelled) {
55
+ setJoinResult(result);
56
+ setPhase("lobby");
57
+ }
58
+ })
59
+ .catch((err) => {
60
+ if (cancelled) {
61
+ return;
62
+ }
63
+ if (err instanceof ApiRequestError && err.status === 404) {
64
+ setPhase("not-found");
65
+ return;
66
+ }
67
+ setLoadError(err instanceof ApiRequestError ? err.message : "Could not load this meeting.");
68
+ setPhase("not-found");
69
+ });
70
+ return () => {
71
+ cancelled = true;
72
+ };
73
+ }, [token]);
74
+ function handleJoin(prefs) {
75
+ setPreferences(prefs);
76
+ setPhase("in-call");
77
+ }
78
+ function handleLeave() {
79
+ setPhase("ended");
80
+ }
81
+ if (phase === "loading") {
82
+ return (_jsx(MeetCard, { children: _jsx("p", { className: "text-base text-text-muted", children: "Loading\u2026" }) }));
83
+ }
84
+ if (phase === "not-found") {
85
+ return (_jsx(MeetCard, { children: _jsx(Alert, { children: loadError ?? "This meeting link isn't valid." }) }));
86
+ }
87
+ if (phase === "ended") {
88
+ return (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight mb-2", children: "You left the meeting" }), _jsx("p", { className: "text-base text-text-muted", children: "You can close this page now." })] }));
89
+ }
90
+ if (phase === "in-call" && joinResult && preferences) {
91
+ return (_jsx(CallView, { channel: joinResult.meeting.uid,
92
+ // `token` is present only for the common anonymous case (`authenticated: false`) - when the
93
+ // caller's own existing session already authenticated them (`authenticated: true`), there is no
94
+ // guest token to apply as a cookie at all, and `GuestSignalingClient` relies entirely on the
95
+ // browser's own already-existing `jwt` session cookie instead (see its own doc comment and
96
+ // `VideoMeetingJoinResult`'s doc comment in `_meetApi.ts`).
97
+ token: joinResult.token, selfUid: joinResult.selfUid, selfName: preferences.name, iceServers: joinResult.iceServers, initialStream: preferences.stream, initialMicOn: preferences.micOn, initialCameraOn: preferences.cameraOn, onLeave: handleLeave }));
98
+ }
99
+ // phase === "lobby" (join Result is always set by then - see the effect above).
100
+ return (_jsx(MeetCard, { maxWidth: "max-w-4xl", children: _jsx(MeetLobby, { meeting: joinResult.meeting, onJoin: handleJoin }) }));
101
+ }
@@ -0,0 +1,18 @@
1
+ /** The in-call bottom control bar: mute/unmute, camera on/off, share screen, grid/focus toggle, leave. Deliberately
2
+ * minimal per this plugin's Phase 2 spec ("no chat, no recording, no reactions"). */
3
+ import React from "react";
4
+ export type CallViewMode = "grid" | "focus";
5
+ export interface CallControlsProps {
6
+ micOn: boolean;
7
+ onToggleMic: () => void;
8
+ cameraOn: boolean;
9
+ onToggleCamera: () => void;
10
+ isPresenting: boolean;
11
+ /** Someone else is presenting - the share button is disabled and explains why. */
12
+ presentingElsewhereName?: string;
13
+ onToggleShare: () => void;
14
+ viewMode: CallViewMode;
15
+ onToggleViewMode: () => void;
16
+ onLeave: () => void;
17
+ }
18
+ export default function CallControls({ micOn, onToggleMic, cameraOn, onToggleCamera, isPresenting, presentingElsewhereName, onToggleShare, viewMode, onToggleViewMode, onLeave, }: CallControlsProps): React.JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
3
+ export default function CallControls({ micOn, onToggleMic, cameraOn, onToggleCamera, isPresenting, presentingElsewhereName, onToggleShare, viewMode, onToggleViewMode, onLeave, }) {
4
+ const shareDisabled = !isPresenting && !!presentingElsewhereName;
5
+ return (_jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2 py-3 px-4 bg-surface border-t border-border", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onToggleMic, children: micOn ? "Mute" : "Unmute" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onToggleCamera, children: cameraOn ? "Turn camera off" : "Turn camera on" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: isPresenting ? "Stop sharing" : "Share screen" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onToggleViewMode, children: viewMode === "grid" ? "Focused view" : "Grid view" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: onLeave, children: "Leave" })] }));
6
+ }