@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,262 @@
1
+ /** The lexicographically smaller uid is always the offerer for that pair - see this module's doc comment. */
2
+ export function isOfferer(selfUid, peerUid) {
3
+ return selfUid < peerUid;
4
+ }
5
+ export class MeshConnectionManager {
6
+ constructor(options) {
7
+ this.options = options;
8
+ this.peers = new Map();
9
+ this.listeners = new Set();
10
+ this.started = false;
11
+ this.stopped = false;
12
+ }
13
+ get participants() {
14
+ return [...this.peers.values()].map((peer) => ({ uid: peer.uid, name: peer.name }));
15
+ }
16
+ get presenterUid() {
17
+ return this.currentPresenterUid;
18
+ }
19
+ onEvent(listener) {
20
+ this.listeners.add(listener);
21
+ return () => this.listeners.delete(listener);
22
+ }
23
+ /** Subscribes to the channel and announces this participant. Idempotent - a second call is a no-op. */
24
+ start() {
25
+ if (this.started) {
26
+ return;
27
+ }
28
+ this.started = true;
29
+ this.unsubscribe = this.options.channel.onMessage((message) => this.handleMessage(message));
30
+ this.sendHello();
31
+ }
32
+ /** Announces departure, closes every peer connection and unsubscribes - idempotent, and safe to call whether
33
+ * or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping `localStream`'s
34
+ * own tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns that
35
+ * stream's lifecycle, only attaches it. */
36
+ stop() {
37
+ if (this.stopped) {
38
+ return;
39
+ }
40
+ this.stopped = true;
41
+ if (this.started) {
42
+ this.send({ kind: "bye" });
43
+ }
44
+ this.unsubscribe?.();
45
+ this.unsubscribe = undefined;
46
+ for (const peer of this.peers.values()) {
47
+ peer.pc.close();
48
+ }
49
+ this.peers.clear();
50
+ this.listeners.clear();
51
+ }
52
+ /** Swaps every peer connection's outgoing video track (presentation mode) - see this module's doc comment.
53
+ * `track` is `null` to stop sending video at all (never used by this plugin's UI today, which always has a
54
+ * camera-or-screen track to fall back to, but kept correct since `RTCRtpSender.replaceTrack()` itself allows
55
+ * it). */
56
+ replaceLocalVideoTrack(track) {
57
+ for (const peer of this.peers.values()) {
58
+ const sender = peer.pc.getSenders().find((s) => s.track?.kind === "video");
59
+ void sender?.replaceTrack(track);
60
+ }
61
+ }
62
+ /** Claims presenter status for the local participant. Refuses (returns `false`, sends nothing) when someone
63
+ * else already presents - the caller (`_CallView.tsx`) uses this to disable its own "share screen" control
64
+ * rather than let a claim silently do nothing. */
65
+ claimPresenter() {
66
+ if (this.currentPresenterUid !== undefined && this.currentPresenterUid !== this.options.selfUid) {
67
+ return false;
68
+ }
69
+ this.currentPresenterUid = this.options.selfUid;
70
+ this.send({ kind: "presenter-claim" });
71
+ this.emit({ type: "presenter-changed", uid: this.currentPresenterUid });
72
+ return true;
73
+ }
74
+ /** Releases presenter status - a no-op unless the local participant currently holds it. */
75
+ releasePresenter() {
76
+ if (this.currentPresenterUid !== this.options.selfUid) {
77
+ return;
78
+ }
79
+ this.currentPresenterUid = undefined;
80
+ this.send({ kind: "presenter-release" });
81
+ this.emit({ type: "presenter-changed", uid: undefined });
82
+ }
83
+ sendHello() {
84
+ this.send({ kind: "hello", name: this.options.selfName });
85
+ }
86
+ send(partial) {
87
+ this.options.channel.send({ type: "video-meeting-signal", from: this.options.selfUid, ...partial });
88
+ }
89
+ emit(event) {
90
+ for (const listener of [...this.listeners]) {
91
+ listener(event);
92
+ }
93
+ }
94
+ handleMessage(message) {
95
+ if (message.type !== "video-meeting-signal" || message.from === this.options.selfUid) {
96
+ return;
97
+ }
98
+ if (message.to !== undefined && message.to !== this.options.selfUid) {
99
+ return;
100
+ }
101
+ switch (message.kind) {
102
+ case "hello":
103
+ this.handleHello(message);
104
+ return;
105
+ case "bye":
106
+ this.handleBye(message.from);
107
+ return;
108
+ case "offer":
109
+ void this.handleOffer(message);
110
+ return;
111
+ case "answer":
112
+ void this.handleAnswer(message);
113
+ return;
114
+ case "ice-candidate":
115
+ void this.handleIceCandidate(message);
116
+ return;
117
+ case "presenter-claim":
118
+ this.handlePresenterClaim(message.from);
119
+ return;
120
+ case "presenter-release":
121
+ this.handlePresenterRelease(message.from);
122
+ return;
123
+ }
124
+ }
125
+ handleHello(message) {
126
+ if (this.peers.has(message.from)) {
127
+ return;
128
+ }
129
+ const name = message.name ?? message.from;
130
+ const peer = this.createPeer(message.from, name);
131
+ this.emit({ type: "participant-joined", participant: { uid: peer.uid, name: peer.name } });
132
+ // Let a newcomer who couldn't have seen our own original `hello` learn about us too - see this module's
133
+ // doc comment on roster discovery.
134
+ this.sendHello();
135
+ if (isOfferer(this.options.selfUid, message.from)) {
136
+ void this.initiateOffer(peer);
137
+ }
138
+ }
139
+ handleBye(uid) {
140
+ const peer = this.peers.get(uid);
141
+ if (!peer) {
142
+ return;
143
+ }
144
+ peer.pc.close();
145
+ this.peers.delete(uid);
146
+ this.emit({ type: "participant-left", uid });
147
+ if (this.currentPresenterUid === uid) {
148
+ this.currentPresenterUid = undefined;
149
+ this.emit({ type: "presenter-changed", uid: undefined });
150
+ }
151
+ }
152
+ createPeer(uid, name) {
153
+ const pc = this.options.createPeerConnection({ iceServers: this.options.iceServers });
154
+ const peer = { uid, name, pc, remoteDescriptionSet: false, pendingCandidates: [] };
155
+ this.peers.set(uid, peer);
156
+ for (const track of this.options.localStream.getTracks()) {
157
+ pc.addTrack(track, this.options.localStream);
158
+ }
159
+ pc.onicecandidate = (event) => {
160
+ if (event.candidate) {
161
+ this.send({ kind: "ice-candidate", to: uid, candidate: event.candidate });
162
+ }
163
+ };
164
+ pc.ontrack = (event) => {
165
+ const stream = event.streams[0];
166
+ if (stream) {
167
+ this.emit({ type: "remote-stream", uid, stream });
168
+ }
169
+ };
170
+ pc.onconnectionstatechange = () => {
171
+ if (pc.connectionState === "failed" || pc.connectionState === "closed") {
172
+ this.handleBye(uid);
173
+ }
174
+ };
175
+ return peer;
176
+ }
177
+ async initiateOffer(peer) {
178
+ const offer = await peer.pc.createOffer();
179
+ await peer.pc.setLocalDescription(offer);
180
+ this.send({ kind: "offer", to: peer.uid, sdp: offer });
181
+ }
182
+ async handleOffer(message) {
183
+ if (!message.sdp) {
184
+ return;
185
+ }
186
+ let peer = this.peers.get(message.from);
187
+ const isNew = !peer;
188
+ if (!peer) {
189
+ peer = this.createPeer(message.from, message.from);
190
+ }
191
+ await peer.pc.setRemoteDescription(message.sdp);
192
+ this.flushPendingCandidates(peer);
193
+ if (isNew) {
194
+ this.emit({ type: "participant-joined", participant: { uid: peer.uid, name: peer.name } });
195
+ }
196
+ const answer = await peer.pc.createAnswer();
197
+ await peer.pc.setLocalDescription(answer);
198
+ this.send({ kind: "answer", to: peer.uid, sdp: answer });
199
+ }
200
+ async handleAnswer(message) {
201
+ const peer = this.peers.get(message.from);
202
+ if (!peer || !message.sdp) {
203
+ return;
204
+ }
205
+ await peer.pc.setRemoteDescription(message.sdp);
206
+ this.flushPendingCandidates(peer);
207
+ }
208
+ async handleIceCandidate(message) {
209
+ if (!message.candidate) {
210
+ return;
211
+ }
212
+ const peer = this.peers.get(message.from);
213
+ if (!peer) {
214
+ // No connection tracked for this peer yet (a candidate that raced ahead of its own offer/answer) -
215
+ // nothing to buffer it against, and one lost trickle-ICE candidate does not by itself break a
216
+ // connection (there are normally several). See this module's known-limitations note in the Phase 2
217
+ // report.
218
+ return;
219
+ }
220
+ if (!peer.remoteDescriptionSet) {
221
+ peer.pendingCandidates.push(message.candidate);
222
+ return;
223
+ }
224
+ await peer.pc.addIceCandidate(message.candidate);
225
+ }
226
+ flushPendingCandidates(peer) {
227
+ peer.remoteDescriptionSet = true;
228
+ const pending = peer.pendingCandidates;
229
+ peer.pendingCandidates = [];
230
+ for (const candidate of pending) {
231
+ void peer.pc.addIceCandidate(candidate);
232
+ }
233
+ }
234
+ handlePresenterClaim(from) {
235
+ if (this.currentPresenterUid === undefined) {
236
+ this.currentPresenterUid = from;
237
+ this.emit({ type: "presenter-changed", uid: from });
238
+ return;
239
+ }
240
+ if (this.currentPresenterUid === from) {
241
+ return;
242
+ }
243
+ // Collision: a claim from someone other than the presenter we already recorded. Resolve deterministically
244
+ // - see this module's doc comment - so every participant converges on the same winner. When the
245
+ // already-recorded presenter has the smaller uid, it wins and nothing changes here - including when that
246
+ // presenter is `this.options.selfUid` itself, whose own optimistic claim simply stays in place.
247
+ if (from < this.currentPresenterUid) {
248
+ const loser = this.currentPresenterUid;
249
+ this.currentPresenterUid = from;
250
+ this.emit({ type: "presenter-changed", uid: from });
251
+ if (loser === this.options.selfUid) {
252
+ this.send({ kind: "presenter-release" });
253
+ }
254
+ }
255
+ }
256
+ handlePresenterRelease(from) {
257
+ if (this.currentPresenterUid === from) {
258
+ this.currentPresenterUid = undefined;
259
+ this.emit({ type: "presenter-changed", uid: undefined });
260
+ }
261
+ }
262
+ }
@@ -0,0 +1,7 @@
1
+ /** Adapts the real browser `RTCPeerConnection` to `RTCPeerConnectionLike` (`types.ts`) - `MeshConnectionManager`'s
2
+ * own tests never use this (they inject a fake `RTCPeerConnectionLike` directly); `apps/meet/_CallView.tsx` is
3
+ * this factory's only real caller. `createBrowserPeerConnection` is a plain function value, never invoked at
4
+ * module scope, so importing this file is safe during this plugin's page SSR, where `RTCPeerConnection` does not
5
+ * exist at all - it would only throw if actually *called* outside a browser. */
6
+ import type { RTCPeerConnectionFactory } from "./types.js";
7
+ export declare const createBrowserPeerConnection: RTCPeerConnectionFactory;
@@ -0,0 +1,26 @@
1
+ export const createBrowserPeerConnection = (config) => {
2
+ const pc = new RTCPeerConnection(config);
3
+ const like = {
4
+ addTrack: (track, stream) => pc.addTrack(track, stream),
5
+ getSenders: () => pc.getSenders(),
6
+ createOffer: () => pc.createOffer(),
7
+ createAnswer: () => pc.createAnswer(),
8
+ setLocalDescription: (description) => pc.setLocalDescription(description),
9
+ setRemoteDescription: (description) => pc.setRemoteDescription(description),
10
+ addIceCandidate: (candidate) => pc.addIceCandidate(candidate),
11
+ close: () => pc.close(),
12
+ onicecandidate: null,
13
+ ontrack: null,
14
+ onconnectionstatechange: null,
15
+ get connectionState() {
16
+ return pc.connectionState;
17
+ },
18
+ };
19
+ // Forwards the real browser events to whatever handler `MeshConnectionManager` has currently assigned on
20
+ // `like` - a level of indirection needed because `like`'s own handler properties are reassigned *after* this
21
+ // adapter object is constructed and returned (see `MeshConnectionManager.createPeer()`).
22
+ pc.onicecandidate = (event) => like.onicecandidate?.({ candidate: event.candidate ? event.candidate.toJSON() : null });
23
+ pc.ontrack = (event) => like.ontrack?.({ streams: event.streams });
24
+ pc.onconnectionstatechange = () => like.onconnectionstatechange?.();
25
+ return like;
26
+ };
@@ -0,0 +1,94 @@
1
+ /** Shared types for `MeshConnectionManager.ts` and its signaling transport (`apps/shared/push/GuestSignalingClient.ts`) -
2
+ * kept in their own module so a test can depend on the shapes alone. `GuestSignalingClient` itself now also serves
3
+ * an already-authenticated real caller (`VideoMeetingJoinResult.authenticated`), not only a synthetic guest - see
4
+ * its own doc comment. */
5
+ /**
6
+ * One WebRTC signaling message, published as the JSON body of `POST /push/:meetingUid` (`BasePushRoute.send()`)
7
+ * and received back over `/push`'s WebSocket, exactly as `BaseVideoMeetingRoute`'s doc comment describes ("SDP
8
+ * offers/answers and ICE candidates are ordinary `NotificationUtils.sendMessage()` payloads"). `type` is a fixed
9
+ * literal so a listener on the meeting's channel (which will also see other frame shapes if this server ever
10
+ * grows other push consumers) can cheaply recognize and skip anything that isn't one of these.
11
+ *
12
+ * `hello`/`bye`/`presenter-claim`/`presenter-release` are broadcast (`to` left unset - every participant is
13
+ * relevant); `offer`/`answer`/`ice-candidate` are point-to-point (`to` is the intended peer's uid) - a recipient
14
+ * that isn't `to` (when set) ignores the message, since a channel's pub/sub fans every message out to every
15
+ * subscriber, sender included.
16
+ */
17
+ export interface SignalMessage {
18
+ type: "video-meeting-signal";
19
+ kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release";
20
+ /** The sender's uid - the real, already-authenticated caller's own uid when `join()` returned
21
+ * `authenticated: true`, else the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted (either way, see
22
+ * `VideoMeetingJoinResult.selfUid`). */
23
+ from: string;
24
+ /** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`). */
25
+ to?: string;
26
+ /** `hello` only - the display name the sender chose in the lobby. */
27
+ name?: string;
28
+ /** `offer`/`answer` only. */
29
+ sdp?: RTCSessionDescriptionInit;
30
+ /** `ice-candidate` only. */
31
+ candidate?: RTCIceCandidateInit;
32
+ }
33
+ /** What `MeshConnectionManager` needs from a signaling transport - implemented by
34
+ * `apps/shared/push/GuestSignalingClient.ts` for a real call, and by an in-memory fake for `MeshConnectionManager`'s own
35
+ * tests (see `test/webrtc/MeshConnectionManager.test.ts`), which never opens a real network connection. */
36
+ export interface SignalingChannel {
37
+ send(message: SignalMessage): void;
38
+ /** Calls `handler` with every message received on the channel (including, per the pub/sub fan-out this
39
+ * transport is built on, this same client's own sends - `MeshConnectionManager` filters those out itself
40
+ * rather than relying on the transport to). Returns the function that stops listening. */
41
+ onMessage(handler: (message: SignalMessage) => void): () => void;
42
+ }
43
+ /** The subset of `RTCRtpSender` `MeshConnectionManager.replaceLocalVideoTrack()` uses (presentation mode - see
44
+ * this plugin's Phase 2 `.claude/NOTES.md` entry: sharing replaces the outgoing camera track rather than adding a
45
+ * second video track, so no renegotiation is ever needed). A real `RTCRtpSender` already satisfies this shape. */
46
+ export interface RTCRtpSenderLike {
47
+ track: MediaStreamTrack | null;
48
+ replaceTrack(track: MediaStreamTrack | null): Promise<void>;
49
+ }
50
+ /** The subset of `RTCPeerConnection` `MeshConnectionManager` uses - what a test's fake implements, and what
51
+ * `apps/shared/webrtc/realPeerConnection.ts` adapts the real browser constructor to. Event handlers are plain settable
52
+ * properties (`RTCPeerConnection`'s own `on*` style) rather than `addEventListener`, matching the browser API
53
+ * this stands in for and keeping a test fake to the bare minimum. */
54
+ export interface RTCPeerConnectionLike {
55
+ addTrack(track: MediaStreamTrack, stream: MediaStream): RTCRtpSenderLike;
56
+ getSenders(): RTCRtpSenderLike[];
57
+ createOffer(): Promise<RTCSessionDescriptionInit>;
58
+ createAnswer(): Promise<RTCSessionDescriptionInit>;
59
+ setLocalDescription(description: RTCSessionDescriptionInit): Promise<void>;
60
+ setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void>;
61
+ addIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
62
+ close(): void;
63
+ onicecandidate: ((event: {
64
+ candidate: RTCIceCandidateInit | null;
65
+ }) => void) | null;
66
+ ontrack: ((event: {
67
+ streams: readonly MediaStream[];
68
+ }) => void) | null;
69
+ onconnectionstatechange: (() => void) | null;
70
+ connectionState: string;
71
+ }
72
+ export type RTCPeerConnectionFactory = (config: {
73
+ iceServers: RTCIceServer[];
74
+ }) => RTCPeerConnectionLike;
75
+ /** One other participant currently known to be in the call - `MeshConnectionManager.participants` never includes
76
+ * the local participant themselves (the UI already knows its own name/uid without asking the manager). */
77
+ export interface MeshParticipant {
78
+ uid: string;
79
+ name: string;
80
+ }
81
+ export type MeshEvent = {
82
+ type: "participant-joined";
83
+ participant: MeshParticipant;
84
+ } | {
85
+ type: "participant-left";
86
+ uid: string;
87
+ } | {
88
+ type: "remote-stream";
89
+ uid: string;
90
+ stream: MediaStream;
91
+ } | {
92
+ type: "presenter-changed";
93
+ uid: string | undefined;
94
+ };
@@ -0,0 +1,9 @@
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
+ export {};
@@ -0,0 +1,25 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Private and public WebRTC video conferencing for a `@rapidmx/restapi`-based mail server: a mailbox owner creates
7
+ * a `VideoMeeting` (private, with one join link per invitee, or public, with a single shareable link), and a
8
+ * participant - the owner or an anonymous guest holding a valid link - joins it and exchanges WebRTC signaling
9
+ * over the server's existing `/push` channel (see `BaseVideoMeetingRoute`'s class doc comment).
10
+ *
11
+ * This module exports only the backend-agnostic surface: the entity interfaces, the pure ICE-server/public-URL/
12
+ * token utilities, the abstract route, and `createSingleInviteeVideoMeeting()` - the small integration function
13
+ * another plugin (`booking-plugin`, today) calls in-process to mint a private meeting without either package
14
+ * taking a hard dependency on the other (see `util/BookingIntegrationUtils.ts`'s doc comment). The concrete
15
+ * Mongo/SQL classes a server loads (models and the route mounted at `/api/mail/video-meetings`) come from this
16
+ * package's `./mongo` and `./sql` entry points.
17
+ */
18
+ export * from "./models/types.js";
19
+ export * from "./util/BookingIntegrationUtils.js";
20
+ export * from "./util/IceServerUtils.js";
21
+ export * from "./util/PublicUrlUtils.js";
22
+ export * from "./util/RouteAccessUtils.js";
23
+ export * from "./util/TokenUtils.js";
24
+ export * from "./routes/BaseVideoMeetingRoute.js";
25
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,mEAAmE;AACnE,mCAAmC;AACnC,+EAA+E;AAC/E;;;;;;;;;;;;GAYG;AACH,cAAc,mBAAmB,CAAC;AAClC,cAAc,mCAAmC,CAAC;AAClD,cAAc,0BAA0B,CAAC;AACzC,cAAc,0BAA0B,CAAC;AACzC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AACrC,cAAc,mCAAmC,CAAC"}
@@ -0,0 +1,87 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ ///////////////////////////////////////////////////////////////////////////////
11
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
12
+ // SPDX-License-Identifier: MPL-2.0
13
+ ///////////////////////////////////////////////////////////////////////////////
14
+ import { ObjectDecorators } from "@rapidrest/core";
15
+ import { BaseMongoEntity, DocDecorators, ModelDecorators, PersistenceDecorators } from "@rapidrest/service-core";
16
+ import { MailboxScopedData } from "@rapidmx/restapi";
17
+ const { Description } = DocDecorators;
18
+ const { DataStore, Protect } = ModelDecorators;
19
+ const { Column, Entity, Index } = PersistenceDecorators;
20
+ const { Nullable } = ObjectDecorators;
21
+ /**
22
+ * Implementation of the `VideoMeetingInvitee` interface for storage in a MongoDB database. If SQL is desired,
23
+ * please use `models.sql.VideoMeetingInviteeSQL` instead.
24
+ *
25
+ * @author Jean-Philippe Steinmetz
26
+ */
27
+ let VideoMeetingInviteeMongo = class VideoMeetingInviteeMongo extends BaseMongoEntity {
28
+ constructor(other) {
29
+ super(other);
30
+ this.meetingUid = "";
31
+ this.mailboxUid = "";
32
+ this.email = "";
33
+ this.joinToken = "";
34
+ if (other) {
35
+ this.meetingUid = other.meetingUid !== undefined ? other.meetingUid : this.meetingUid;
36
+ this.mailboxUid = other.mailboxUid !== undefined ? other.mailboxUid : this.mailboxUid;
37
+ this.email = other.email !== undefined ? other.email : this.email;
38
+ this.displayName = "displayName" in other ? other.displayName : this.displayName;
39
+ this.joinToken = other.joinToken !== undefined ? other.joinToken : this.joinToken;
40
+ }
41
+ }
42
+ };
43
+ __decorate([
44
+ Column(),
45
+ Description("The unique identifier of the `VideoMeeting` this invitee belongs to."),
46
+ __metadata("design:type", String)
47
+ ], VideoMeetingInviteeMongo.prototype, "meetingUid", void 0);
48
+ __decorate([
49
+ Column(),
50
+ Description("The unique identifier of the host `Mailbox`, denormalized from the meeting."),
51
+ __metadata("design:type", String)
52
+ ], VideoMeetingInviteeMongo.prototype, "mailboxUid", void 0);
53
+ __decorate([
54
+ Column(),
55
+ Description("The invitee's email address, normalized to lowercase."),
56
+ __metadata("design:type", String)
57
+ ], VideoMeetingInviteeMongo.prototype, "email", void 0);
58
+ __decorate([
59
+ Column({ nullable: true }),
60
+ Description("The invitee's display name, if supplied."),
61
+ Nullable,
62
+ __metadata("design:type", String)
63
+ ], VideoMeetingInviteeMongo.prototype, "displayName", void 0);
64
+ __decorate([
65
+ Column(),
66
+ Description("The unguessable token embedded in this invitee's join link. Minted server-side, immutable."),
67
+ __metadata("design:type", String)
68
+ ], VideoMeetingInviteeMongo.prototype, "joinToken", void 0);
69
+ VideoMeetingInviteeMongo = __decorate([
70
+ DataStore("mongo"),
71
+ Entity(),
72
+ MailboxScopedData(),
73
+ Description("One invited participant of a PRIVATE VideoMeeting, identified by an unguessable join token."),
74
+ Index("videomeetinginvitee_join_token", ["joinToken"], { unique: true }),
75
+ Index("videomeetinginvitee_meeting", ["meetingUid"]),
76
+ Index("videomeetinginvitee_mailbox", ["mailboxUid"]),
77
+ Protect({
78
+ uid: "VideoMeetingInvitee",
79
+ records: [
80
+ { userOrRoleId: "anonymous", actions: [] },
81
+ { userOrRoleId: ".*", actions: [] },
82
+ ],
83
+ }, false),
84
+ __metadata("design:paramtypes", [Object])
85
+ ], VideoMeetingInviteeMongo);
86
+ export { VideoMeetingInviteeMongo };
87
+ //# sourceMappingURL=VideoMeetingInviteeMongo.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"VideoMeetingInviteeMongo.js","sourceRoot":"","sources":["../../../../src/models/mongo/VideoMeetingInviteeMongo.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,126 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ ///////////////////////////////////////////////////////////////////////////////
11
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
12
+ // SPDX-License-Identifier: MPL-2.0
13
+ ///////////////////////////////////////////////////////////////////////////////
14
+ import { ObjectDecorators } from "@rapidrest/core";
15
+ import { BaseMongoEntity, DocDecorators, ModelDecorators, PersistenceDecorators } from "@rapidrest/service-core";
16
+ import { MailboxScopedData } from "@rapidmx/restapi";
17
+ import { VideoMeetingStatus, VideoMeetingVisibility } from "../types.js";
18
+ const { Description } = DocDecorators;
19
+ const { DataStore, Protect } = ModelDecorators;
20
+ const { Column, Entity, Index } = PersistenceDecorators;
21
+ const { Nullable } = ObjectDecorators;
22
+ /**
23
+ * Implementation of the `VideoMeeting` interface for storage in a MongoDB database. If SQL is desired, please use
24
+ * `models.sql.VideoMeetingSQL` instead.
25
+ *
26
+ * `recordACL: true` - see the `VideoMeeting` interface's own doc comment for why this entity, unlike most of this
27
+ * codebase's mailbox-scoped data, gets its own per-record `AccessControlList`.
28
+ *
29
+ * @author Jean-Philippe Steinmetz
30
+ */
31
+ let VideoMeetingMongo = class VideoMeetingMongo extends BaseMongoEntity {
32
+ constructor(other) {
33
+ super(other);
34
+ this.mailboxUid = "";
35
+ this.title = "";
36
+ this.visibility = VideoMeetingVisibility.PRIVATE;
37
+ this.status = VideoMeetingStatus.SCHEDULED;
38
+ if (other) {
39
+ this.mailboxUid = other.mailboxUid !== undefined ? other.mailboxUid : this.mailboxUid;
40
+ this.calendarEventUid = "calendarEventUid" in other ? other.calendarEventUid : this.calendarEventUid;
41
+ this.title = other.title !== undefined ? other.title : this.title;
42
+ this.visibility = other.visibility !== undefined ? other.visibility : this.visibility;
43
+ this.publicSlug = "publicSlug" in other ? other.publicSlug : this.publicSlug;
44
+ this.organizerSlug = "organizerSlug" in other ? other.organizerSlug : this.organizerSlug;
45
+ this.status = other.status !== undefined ? other.status : this.status;
46
+ this.startTime = "startTime" in other ? other.startTime : this.startTime;
47
+ this.endTime = "endTime" in other ? other.endTime : this.endTime;
48
+ }
49
+ }
50
+ };
51
+ __decorate([
52
+ Column(),
53
+ Description("The unique identifier of the `Mailbox` that owns this meeting."),
54
+ __metadata("design:type", String)
55
+ ], VideoMeetingMongo.prototype, "mailboxUid", void 0);
56
+ __decorate([
57
+ Column({ nullable: true }),
58
+ Description("The unique identifier of the `CalendarEvent` this meeting was minted for, if any."),
59
+ Nullable,
60
+ __metadata("design:type", String)
61
+ ], VideoMeetingMongo.prototype, "calendarEventUid", void 0);
62
+ __decorate([
63
+ Column(),
64
+ Description("The meeting's display title."),
65
+ __metadata("design:type", String)
66
+ ], VideoMeetingMongo.prototype, "title", void 0);
67
+ __decorate([
68
+ Column(),
69
+ Description("Whether this meeting is joinable only by its invitees or by anyone holding its public link."),
70
+ __metadata("design:type", String)
71
+ ], VideoMeetingMongo.prototype, "visibility", void 0);
72
+ __decorate([
73
+ Column({ nullable: true }),
74
+ Description("The public join link's unique identifier, set only when visibility is PUBLIC."),
75
+ Nullable,
76
+ __metadata("design:type", String)
77
+ ], VideoMeetingMongo.prototype, "publicSlug", void 0);
78
+ __decorate([
79
+ Column({ nullable: true }),
80
+ Description("The organizer's own join link identifier, set only when visibility is PRIVATE."),
81
+ Nullable,
82
+ __metadata("design:type", String)
83
+ ], VideoMeetingMongo.prototype, "organizerSlug", void 0);
84
+ __decorate([
85
+ Column(),
86
+ Description("The current lifecycle state of this meeting."),
87
+ __metadata("design:type", String)
88
+ ], VideoMeetingMongo.prototype, "status", void 0);
89
+ __decorate([
90
+ Column({ nullable: true }),
91
+ Description("Informational only: not used for any availability or conflict checking."),
92
+ Nullable,
93
+ __metadata("design:type", Date)
94
+ ], VideoMeetingMongo.prototype, "startTime", void 0);
95
+ __decorate([
96
+ Column({ nullable: true }),
97
+ Description("Informational only: not used for any availability or conflict checking."),
98
+ Nullable,
99
+ __metadata("design:type", Date)
100
+ ], VideoMeetingMongo.prototype, "endTime", void 0);
101
+ VideoMeetingMongo = __decorate([
102
+ DataStore("mongo"),
103
+ Entity(),
104
+ MailboxScopedData(),
105
+ Description("A WebRTC video meeting, joinable directly or from a calendar invite."),
106
+ Index("videomeeting_mailbox", ["mailboxUid"])
107
+ // Single-field, not `["mailboxUid", "publicSlug"]`: a compound sparse index still indexes a document carrying at
108
+ // least one of its keys, so every document has `mailboxUid` and would be indexed regardless of whether `publicSlug`
109
+ // is set - two private meetings in the same mailbox (both missing `publicSlug`) would then collide on `(mailboxUid,
110
+ // null)`, rejecting the second one outright. A single-field sparse index skips a document missing the field
111
+ // entirely, matching the global (not per-mailbox) lookup `join()` actually performs - see `VideoMeeting.publicSlug`'s
112
+ // own doc comment on that tradeoff. `organizerSlug` below hit the exact same pitfall and is fixed the same way.
113
+ ,
114
+ Index("videomeeting_public_slug", ["publicSlug"], { unique: true, sparse: true }),
115
+ Index("videomeeting_organizer_slug", ["organizerSlug"], { unique: true, sparse: true }),
116
+ Protect({
117
+ uid: "VideoMeeting",
118
+ records: [
119
+ { userOrRoleId: "anonymous", actions: [] },
120
+ { userOrRoleId: ".*", actions: [] },
121
+ ],
122
+ }, true),
123
+ __metadata("design:paramtypes", [Object])
124
+ ], VideoMeetingMongo);
125
+ export { VideoMeetingMongo };
126
+ //# sourceMappingURL=VideoMeetingMongo.js.map