@edge-base/react-native 0.1.5 → 0.2.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/dist/index.js CHANGED
@@ -752,7 +752,7 @@ var DatabaseLiveClient = class {
752
752
  (refreshToken) => refreshAccessToken(this.baseUrl, refreshToken)
753
753
  );
754
754
  if (!token) throw new EdgeBaseError(401, "No access token available. Sign in first.");
755
- this.sendRaw({ type: "auth", token, sdkVersion: "0.1.5" });
755
+ this.sendRaw({ type: "auth", token, sdkVersion: "0.2.0" });
756
756
  return new Promise((resolve, reject) => {
757
757
  const timeout = setTimeout(() => reject(new EdgeBaseError(401, "Auth timeout")), 1e4);
758
758
  const original = this.ws?.onmessage;
@@ -872,7 +872,7 @@ var DatabaseLiveClient = class {
872
872
  refreshAuth() {
873
873
  const token = this.tokenManager.currentAccessToken;
874
874
  if (!token || !this.ws || !this.connected) return;
875
- this.sendRaw({ type: "auth", token, sdkVersion: "0.1.5" });
875
+ this.sendRaw({ type: "auth", token, sdkVersion: "0.2.0" });
876
876
  }
877
877
  handleAuthStateChange(user) {
878
878
  if (user) {
@@ -935,7 +935,9 @@ var DatabaseLiveClient = class {
935
935
  }
936
936
  }
937
937
  scheduleReconnect(channel) {
938
- const delay = this.options.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts);
938
+ const baseDelay = this.options.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts);
939
+ const jitter = Math.random() * baseDelay * 0.25;
940
+ const delay = baseDelay + jitter;
939
941
  this.reconnectAttempts++;
940
942
  setTimeout(() => {
941
943
  this.connect(channel).catch(() => {
@@ -1011,11 +1013,1119 @@ function matchesDatabaseLiveChannel(channel, change, messageChannel) {
1011
1013
  if (parts.length === 2) return parts[1] === change.table;
1012
1014
  if (parts.length === 3) return parts[2] === change.table;
1013
1015
  if (parts.length === 4) {
1014
- if (parts[2] === change.table) return change.docId === parts[3];
1016
+ if (parts[2] === change.table && change.docId === parts[3]) return true;
1015
1017
  return parts[3] === change.table;
1016
1018
  }
1017
1019
  return parts[3] === change.table && change.docId === parts[4];
1018
1020
  }
1021
+
1022
+ // src/room-cloudflare-media.ts
1023
+ function buildRemoteTrackKey(participantId, kind) {
1024
+ return `${participantId}:${kind}`;
1025
+ }
1026
+ function installMessage(packageName) {
1027
+ return `Install ${packageName} to use React Native room media transport. See https://edgebase.fun/docs/room/media`;
1028
+ }
1029
+ var RoomCloudflareMediaTransport = class {
1030
+ room;
1031
+ options;
1032
+ localTracks = /* @__PURE__ */ new Map();
1033
+ remoteTrackHandlers = [];
1034
+ participantListeners = /* @__PURE__ */ new Map();
1035
+ remoteTrackIds = /* @__PURE__ */ new Map();
1036
+ clientFactoryPromise = null;
1037
+ connectPromise = null;
1038
+ lifecycleVersion = 0;
1039
+ meeting = null;
1040
+ sessionId = null;
1041
+ joinedMapSubscriptionsAttached = false;
1042
+ onParticipantJoined = (participant) => {
1043
+ this.attachParticipant(participant);
1044
+ };
1045
+ onParticipantLeft = (participant) => {
1046
+ this.detachParticipant(participant.id);
1047
+ };
1048
+ onParticipantsCleared = () => {
1049
+ this.clearParticipantListeners();
1050
+ };
1051
+ onParticipantsUpdate = () => {
1052
+ this.syncAllParticipants();
1053
+ };
1054
+ constructor(room, options) {
1055
+ this.room = room;
1056
+ this.options = {
1057
+ autoSubscribe: options?.autoSubscribe ?? true,
1058
+ mediaDevices: options?.mediaDevices ?? (typeof navigator !== "undefined" ? navigator.mediaDevices : void 0),
1059
+ clientFactory: options?.clientFactory
1060
+ };
1061
+ }
1062
+ getSessionId() {
1063
+ return this.sessionId;
1064
+ }
1065
+ getPeerConnection() {
1066
+ return null;
1067
+ }
1068
+ async connect(payload) {
1069
+ if (this.meeting && this.sessionId) {
1070
+ return this.sessionId;
1071
+ }
1072
+ if (this.connectPromise) {
1073
+ return this.connectPromise;
1074
+ }
1075
+ const connectPromise = (async () => {
1076
+ const lifecycleVersion = this.lifecycleVersion;
1077
+ const session = await this.room.media.cloudflareRealtimeKit.createSession(payload);
1078
+ this.assertConnectStillActive(lifecycleVersion);
1079
+ const factory = await this.resolveClientFactory();
1080
+ this.assertConnectStillActive(lifecycleVersion);
1081
+ const meeting = await factory.init({
1082
+ authToken: session.authToken,
1083
+ defaults: {
1084
+ audio: false,
1085
+ video: false
1086
+ }
1087
+ });
1088
+ this.assertConnectStillActive(lifecycleVersion, meeting);
1089
+ this.meeting = meeting;
1090
+ this.sessionId = session.sessionId;
1091
+ this.attachParticipantMapListeners();
1092
+ try {
1093
+ if (meeting.join) {
1094
+ await meeting.join();
1095
+ } else if (meeting.joinRoom) {
1096
+ await meeting.joinRoom();
1097
+ } else {
1098
+ throw new Error("RealtimeKit client does not expose join()/joinRoom().");
1099
+ }
1100
+ this.assertConnectStillActive(lifecycleVersion, meeting);
1101
+ this.syncAllParticipants();
1102
+ return session.sessionId;
1103
+ } catch (error) {
1104
+ if (this.meeting === meeting) {
1105
+ this.cleanupMeeting();
1106
+ } else {
1107
+ this.leaveMeetingSilently(meeting);
1108
+ }
1109
+ throw error;
1110
+ }
1111
+ })();
1112
+ this.connectPromise = connectPromise;
1113
+ try {
1114
+ return await connectPromise;
1115
+ } finally {
1116
+ if (this.connectPromise === connectPromise) {
1117
+ this.connectPromise = null;
1118
+ }
1119
+ }
1120
+ }
1121
+ async enableAudio(constraints = true) {
1122
+ const meeting = await this.ensureMeeting();
1123
+ const customTrack = await this.createUserMediaTrack("audio", constraints);
1124
+ await meeting.self.enableAudio(customTrack ?? void 0);
1125
+ const track = meeting.self.audioTrack ?? customTrack;
1126
+ if (!track) {
1127
+ throw new Error("RealtimeKit did not expose a local audio track after enabling audio.");
1128
+ }
1129
+ this.rememberLocalTrack("audio", track, track.getSettings().deviceId ?? customTrack?.getSettings().deviceId, !!customTrack);
1130
+ await this.room.media.audio.enable?.({
1131
+ trackId: track.id,
1132
+ deviceId: this.localTracks.get("audio")?.deviceId,
1133
+ providerSessionId: meeting.self.id
1134
+ });
1135
+ return track;
1136
+ }
1137
+ async enableVideo(constraints = true) {
1138
+ const meeting = await this.ensureMeeting();
1139
+ const customTrack = await this.createUserMediaTrack("video", constraints);
1140
+ await meeting.self.enableVideo(customTrack ?? void 0);
1141
+ const track = meeting.self.videoTrack ?? customTrack;
1142
+ if (!track) {
1143
+ throw new Error("RealtimeKit did not expose a local video track after enabling video.");
1144
+ }
1145
+ this.rememberLocalTrack("video", track, track.getSettings().deviceId ?? customTrack?.getSettings().deviceId, !!customTrack);
1146
+ await this.room.media.video.enable?.({
1147
+ trackId: track.id,
1148
+ deviceId: this.localTracks.get("video")?.deviceId,
1149
+ providerSessionId: meeting.self.id
1150
+ });
1151
+ return track;
1152
+ }
1153
+ async startScreenShare(_constraints = { video: true, audio: false }) {
1154
+ const meeting = await this.ensureMeeting();
1155
+ await meeting.self.enableScreenShare();
1156
+ const track = meeting.self.screenShareTracks?.video;
1157
+ if (!track) {
1158
+ throw new Error("RealtimeKit did not expose a screen-share video track.");
1159
+ }
1160
+ track.addEventListener("ended", () => {
1161
+ void this.stopScreenShare();
1162
+ }, { once: true });
1163
+ this.rememberLocalTrack("screen", track, track.getSettings().deviceId, false);
1164
+ await this.room.media.screen.start?.({
1165
+ trackId: track.id,
1166
+ deviceId: track.getSettings().deviceId,
1167
+ providerSessionId: meeting.self.id
1168
+ });
1169
+ return track;
1170
+ }
1171
+ async disableAudio() {
1172
+ if (!this.meeting) return;
1173
+ await this.meeting.self.disableAudio();
1174
+ this.releaseLocalTrack("audio");
1175
+ await this.room.media.audio.disable();
1176
+ }
1177
+ async disableVideo() {
1178
+ if (!this.meeting) return;
1179
+ await this.meeting.self.disableVideo();
1180
+ this.releaseLocalTrack("video");
1181
+ await this.room.media.video.disable();
1182
+ }
1183
+ async stopScreenShare() {
1184
+ if (!this.meeting) return;
1185
+ await this.meeting.self.disableScreenShare();
1186
+ this.releaseLocalTrack("screen");
1187
+ await this.room.media.screen.stop();
1188
+ }
1189
+ async setMuted(kind, muted) {
1190
+ const localTrack = this.localTracks.get(kind)?.track ?? (kind === "audio" ? this.meeting?.self.audioTrack : this.meeting?.self.videoTrack);
1191
+ if (localTrack) {
1192
+ localTrack.enabled = !muted;
1193
+ }
1194
+ if (kind === "audio") {
1195
+ await this.room.media.audio.setMuted?.(muted);
1196
+ } else {
1197
+ await this.room.media.video.setMuted?.(muted);
1198
+ }
1199
+ }
1200
+ async switchDevices(payload) {
1201
+ const meeting = await this.ensureMeeting();
1202
+ if (payload.audioInputId) {
1203
+ const audioDevice = await meeting.self.getDeviceById(payload.audioInputId, "audio");
1204
+ await meeting.self.setDevice(audioDevice);
1205
+ const audioTrack = meeting.self.audioTrack;
1206
+ if (audioTrack) {
1207
+ this.rememberLocalTrack("audio", audioTrack, payload.audioInputId, false);
1208
+ }
1209
+ }
1210
+ if (payload.videoInputId) {
1211
+ const videoDevice = await meeting.self.getDeviceById(payload.videoInputId, "video");
1212
+ await meeting.self.setDevice(videoDevice);
1213
+ const videoTrack = meeting.self.videoTrack;
1214
+ if (videoTrack) {
1215
+ this.rememberLocalTrack("video", videoTrack, payload.videoInputId, false);
1216
+ }
1217
+ }
1218
+ await this.room.media.devices.switch(payload);
1219
+ }
1220
+ onRemoteTrack(handler) {
1221
+ this.remoteTrackHandlers.push(handler);
1222
+ return {
1223
+ unsubscribe: () => {
1224
+ const index = this.remoteTrackHandlers.indexOf(handler);
1225
+ if (index >= 0) {
1226
+ this.remoteTrackHandlers.splice(index, 1);
1227
+ }
1228
+ }
1229
+ };
1230
+ }
1231
+ destroy() {
1232
+ this.lifecycleVersion += 1;
1233
+ this.connectPromise = null;
1234
+ for (const kind of this.localTracks.keys()) {
1235
+ this.releaseLocalTrack(kind);
1236
+ }
1237
+ this.clearParticipantListeners();
1238
+ this.detachParticipantMapListeners();
1239
+ this.cleanupMeeting();
1240
+ }
1241
+ async ensureMeeting() {
1242
+ if (!this.meeting) {
1243
+ await this.connect();
1244
+ }
1245
+ if (!this.meeting) {
1246
+ throw new Error("Cloudflare media transport is not connected");
1247
+ }
1248
+ return this.meeting;
1249
+ }
1250
+ async resolveClientFactory() {
1251
+ if (this.options.clientFactory) {
1252
+ return this.options.clientFactory;
1253
+ }
1254
+ this.clientFactoryPromise ??= import('@cloudflare/realtimekit-react-native').then((mod) => mod.default ?? mod).catch((error) => {
1255
+ throw new Error(`${installMessage("@cloudflare/realtimekit-react-native")}
1256
+ ${String(error)}`);
1257
+ });
1258
+ return this.clientFactoryPromise;
1259
+ }
1260
+ async createUserMediaTrack(kind, constraints) {
1261
+ const devices = this.options.mediaDevices;
1262
+ if (!devices?.getUserMedia || constraints === false) {
1263
+ return null;
1264
+ }
1265
+ const stream = await devices.getUserMedia(
1266
+ kind === "audio" ? { audio: constraints, video: false } : { audio: false, video: constraints }
1267
+ );
1268
+ return kind === "audio" ? stream.getAudioTracks()[0] ?? null : stream.getVideoTracks()[0] ?? null;
1269
+ }
1270
+ rememberLocalTrack(kind, track, deviceId, stopOnCleanup) {
1271
+ this.releaseLocalTrack(kind);
1272
+ this.localTracks.set(kind, {
1273
+ kind,
1274
+ track,
1275
+ deviceId,
1276
+ stopOnCleanup
1277
+ });
1278
+ }
1279
+ releaseLocalTrack(kind) {
1280
+ const local = this.localTracks.get(kind);
1281
+ if (!local) return;
1282
+ if (local.stopOnCleanup) {
1283
+ local.track.stop();
1284
+ }
1285
+ this.localTracks.delete(kind);
1286
+ }
1287
+ attachParticipantMapListeners() {
1288
+ const participantMap = this.getParticipantMap();
1289
+ if (!participantMap || !this.meeting || this.joinedMapSubscriptionsAttached) {
1290
+ return;
1291
+ }
1292
+ participantMap.on("participantJoined", this.onParticipantJoined);
1293
+ participantMap.on("participantLeft", this.onParticipantLeft);
1294
+ participantMap.on("participantsCleared", this.onParticipantsCleared);
1295
+ participantMap.on("participantsUpdate", this.onParticipantsUpdate);
1296
+ this.joinedMapSubscriptionsAttached = true;
1297
+ }
1298
+ detachParticipantMapListeners() {
1299
+ const participantMap = this.getParticipantMap();
1300
+ if (!participantMap || !this.meeting || !this.joinedMapSubscriptionsAttached) {
1301
+ return;
1302
+ }
1303
+ participantMap.off("participantJoined", this.onParticipantJoined);
1304
+ participantMap.off("participantLeft", this.onParticipantLeft);
1305
+ participantMap.off("participantsCleared", this.onParticipantsCleared);
1306
+ participantMap.off("participantsUpdate", this.onParticipantsUpdate);
1307
+ this.joinedMapSubscriptionsAttached = false;
1308
+ }
1309
+ syncAllParticipants() {
1310
+ const participantMap = this.getParticipantMap();
1311
+ if (!participantMap || !this.meeting || !this.options.autoSubscribe) {
1312
+ return;
1313
+ }
1314
+ for (const participant of participantMap.values()) {
1315
+ this.attachParticipant(participant);
1316
+ }
1317
+ }
1318
+ getParticipantMap() {
1319
+ if (!this.meeting) {
1320
+ return null;
1321
+ }
1322
+ return this.meeting.participants.active ?? this.meeting.participants.joined ?? null;
1323
+ }
1324
+ attachParticipant(participant) {
1325
+ if (!this.options.autoSubscribe || !this.meeting) {
1326
+ return;
1327
+ }
1328
+ if (participant.id === this.meeting.self.id || this.participantListeners.has(participant.id)) {
1329
+ this.syncParticipantTracks(participant);
1330
+ return;
1331
+ }
1332
+ const listenerSet = {
1333
+ participant,
1334
+ onAudioUpdate: ({ audioEnabled, audioTrack }) => {
1335
+ this.handleRemoteTrackUpdate("audio", participant, audioTrack, audioEnabled);
1336
+ },
1337
+ onVideoUpdate: ({ videoEnabled, videoTrack }) => {
1338
+ this.handleRemoteTrackUpdate("video", participant, videoTrack, videoEnabled);
1339
+ },
1340
+ onScreenShareUpdate: ({ screenShareEnabled, screenShareTracks }) => {
1341
+ this.handleRemoteTrackUpdate("screen", participant, screenShareTracks.video, screenShareEnabled);
1342
+ }
1343
+ };
1344
+ participant.on("audioUpdate", listenerSet.onAudioUpdate);
1345
+ participant.on("videoUpdate", listenerSet.onVideoUpdate);
1346
+ participant.on("screenShareUpdate", listenerSet.onScreenShareUpdate);
1347
+ this.participantListeners.set(participant.id, listenerSet);
1348
+ this.syncParticipantTracks(participant);
1349
+ }
1350
+ detachParticipant(participantId) {
1351
+ const listenerSet = this.participantListeners.get(participantId);
1352
+ if (!listenerSet) return;
1353
+ listenerSet.participant.off("audioUpdate", listenerSet.onAudioUpdate);
1354
+ listenerSet.participant.off("videoUpdate", listenerSet.onVideoUpdate);
1355
+ listenerSet.participant.off("screenShareUpdate", listenerSet.onScreenShareUpdate);
1356
+ this.participantListeners.delete(participantId);
1357
+ for (const kind of ["audio", "video", "screen"]) {
1358
+ this.remoteTrackIds.delete(buildRemoteTrackKey(participantId, kind));
1359
+ }
1360
+ }
1361
+ clearParticipantListeners() {
1362
+ for (const participantId of Array.from(this.participantListeners.keys())) {
1363
+ this.detachParticipant(participantId);
1364
+ }
1365
+ this.remoteTrackIds.clear();
1366
+ }
1367
+ syncParticipantTracks(participant) {
1368
+ this.handleRemoteTrackUpdate("audio", participant, participant.audioTrack, participant.audioEnabled === true);
1369
+ this.handleRemoteTrackUpdate("video", participant, participant.videoTrack, participant.videoEnabled === true);
1370
+ this.handleRemoteTrackUpdate("screen", participant, participant.screenShareTracks?.video, participant.screenShareEnabled === true);
1371
+ }
1372
+ handleRemoteTrackUpdate(kind, participant, track, enabled) {
1373
+ const key = buildRemoteTrackKey(participant.id, kind);
1374
+ if (!enabled || !track) {
1375
+ this.remoteTrackIds.delete(key);
1376
+ return;
1377
+ }
1378
+ const previousTrackId = this.remoteTrackIds.get(key);
1379
+ if (previousTrackId === track.id) {
1380
+ return;
1381
+ }
1382
+ this.remoteTrackIds.set(key, track.id);
1383
+ const payload = {
1384
+ kind,
1385
+ track,
1386
+ stream: new MediaStream([track]),
1387
+ trackName: track.id,
1388
+ providerSessionId: participant.id,
1389
+ participantId: participant.id,
1390
+ customParticipantId: participant.customParticipantId,
1391
+ userId: participant.userId
1392
+ };
1393
+ for (const handler of this.remoteTrackHandlers) {
1394
+ handler(payload);
1395
+ }
1396
+ }
1397
+ cleanupMeeting() {
1398
+ const meeting = this.meeting;
1399
+ this.detachParticipantMapListeners();
1400
+ this.clearParticipantListeners();
1401
+ this.meeting = null;
1402
+ this.sessionId = null;
1403
+ this.leaveMeetingSilently(meeting);
1404
+ }
1405
+ assertConnectStillActive(lifecycleVersion, meeting) {
1406
+ if (lifecycleVersion === this.lifecycleVersion) {
1407
+ return;
1408
+ }
1409
+ if (meeting) {
1410
+ this.leaveMeetingSilently(meeting);
1411
+ }
1412
+ throw new Error("Cloudflare media transport was destroyed during connect.");
1413
+ }
1414
+ leaveMeetingSilently(meeting) {
1415
+ if (!meeting) {
1416
+ return;
1417
+ }
1418
+ const leavePromise = meeting.leave?.() ?? meeting.leaveRoom?.();
1419
+ if (leavePromise) {
1420
+ void leavePromise.catch(() => {
1421
+ });
1422
+ }
1423
+ }
1424
+ };
1425
+
1426
+ // src/room-p2p-media.ts
1427
+ var DEFAULT_SIGNAL_PREFIX = "edgebase.media.p2p";
1428
+ var DEFAULT_ICE_SERVERS = [
1429
+ { urls: "stun:stun.l.google.com:19302" }
1430
+ ];
1431
+ var DEFAULT_MEMBER_READY_TIMEOUT_MS = 1e4;
1432
+ function buildTrackKey(memberId, trackId) {
1433
+ return `${memberId}:${trackId}`;
1434
+ }
1435
+ function buildExactDeviceConstraint(deviceId) {
1436
+ return { deviceId: { exact: deviceId } };
1437
+ }
1438
+ function normalizeTrackKind(track) {
1439
+ if (track.kind === "audio") return "audio";
1440
+ if (track.kind === "video") return "video";
1441
+ return null;
1442
+ }
1443
+ function serializeDescription(description) {
1444
+ return {
1445
+ type: description.type,
1446
+ sdp: description.sdp ?? void 0
1447
+ };
1448
+ }
1449
+ function serializeCandidate(candidate) {
1450
+ if ("toJSON" in candidate && typeof candidate.toJSON === "function") {
1451
+ return candidate.toJSON();
1452
+ }
1453
+ return candidate;
1454
+ }
1455
+ function installMessage2(packageName) {
1456
+ return `Install ${packageName} to use React Native P2P media transport. See https://edgebase.fun/docs/room/media`;
1457
+ }
1458
+ var RoomP2PMediaTransport = class {
1459
+ room;
1460
+ options;
1461
+ localTracks = /* @__PURE__ */ new Map();
1462
+ peers = /* @__PURE__ */ new Map();
1463
+ remoteTrackHandlers = [];
1464
+ remoteTrackKinds = /* @__PURE__ */ new Map();
1465
+ emittedRemoteTracks = /* @__PURE__ */ new Set();
1466
+ pendingRemoteTracks = /* @__PURE__ */ new Map();
1467
+ subscriptions = [];
1468
+ localMemberId = null;
1469
+ connected = false;
1470
+ runtimePromise = null;
1471
+ constructor(room, options) {
1472
+ this.room = room;
1473
+ this.options = {
1474
+ rtcConfiguration: {
1475
+ ...options?.rtcConfiguration,
1476
+ iceServers: options?.rtcConfiguration?.iceServers && options.rtcConfiguration.iceServers.length > 0 ? options.rtcConfiguration.iceServers : DEFAULT_ICE_SERVERS
1477
+ },
1478
+ peerConnectionFactory: options?.peerConnectionFactory,
1479
+ mediaDevices: options?.mediaDevices,
1480
+ signalPrefix: options?.signalPrefix ?? DEFAULT_SIGNAL_PREFIX
1481
+ };
1482
+ }
1483
+ getSessionId() {
1484
+ return this.localMemberId;
1485
+ }
1486
+ getPeerConnection() {
1487
+ if (this.peers.size !== 1) {
1488
+ return null;
1489
+ }
1490
+ return this.peers.values().next().value?.pc ?? null;
1491
+ }
1492
+ async connect(payload) {
1493
+ if (this.connected && this.localMemberId) {
1494
+ return this.localMemberId;
1495
+ }
1496
+ if (payload && typeof payload === "object" && "sessionDescription" in payload) {
1497
+ throw new Error(
1498
+ "RoomP2PMediaTransport.connect() does not accept sessionDescription; use room.signals through the built-in transport instead."
1499
+ );
1500
+ }
1501
+ await this.ensureRuntime();
1502
+ const currentMember = await this.waitForCurrentMember();
1503
+ if (!currentMember) {
1504
+ throw new Error("Join the room before connecting a P2P media transport.");
1505
+ }
1506
+ this.localMemberId = currentMember.memberId;
1507
+ this.connected = true;
1508
+ this.hydrateRemoteTrackKinds();
1509
+ this.attachRoomSubscriptions();
1510
+ try {
1511
+ for (const member of this.room.members.list()) {
1512
+ if (member.memberId !== this.localMemberId) {
1513
+ this.ensurePeer(member.memberId);
1514
+ }
1515
+ }
1516
+ } catch (error) {
1517
+ this.rollbackConnectedState();
1518
+ throw error;
1519
+ }
1520
+ return this.localMemberId;
1521
+ }
1522
+ async waitForCurrentMember(timeoutMs = DEFAULT_MEMBER_READY_TIMEOUT_MS) {
1523
+ const startedAt = Date.now();
1524
+ while (Date.now() - startedAt < timeoutMs) {
1525
+ const member = this.room.members.current();
1526
+ if (member) {
1527
+ return member;
1528
+ }
1529
+ await new Promise((resolve) => globalThis.setTimeout(resolve, 50));
1530
+ }
1531
+ return this.room.members.current();
1532
+ }
1533
+ async enableAudio(constraints = true) {
1534
+ const track = await this.createUserMediaTrack("audio", constraints);
1535
+ if (!track) {
1536
+ throw new Error("P2P transport could not create a local audio track.");
1537
+ }
1538
+ const providerSessionId = await this.ensureConnectedMemberId();
1539
+ this.rememberLocalTrack("audio", track, track.getSettings().deviceId, true);
1540
+ await this.room.media.audio.enable?.({
1541
+ trackId: track.id,
1542
+ deviceId: track.getSettings().deviceId,
1543
+ providerSessionId
1544
+ });
1545
+ this.syncAllPeerSenders();
1546
+ return track;
1547
+ }
1548
+ async enableVideo(constraints = true) {
1549
+ const track = await this.createUserMediaTrack("video", constraints);
1550
+ if (!track) {
1551
+ throw new Error("P2P transport could not create a local video track.");
1552
+ }
1553
+ const providerSessionId = await this.ensureConnectedMemberId();
1554
+ this.rememberLocalTrack("video", track, track.getSettings().deviceId, true);
1555
+ await this.room.media.video.enable?.({
1556
+ trackId: track.id,
1557
+ deviceId: track.getSettings().deviceId,
1558
+ providerSessionId
1559
+ });
1560
+ this.syncAllPeerSenders();
1561
+ return track;
1562
+ }
1563
+ async startScreenShare(constraints = { video: true, audio: false }) {
1564
+ const devices = await this.resolveMediaDevices();
1565
+ if (!devices?.getDisplayMedia) {
1566
+ throw new Error("Screen sharing is not available in this environment.");
1567
+ }
1568
+ const stream = await devices.getDisplayMedia(constraints);
1569
+ const track = stream.getVideoTracks()[0] ?? null;
1570
+ if (!track) {
1571
+ throw new Error("P2P transport could not create a screen-share track.");
1572
+ }
1573
+ track.addEventListener("ended", () => {
1574
+ void this.stopScreenShare();
1575
+ }, { once: true });
1576
+ const providerSessionId = await this.ensureConnectedMemberId();
1577
+ this.rememberLocalTrack("screen", track, track.getSettings().deviceId, true);
1578
+ await this.room.media.screen.start?.({
1579
+ trackId: track.id,
1580
+ deviceId: track.getSettings().deviceId,
1581
+ providerSessionId
1582
+ });
1583
+ this.syncAllPeerSenders();
1584
+ return track;
1585
+ }
1586
+ async disableAudio() {
1587
+ this.releaseLocalTrack("audio");
1588
+ this.syncAllPeerSenders();
1589
+ await this.room.media.audio.disable();
1590
+ }
1591
+ async disableVideo() {
1592
+ this.releaseLocalTrack("video");
1593
+ this.syncAllPeerSenders();
1594
+ await this.room.media.video.disable();
1595
+ }
1596
+ async stopScreenShare() {
1597
+ this.releaseLocalTrack("screen");
1598
+ this.syncAllPeerSenders();
1599
+ await this.room.media.screen.stop();
1600
+ }
1601
+ async setMuted(kind, muted) {
1602
+ const localTrack = this.localTracks.get(kind)?.track;
1603
+ if (localTrack) {
1604
+ localTrack.enabled = !muted;
1605
+ }
1606
+ if (kind === "audio") {
1607
+ await this.room.media.audio.setMuted?.(muted);
1608
+ } else {
1609
+ await this.room.media.video.setMuted?.(muted);
1610
+ }
1611
+ }
1612
+ async switchDevices(payload) {
1613
+ if (payload.audioInputId && this.localTracks.has("audio")) {
1614
+ const nextAudioTrack = await this.createUserMediaTrack("audio", buildExactDeviceConstraint(payload.audioInputId));
1615
+ if (nextAudioTrack) {
1616
+ this.rememberLocalTrack("audio", nextAudioTrack, payload.audioInputId, true);
1617
+ }
1618
+ }
1619
+ if (payload.videoInputId && this.localTracks.has("video")) {
1620
+ const nextVideoTrack = await this.createUserMediaTrack("video", buildExactDeviceConstraint(payload.videoInputId));
1621
+ if (nextVideoTrack) {
1622
+ this.rememberLocalTrack("video", nextVideoTrack, payload.videoInputId, true);
1623
+ }
1624
+ }
1625
+ this.syncAllPeerSenders();
1626
+ await this.room.media.devices.switch(payload);
1627
+ }
1628
+ onRemoteTrack(handler) {
1629
+ this.remoteTrackHandlers.push(handler);
1630
+ return {
1631
+ unsubscribe: () => {
1632
+ const index = this.remoteTrackHandlers.indexOf(handler);
1633
+ if (index >= 0) {
1634
+ this.remoteTrackHandlers.splice(index, 1);
1635
+ }
1636
+ }
1637
+ };
1638
+ }
1639
+ destroy() {
1640
+ this.connected = false;
1641
+ this.localMemberId = null;
1642
+ for (const subscription of this.subscriptions.splice(0)) {
1643
+ subscription.unsubscribe();
1644
+ }
1645
+ for (const peer of this.peers.values()) {
1646
+ this.destroyPeer(peer);
1647
+ }
1648
+ this.peers.clear();
1649
+ for (const kind of Array.from(this.localTracks.keys())) {
1650
+ this.releaseLocalTrack(kind);
1651
+ }
1652
+ this.remoteTrackKinds.clear();
1653
+ this.emittedRemoteTracks.clear();
1654
+ this.pendingRemoteTracks.clear();
1655
+ }
1656
+ attachRoomSubscriptions() {
1657
+ if (this.subscriptions.length > 0) {
1658
+ return;
1659
+ }
1660
+ this.subscriptions.push(
1661
+ this.room.members.onJoin((member) => {
1662
+ if (member.memberId !== this.localMemberId) {
1663
+ this.ensurePeer(member.memberId);
1664
+ }
1665
+ }),
1666
+ this.room.members.onSync((members) => {
1667
+ const activeMemberIds = /* @__PURE__ */ new Set();
1668
+ for (const member of members) {
1669
+ if (member.memberId !== this.localMemberId) {
1670
+ activeMemberIds.add(member.memberId);
1671
+ this.ensurePeer(member.memberId);
1672
+ }
1673
+ }
1674
+ for (const memberId of Array.from(this.peers.keys())) {
1675
+ if (!activeMemberIds.has(memberId)) {
1676
+ this.removeRemoteMember(memberId);
1677
+ }
1678
+ }
1679
+ }),
1680
+ this.room.members.onLeave((member) => {
1681
+ this.removeRemoteMember(member.memberId);
1682
+ }),
1683
+ this.room.signals.on(this.offerEvent, (payload, meta) => {
1684
+ void this.handleDescriptionSignal("offer", payload, meta);
1685
+ }),
1686
+ this.room.signals.on(this.answerEvent, (payload, meta) => {
1687
+ void this.handleDescriptionSignal("answer", payload, meta);
1688
+ }),
1689
+ this.room.signals.on(this.iceEvent, (payload, meta) => {
1690
+ void this.handleIceSignal(payload, meta);
1691
+ }),
1692
+ this.room.media.onTrack((track, member) => {
1693
+ if (member.memberId !== this.localMemberId) {
1694
+ this.ensurePeer(member.memberId);
1695
+ }
1696
+ this.rememberRemoteTrackKind(track, member);
1697
+ }),
1698
+ this.room.media.onTrackRemoved((track, member) => {
1699
+ if (!track.trackId) return;
1700
+ const key = buildTrackKey(member.memberId, track.trackId);
1701
+ this.remoteTrackKinds.delete(key);
1702
+ this.emittedRemoteTracks.delete(key);
1703
+ this.pendingRemoteTracks.delete(key);
1704
+ })
1705
+ );
1706
+ }
1707
+ hydrateRemoteTrackKinds() {
1708
+ this.remoteTrackKinds.clear();
1709
+ this.emittedRemoteTracks.clear();
1710
+ this.pendingRemoteTracks.clear();
1711
+ for (const mediaMember of this.room.media.list()) {
1712
+ for (const track of mediaMember.tracks) {
1713
+ this.rememberRemoteTrackKind(track, mediaMember.member);
1714
+ }
1715
+ }
1716
+ }
1717
+ rememberRemoteTrackKind(track, member) {
1718
+ if (!track.trackId || member.memberId === this.localMemberId) {
1719
+ return;
1720
+ }
1721
+ const key = buildTrackKey(member.memberId, track.trackId);
1722
+ this.remoteTrackKinds.set(key, track.kind);
1723
+ const pending = this.pendingRemoteTracks.get(key);
1724
+ if (pending) {
1725
+ this.pendingRemoteTracks.delete(key);
1726
+ this.emitRemoteTrack(member.memberId, pending.track, pending.stream, track.kind);
1727
+ return;
1728
+ }
1729
+ this.flushPendingRemoteTracks(member.memberId, track.kind);
1730
+ }
1731
+ ensurePeer(memberId) {
1732
+ const existing = this.peers.get(memberId);
1733
+ if (existing) {
1734
+ this.syncPeerSenders(existing);
1735
+ return existing;
1736
+ }
1737
+ const factory = this.options.peerConnectionFactory ?? ((configuration) => new RTCPeerConnection(configuration));
1738
+ const pc = factory(this.options.rtcConfiguration);
1739
+ const peer = {
1740
+ memberId,
1741
+ pc,
1742
+ polite: !!this.localMemberId && this.localMemberId.localeCompare(memberId) > 0,
1743
+ makingOffer: false,
1744
+ ignoreOffer: false,
1745
+ isSettingRemoteAnswerPending: false,
1746
+ pendingCandidates: [],
1747
+ senders: /* @__PURE__ */ new Map()
1748
+ };
1749
+ pc.onicecandidate = (event) => {
1750
+ if (!event.candidate) return;
1751
+ void this.room.signals.sendTo(memberId, this.iceEvent, {
1752
+ candidate: serializeCandidate(event.candidate)
1753
+ });
1754
+ };
1755
+ pc.onnegotiationneeded = () => {
1756
+ void this.negotiatePeer(peer);
1757
+ };
1758
+ pc.ontrack = (event) => {
1759
+ const stream = event.streams[0] ?? new MediaStream([event.track]);
1760
+ const key = buildTrackKey(memberId, event.track.id);
1761
+ const exactKind = this.remoteTrackKinds.get(key);
1762
+ const fallbackKind = exactKind ? null : this.resolveFallbackRemoteTrackKind(memberId, event.track);
1763
+ const kind = exactKind ?? fallbackKind ?? normalizeTrackKind(event.track);
1764
+ if (!kind || !exactKind && !fallbackKind && kind === "video" && event.track.kind === "video") {
1765
+ this.pendingRemoteTracks.set(key, { memberId, track: event.track, stream });
1766
+ return;
1767
+ }
1768
+ this.emitRemoteTrack(memberId, event.track, stream, kind);
1769
+ };
1770
+ this.peers.set(memberId, peer);
1771
+ this.syncPeerSenders(peer);
1772
+ return peer;
1773
+ }
1774
+ async negotiatePeer(peer) {
1775
+ if (!this.connected || peer.pc.connectionState === "closed" || peer.makingOffer || peer.isSettingRemoteAnswerPending || peer.pc.signalingState !== "stable") {
1776
+ return;
1777
+ }
1778
+ try {
1779
+ peer.makingOffer = true;
1780
+ await peer.pc.setLocalDescription();
1781
+ if (!peer.pc.localDescription) {
1782
+ return;
1783
+ }
1784
+ await this.room.signals.sendTo(peer.memberId, this.offerEvent, {
1785
+ description: serializeDescription(peer.pc.localDescription)
1786
+ });
1787
+ } catch (error) {
1788
+ console.warn("[RoomP2PMediaTransport] Failed to negotiate peer offer.", {
1789
+ memberId: peer.memberId,
1790
+ signalingState: peer.pc.signalingState,
1791
+ error
1792
+ });
1793
+ } finally {
1794
+ peer.makingOffer = false;
1795
+ }
1796
+ }
1797
+ async handleDescriptionSignal(expectedType, payload, meta) {
1798
+ const senderId = typeof meta.memberId === "string" && meta.memberId.trim() ? meta.memberId.trim() : "";
1799
+ if (!senderId || senderId === this.localMemberId) {
1800
+ return;
1801
+ }
1802
+ const description = this.normalizeDescription(payload);
1803
+ if (!description || description.type !== expectedType) {
1804
+ return;
1805
+ }
1806
+ const peer = this.ensurePeer(senderId);
1807
+ const readyForOffer = !peer.makingOffer && (peer.pc.signalingState === "stable" || peer.isSettingRemoteAnswerPending);
1808
+ const offerCollision = description.type === "offer" && !readyForOffer;
1809
+ peer.ignoreOffer = !peer.polite && offerCollision;
1810
+ if (peer.ignoreOffer) {
1811
+ return;
1812
+ }
1813
+ try {
1814
+ peer.isSettingRemoteAnswerPending = description.type === "answer";
1815
+ await peer.pc.setRemoteDescription(description);
1816
+ peer.isSettingRemoteAnswerPending = false;
1817
+ await this.flushPendingCandidates(peer);
1818
+ if (description.type === "offer") {
1819
+ this.syncPeerSenders(peer);
1820
+ await peer.pc.setLocalDescription();
1821
+ if (!peer.pc.localDescription) {
1822
+ return;
1823
+ }
1824
+ await this.room.signals.sendTo(senderId, this.answerEvent, {
1825
+ description: serializeDescription(peer.pc.localDescription)
1826
+ });
1827
+ }
1828
+ } catch (error) {
1829
+ console.warn("[RoomP2PMediaTransport] Failed to apply remote session description.", {
1830
+ memberId: senderId,
1831
+ expectedType,
1832
+ signalingState: peer.pc.signalingState,
1833
+ error
1834
+ });
1835
+ peer.isSettingRemoteAnswerPending = false;
1836
+ }
1837
+ }
1838
+ async handleIceSignal(payload, meta) {
1839
+ const senderId = typeof meta.memberId === "string" && meta.memberId.trim() ? meta.memberId.trim() : "";
1840
+ if (!senderId || senderId === this.localMemberId) {
1841
+ return;
1842
+ }
1843
+ const candidate = this.normalizeCandidate(payload);
1844
+ if (!candidate) {
1845
+ return;
1846
+ }
1847
+ const peer = this.ensurePeer(senderId);
1848
+ if (!peer.pc.remoteDescription) {
1849
+ peer.pendingCandidates.push(candidate);
1850
+ return;
1851
+ }
1852
+ try {
1853
+ await peer.pc.addIceCandidate(candidate);
1854
+ } catch (error) {
1855
+ console.warn("[RoomP2PMediaTransport] Failed to add ICE candidate.", {
1856
+ memberId: senderId,
1857
+ error
1858
+ });
1859
+ if (!peer.ignoreOffer) {
1860
+ peer.pendingCandidates.push(candidate);
1861
+ }
1862
+ }
1863
+ }
1864
+ async flushPendingCandidates(peer) {
1865
+ if (!peer.pc.remoteDescription || peer.pendingCandidates.length === 0) {
1866
+ return;
1867
+ }
1868
+ const pending = [...peer.pendingCandidates];
1869
+ peer.pendingCandidates.length = 0;
1870
+ for (const candidate of pending) {
1871
+ try {
1872
+ await peer.pc.addIceCandidate(candidate);
1873
+ } catch (error) {
1874
+ console.warn("[RoomP2PMediaTransport] Failed to flush pending ICE candidate.", {
1875
+ memberId: peer.memberId,
1876
+ error
1877
+ });
1878
+ if (!peer.ignoreOffer) {
1879
+ peer.pendingCandidates.push(candidate);
1880
+ }
1881
+ }
1882
+ }
1883
+ }
1884
+ syncAllPeerSenders() {
1885
+ for (const peer of this.peers.values()) {
1886
+ this.syncPeerSenders(peer);
1887
+ }
1888
+ }
1889
+ syncPeerSenders(peer) {
1890
+ const activeKinds = /* @__PURE__ */ new Set();
1891
+ let changed = false;
1892
+ for (const [kind, localTrack] of this.localTracks.entries()) {
1893
+ activeKinds.add(kind);
1894
+ const sender = peer.senders.get(kind);
1895
+ if (sender) {
1896
+ if (sender.track !== localTrack.track) {
1897
+ void sender.replaceTrack(localTrack.track);
1898
+ changed = true;
1899
+ }
1900
+ continue;
1901
+ }
1902
+ const addedSender = peer.pc.addTrack(localTrack.track, new MediaStream([localTrack.track]));
1903
+ peer.senders.set(kind, addedSender);
1904
+ changed = true;
1905
+ }
1906
+ for (const [kind, sender] of Array.from(peer.senders.entries())) {
1907
+ if (activeKinds.has(kind)) {
1908
+ continue;
1909
+ }
1910
+ try {
1911
+ peer.pc.removeTrack(sender);
1912
+ } catch {
1913
+ }
1914
+ peer.senders.delete(kind);
1915
+ changed = true;
1916
+ }
1917
+ if (changed) {
1918
+ void this.negotiatePeer(peer);
1919
+ }
1920
+ }
1921
+ emitRemoteTrack(memberId, track, stream, kind) {
1922
+ const key = buildTrackKey(memberId, track.id);
1923
+ if (this.emittedRemoteTracks.has(key)) {
1924
+ return;
1925
+ }
1926
+ this.emittedRemoteTracks.add(key);
1927
+ this.remoteTrackKinds.set(key, kind);
1928
+ const participant = this.findMember(memberId);
1929
+ const payload = {
1930
+ kind,
1931
+ track,
1932
+ stream,
1933
+ trackName: track.id,
1934
+ providerSessionId: memberId,
1935
+ participantId: memberId,
1936
+ userId: participant?.userId
1937
+ };
1938
+ for (const handler of this.remoteTrackHandlers) {
1939
+ handler(payload);
1940
+ }
1941
+ }
1942
+ resolveFallbackRemoteTrackKind(memberId, track) {
1943
+ const normalizedKind = normalizeTrackKind(track);
1944
+ if (!normalizedKind) {
1945
+ return null;
1946
+ }
1947
+ if (normalizedKind === "audio") {
1948
+ return "audio";
1949
+ }
1950
+ return this.getNextUnassignedPublishedVideoLikeKind(memberId);
1951
+ }
1952
+ flushPendingRemoteTracks(memberId, roomKind) {
1953
+ const expectedTrackKind = roomKind === "audio" ? "audio" : "video";
1954
+ for (const [key, pending] of this.pendingRemoteTracks.entries()) {
1955
+ if (pending.memberId !== memberId || pending.track.kind !== expectedTrackKind) {
1956
+ continue;
1957
+ }
1958
+ this.pendingRemoteTracks.delete(key);
1959
+ this.emitRemoteTrack(memberId, pending.track, pending.stream, roomKind);
1960
+ return;
1961
+ }
1962
+ }
1963
+ getPublishedVideoLikeKinds(memberId) {
1964
+ const mediaMember = this.room.media.list().find((entry) => entry.member.memberId === memberId);
1965
+ if (!mediaMember) {
1966
+ return [];
1967
+ }
1968
+ const publishedKinds = /* @__PURE__ */ new Set();
1969
+ for (const track of mediaMember.tracks) {
1970
+ if ((track.kind === "video" || track.kind === "screen") && track.trackId) {
1971
+ publishedKinds.add(track.kind);
1972
+ }
1973
+ }
1974
+ return Array.from(publishedKinds);
1975
+ }
1976
+ getNextUnassignedPublishedVideoLikeKind(memberId) {
1977
+ const publishedKinds = this.getPublishedVideoLikeKinds(memberId);
1978
+ if (publishedKinds.length === 0) {
1979
+ return null;
1980
+ }
1981
+ const assignedKinds = /* @__PURE__ */ new Set();
1982
+ for (const key of this.emittedRemoteTracks) {
1983
+ if (!key.startsWith(`${memberId}:`)) {
1984
+ continue;
1985
+ }
1986
+ const kind = this.remoteTrackKinds.get(key);
1987
+ if (kind === "video" || kind === "screen") {
1988
+ assignedKinds.add(kind);
1989
+ }
1990
+ }
1991
+ return publishedKinds.find((kind) => !assignedKinds.has(kind)) ?? null;
1992
+ }
1993
+ closePeer(memberId) {
1994
+ const peer = this.peers.get(memberId);
1995
+ if (!peer) return;
1996
+ this.destroyPeer(peer);
1997
+ this.peers.delete(memberId);
1998
+ }
1999
+ removeRemoteMember(memberId) {
2000
+ this.remoteTrackKinds.forEach((_kind, key) => {
2001
+ if (key.startsWith(`${memberId}:`)) {
2002
+ this.remoteTrackKinds.delete(key);
2003
+ }
2004
+ });
2005
+ this.emittedRemoteTracks.forEach((key) => {
2006
+ if (key.startsWith(`${memberId}:`)) {
2007
+ this.emittedRemoteTracks.delete(key);
2008
+ }
2009
+ });
2010
+ this.pendingRemoteTracks.forEach((_pending, key) => {
2011
+ if (key.startsWith(`${memberId}:`)) {
2012
+ this.pendingRemoteTracks.delete(key);
2013
+ }
2014
+ });
2015
+ this.closePeer(memberId);
2016
+ }
2017
+ findMember(memberId) {
2018
+ return this.room.members.list().find((member) => member.memberId === memberId);
2019
+ }
2020
+ rollbackConnectedState() {
2021
+ this.connected = false;
2022
+ this.localMemberId = null;
2023
+ for (const subscription of this.subscriptions.splice(0)) {
2024
+ subscription.unsubscribe();
2025
+ }
2026
+ for (const peer of this.peers.values()) {
2027
+ this.destroyPeer(peer);
2028
+ }
2029
+ this.peers.clear();
2030
+ this.remoteTrackKinds.clear();
2031
+ this.emittedRemoteTracks.clear();
2032
+ this.pendingRemoteTracks.clear();
2033
+ }
2034
+ destroyPeer(peer) {
2035
+ peer.pc.onicecandidate = null;
2036
+ peer.pc.onnegotiationneeded = null;
2037
+ peer.pc.ontrack = null;
2038
+ try {
2039
+ peer.pc.close();
2040
+ } catch {
2041
+ }
2042
+ }
2043
+ async createUserMediaTrack(kind, constraints) {
2044
+ const devices = await this.resolveMediaDevices();
2045
+ if (!devices?.getUserMedia || constraints === false) {
2046
+ return null;
2047
+ }
2048
+ const stream = await devices.getUserMedia(
2049
+ kind === "audio" ? { audio: constraints, video: false } : { audio: false, video: constraints }
2050
+ );
2051
+ return kind === "audio" ? stream.getAudioTracks()[0] ?? null : stream.getVideoTracks()[0] ?? null;
2052
+ }
2053
+ rememberLocalTrack(kind, track, deviceId, stopOnCleanup) {
2054
+ this.releaseLocalTrack(kind);
2055
+ this.localTracks.set(kind, {
2056
+ kind,
2057
+ track,
2058
+ deviceId,
2059
+ stopOnCleanup
2060
+ });
2061
+ }
2062
+ releaseLocalTrack(kind) {
2063
+ const local = this.localTracks.get(kind);
2064
+ if (!local) return;
2065
+ if (local.stopOnCleanup) {
2066
+ local.track.stop();
2067
+ }
2068
+ this.localTracks.delete(kind);
2069
+ }
2070
+ async ensureConnectedMemberId() {
2071
+ if (this.localMemberId) {
2072
+ return this.localMemberId;
2073
+ }
2074
+ return this.connect();
2075
+ }
2076
+ normalizeDescription(payload) {
2077
+ if (!payload || typeof payload !== "object") {
2078
+ return null;
2079
+ }
2080
+ const raw = payload.description;
2081
+ if (!raw || typeof raw.type !== "string") {
2082
+ return null;
2083
+ }
2084
+ return {
2085
+ type: raw.type,
2086
+ sdp: typeof raw.sdp === "string" ? raw.sdp : void 0
2087
+ };
2088
+ }
2089
+ normalizeCandidate(payload) {
2090
+ if (!payload || typeof payload !== "object") {
2091
+ return null;
2092
+ }
2093
+ const raw = payload.candidate;
2094
+ if (!raw || typeof raw.candidate !== "string") {
2095
+ return null;
2096
+ }
2097
+ return raw;
2098
+ }
2099
+ async ensureRuntime() {
2100
+ this.runtimePromise ??= import('@cloudflare/react-native-webrtc').then((mod) => {
2101
+ const runtime = mod;
2102
+ runtime.registerGlobals?.();
2103
+ return runtime;
2104
+ }).catch((error) => {
2105
+ throw new Error(`${installMessage2("@cloudflare/react-native-webrtc")}
2106
+ ${String(error)}`);
2107
+ });
2108
+ return this.runtimePromise;
2109
+ }
2110
+ async resolveMediaDevices() {
2111
+ if (this.options.mediaDevices) {
2112
+ return this.options.mediaDevices;
2113
+ }
2114
+ const runtime = await this.ensureRuntime();
2115
+ return runtime.mediaDevices ?? (typeof navigator !== "undefined" ? navigator.mediaDevices : void 0);
2116
+ }
2117
+ get offerEvent() {
2118
+ return `${this.options.signalPrefix}.offer`;
2119
+ }
2120
+ get answerEvent() {
2121
+ return `${this.options.signalPrefix}.answer`;
2122
+ }
2123
+ get iceEvent() {
2124
+ return `${this.options.signalPrefix}.ice`;
2125
+ }
2126
+ };
2127
+
2128
+ // src/room.ts
1019
2129
  var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1020
2130
  function deepSet(obj, path, value) {
1021
2131
  const parts = path.split(".");
@@ -1141,6 +2251,21 @@ var RoomClient = class _RoomClient {
1141
2251
  };
1142
2252
  members = {
1143
2253
  list: () => cloneValue(this._members),
2254
+ current: () => {
2255
+ const connectionId = this.currentConnectionId;
2256
+ if (connectionId) {
2257
+ const byConnection = this._members.find((member2) => member2.connectionId === connectionId);
2258
+ if (byConnection) {
2259
+ return cloneValue(byConnection);
2260
+ }
2261
+ }
2262
+ const userId = this.currentUserId;
2263
+ if (!userId) {
2264
+ return null;
2265
+ }
2266
+ const member = this._members.find((entry) => entry.userId === userId) ?? null;
2267
+ return member ? cloneValue(member) : null;
2268
+ },
1144
2269
  onSync: (handler) => this.onMembersSync(handler),
1145
2270
  onJoin: (handler) => this.onMemberJoin(handler),
1146
2271
  onLeave: (handler) => this.onMemberLeave(handler),
@@ -1175,6 +2300,18 @@ var RoomClient = class _RoomClient {
1175
2300
  devices: {
1176
2301
  switch: (payload) => this.switchMediaDevices(payload)
1177
2302
  },
2303
+ cloudflareRealtimeKit: {
2304
+ createSession: (payload) => this.requestCloudflareRealtimeKitMedia("session", "POST", payload)
2305
+ },
2306
+ transport: (options) => {
2307
+ const provider = options?.provider ?? "cloudflare_realtimekit";
2308
+ if (provider === "p2p") {
2309
+ const p2pOptions = options?.p2p;
2310
+ return new RoomP2PMediaTransport(this, p2pOptions);
2311
+ }
2312
+ const cloudflareOptions = options?.cloudflareRealtimeKit;
2313
+ return new RoomCloudflareMediaTransport(this, cloudflareOptions);
2314
+ },
1178
2315
  onTrack: (handler) => this.onMediaTrack(handler),
1179
2316
  onTrackRemoved: (handler) => this.onMediaTrackRemoved(handler),
1180
2317
  onStateChange: (handler) => this.onMediaStateChange(handler),
@@ -1195,7 +2332,8 @@ var RoomClient = class _RoomClient {
1195
2332
  autoReconnect: options?.autoReconnect ?? true,
1196
2333
  maxReconnectAttempts: options?.maxReconnectAttempts ?? 10,
1197
2334
  reconnectBaseDelay: options?.reconnectBaseDelay ?? 1e3,
1198
- sendTimeout: options?.sendTimeout ?? 1e4
2335
+ sendTimeout: options?.sendTimeout ?? 1e4,
2336
+ connectionTimeout: options?.connectionTimeout ?? 15e3
1199
2337
  };
1200
2338
  this.unsubAuthState = this.tokenManager.onAuthStateChange((user) => {
1201
2339
  this.handleAuthStateChange(user);
@@ -1218,6 +2356,36 @@ var RoomClient = class _RoomClient {
1218
2356
  async getMetadata() {
1219
2357
  return _RoomClient.getMetadata(this.baseUrl, this.namespace, this.roomId);
1220
2358
  }
2359
+ async requestCloudflareRealtimeKitMedia(path, method, payload) {
2360
+ return this.requestRoomMedia("cloudflare_realtimekit", path, method, payload);
2361
+ }
2362
+ async requestRoomMedia(providerPath, path, method, payload) {
2363
+ const token = await this.tokenManager.getAccessToken(
2364
+ (refreshToken) => refreshAccessToken(this.baseUrl, refreshToken)
2365
+ );
2366
+ if (!token) {
2367
+ throw new EdgeBaseError(401, "Authentication required");
2368
+ }
2369
+ const url = new URL(`${this.baseUrl.replace(/\/$/, "")}/api/room/media/${providerPath}/${path}`);
2370
+ url.searchParams.set("namespace", this.namespace);
2371
+ url.searchParams.set("id", this.roomId);
2372
+ const response = await fetch(url.toString(), {
2373
+ method,
2374
+ headers: {
2375
+ Authorization: `Bearer ${token}`,
2376
+ "Content-Type": "application/json"
2377
+ },
2378
+ body: method === "GET" ? void 0 : JSON.stringify(payload ?? {})
2379
+ });
2380
+ const data = await response.json().catch(() => ({}));
2381
+ if (!response.ok) {
2382
+ throw new EdgeBaseError(
2383
+ response.status,
2384
+ typeof data.message === "string" && data.message || `Room media request failed: ${response.statusText}`
2385
+ );
2386
+ }
2387
+ return data;
2388
+ }
1221
2389
  /**
1222
2390
  * Static: Get room metadata without creating a RoomClient instance.
1223
2391
  * Useful for lobby screens where you need room info before joining.
@@ -1277,6 +2445,30 @@ var RoomClient = class _RoomClient {
1277
2445
  this.reconnectInfo = null;
1278
2446
  this.setConnectionState("disconnected");
1279
2447
  }
2448
+ /** Destroy the room client, cleaning up all listeners and the auth subscription. */
2449
+ destroy() {
2450
+ this.leave();
2451
+ this.unsubAuthState?.();
2452
+ this.unsubAuthState = null;
2453
+ this.sharedStateHandlers.length = 0;
2454
+ this.playerStateHandlers.length = 0;
2455
+ this.messageHandlers.clear();
2456
+ this.allMessageHandlers.length = 0;
2457
+ this.errorHandlers.length = 0;
2458
+ this.kickedHandlers.length = 0;
2459
+ this.memberSyncHandlers.length = 0;
2460
+ this.memberJoinHandlers.length = 0;
2461
+ this.memberLeaveHandlers.length = 0;
2462
+ this.memberStateHandlers.length = 0;
2463
+ this.signalHandlers.clear();
2464
+ this.anySignalHandlers.length = 0;
2465
+ this.mediaTrackHandlers.length = 0;
2466
+ this.mediaTrackRemovedHandlers.length = 0;
2467
+ this.mediaStateHandlers.length = 0;
2468
+ this.mediaDeviceHandlers.length = 0;
2469
+ this.reconnectHandlers.length = 0;
2470
+ this.connectionStateHandlers.length = 0;
2471
+ }
1280
2472
  // ─── Actions ───
1281
2473
  /**
1282
2474
  * Send an action to the server.
@@ -1610,22 +2802,42 @@ var RoomClient = class _RoomClient {
1610
2802
  const wsUrl = this.buildWsUrl();
1611
2803
  const ws = new WebSocket(wsUrl);
1612
2804
  this.ws = ws;
2805
+ let settled = false;
2806
+ const connectionTimer = setTimeout(() => {
2807
+ if (!settled) {
2808
+ settled = true;
2809
+ try {
2810
+ ws.close();
2811
+ } catch (_) {
2812
+ }
2813
+ this.ws = null;
2814
+ reject(new EdgeBaseError(408, `Room WebSocket connection timed out after ${this.options.connectionTimeout}ms. Is the server running?`));
2815
+ }
2816
+ }, this.options.connectionTimeout);
1613
2817
  ws.onopen = () => {
2818
+ clearTimeout(connectionTimer);
1614
2819
  this.connected = true;
1615
2820
  this.reconnectAttempts = 0;
1616
2821
  this.startHeartbeat();
1617
2822
  this.authenticate().then(() => {
1618
- this.waitingForAuth = false;
1619
- resolve();
2823
+ if (!settled) {
2824
+ settled = true;
2825
+ this.waitingForAuth = false;
2826
+ resolve();
2827
+ }
1620
2828
  }).catch((error) => {
1621
- this.handleAuthenticationFailure(error);
1622
- reject(error);
2829
+ if (!settled) {
2830
+ settled = true;
2831
+ this.handleAuthenticationFailure(error);
2832
+ reject(error);
2833
+ }
1623
2834
  });
1624
2835
  };
1625
2836
  ws.onmessage = (event) => {
1626
2837
  this.handleMessage(event.data);
1627
2838
  };
1628
2839
  ws.onclose = (event) => {
2840
+ clearTimeout(connectionTimer);
1629
2841
  this.connected = false;
1630
2842
  this.authenticated = false;
1631
2843
  this.joined = false;
@@ -1634,6 +2846,9 @@ var RoomClient = class _RoomClient {
1634
2846
  if (event.code === 4004 && this.connectionState !== "kicked") {
1635
2847
  this.handleKicked();
1636
2848
  }
2849
+ if (!this.intentionallyLeft) {
2850
+ this.rejectAllPendingRequests(new EdgeBaseError(499, "WebSocket connection lost"));
2851
+ }
1637
2852
  if (!this.intentionallyLeft && !this.waitingForAuth && this.options.autoReconnect && this.reconnectAttempts < this.options.maxReconnectAttempts) {
1638
2853
  this.scheduleReconnect();
1639
2854
  } else if (!this.intentionallyLeft && this.connectionState !== "kicked" && this.connectionState !== "auth_lost") {
@@ -1641,7 +2856,11 @@ var RoomClient = class _RoomClient {
1641
2856
  }
1642
2857
  };
1643
2858
  ws.onerror = () => {
1644
- reject(new EdgeBaseError(500, "Room WebSocket connection error"));
2859
+ clearTimeout(connectionTimer);
2860
+ if (!settled) {
2861
+ settled = true;
2862
+ reject(new EdgeBaseError(500, "Room WebSocket connection error"));
2863
+ }
1645
2864
  };
1646
2865
  });
1647
2866
  }
@@ -2085,6 +3304,9 @@ var RoomClient = class _RoomClient {
2085
3304
  this.sendRaw({ type: "auth", token });
2086
3305
  }
2087
3306
  handleAuthStateChange(user) {
3307
+ if (user === null) {
3308
+ this.rejectAllPendingRequests(new EdgeBaseError(401, "Auth state lost"));
3309
+ }
2088
3310
  if (user) {
2089
3311
  if (this.ws && this.connected && this.authenticated) {
2090
3312
  this.refreshAuth();
@@ -2339,6 +3561,17 @@ var RoomClient = class _RoomClient {
2339
3561
  [kind]: next
2340
3562
  };
2341
3563
  }
3564
+ rejectAllPendingRequests(error) {
3565
+ for (const [, pending] of this.pendingRequests) {
3566
+ clearTimeout(pending.timeout);
3567
+ pending.reject(error);
3568
+ }
3569
+ this.pendingRequests.clear();
3570
+ this.rejectPendingVoidRequests(this.pendingSignalRequests, error);
3571
+ this.rejectPendingVoidRequests(this.pendingAdminRequests, error);
3572
+ this.rejectPendingVoidRequests(this.pendingMemberStateRequests, error);
3573
+ this.rejectPendingVoidRequests(this.pendingMediaRequests, error);
3574
+ }
2342
3575
  rejectPendingVoidRequests(pendingRequests, error) {
2343
3576
  for (const [, pending] of pendingRequests) {
2344
3577
  clearTimeout(pending.timeout);
@@ -2369,7 +3602,9 @@ var RoomClient = class _RoomClient {
2369
3602
  }
2370
3603
  scheduleReconnect() {
2371
3604
  const attempt = this.reconnectAttempts + 1;
2372
- const delay = this.options.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts);
3605
+ const baseDelay = this.options.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts);
3606
+ const jitter = Math.random() * baseDelay * 0.25;
3607
+ const delay = baseDelay + jitter;
2373
3608
  this.reconnectAttempts++;
2374
3609
  this.reconnectInfo = { attempt };
2375
3610
  this.setConnectionState("reconnecting");