@edge-base/web 0.2.6 → 0.2.7

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/room.js CHANGED
@@ -1,7 +1,5 @@
1
1
  import { EdgeBaseError, createSubscription, networkError, parseErrorResponse, } from '@edge-base/core';
2
2
  import { refreshAccessToken } from './auth-refresh.js';
3
- import { RoomCloudflareMediaTransport, } from './room-cloudflare-media.js';
4
- import { RoomP2PMediaTransport, } from './room-p2p-media.js';
5
3
  export { createSubscription };
6
4
  // ─── Helpers ───
7
5
  const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
@@ -46,19 +44,21 @@ const WS_OPEN = 1;
46
44
  const ROOM_EXPLICIT_LEAVE_CLOSE_CODE = 4005;
47
45
  const ROOM_AUTH_STATE_LOST_CLOSE_CODE = 4006;
48
46
  const ROOM_EXPLICIT_LEAVE_REASON = 'Client left room';
49
- const ROOM_EXPLICIT_LEAVE_CLOSE_DELAY_MS = 40;
47
+ const ROOM_HEARTBEAT_INTERVAL_MS = 8000;
48
+ const ROOM_HEARTBEAT_STALE_TIMEOUT_MS = 20_000;
50
49
  function isSocketOpenOrConnecting(socket) {
51
50
  return !!socket && (socket.readyState === WS_OPEN || socket.readyState === WS_CONNECTING);
52
51
  }
53
52
  function closeSocketAfterLeave(socket, reason) {
54
- globalThis.setTimeout(() => {
55
- try {
56
- socket.close(ROOM_EXPLICIT_LEAVE_CLOSE_CODE, reason);
57
- }
58
- catch {
59
- // Socket already closed.
60
- }
61
- }, ROOM_EXPLICIT_LEAVE_CLOSE_DELAY_MS);
53
+ try {
54
+ socket.close(ROOM_EXPLICIT_LEAVE_CLOSE_CODE, reason);
55
+ }
56
+ catch {
57
+ // Socket already closed.
58
+ }
59
+ }
60
+ function getDefaultHeartbeatStaleTimeoutMs(heartbeatIntervalMs) {
61
+ return Math.max(Math.floor(heartbeatIntervalMs * 2.5), ROOM_HEARTBEAT_STALE_TIMEOUT_MS);
62
62
  }
63
63
  // ─── RoomClient v2 ───
64
64
  export class RoomClient {
@@ -75,7 +75,7 @@ export class RoomClient {
75
75
  _playerState = {};
76
76
  _playerVersion = 0;
77
77
  _members = [];
78
- _mediaMembers = [];
78
+ lastLocalMemberState = null;
79
79
  // ─── Connection ───
80
80
  ws = null;
81
81
  reconnectAttempts = 0;
@@ -88,6 +88,8 @@ export class RoomClient {
88
88
  reconnectInfo = null;
89
89
  connectingPromise = null;
90
90
  heartbeatTimer = null;
91
+ lastHeartbeatAckAt = Date.now();
92
+ disconnectResetTimer = null;
91
93
  intentionallyLeft = false;
92
94
  waitingForAuth = false;
93
95
  joinRequested = false;
@@ -125,7 +127,6 @@ export class RoomClient {
125
127
  pendingSignalRequests = new Map();
126
128
  pendingAdminRequests = new Map();
127
129
  pendingMemberStateRequests = new Map();
128
- pendingMediaRequests = new Map();
129
130
  // ─── Subscriptions ───
130
131
  sharedStateHandlers = [];
131
132
  playerStateHandlers = [];
@@ -134,16 +135,14 @@ export class RoomClient {
134
135
  errorHandlers = [];
135
136
  kickedHandlers = [];
136
137
  memberSyncHandlers = [];
138
+ memberSnapshotHandlers = [];
137
139
  memberJoinHandlers = [];
138
140
  memberLeaveHandlers = [];
139
141
  memberStateHandlers = [];
140
142
  signalHandlers = new Map();
141
143
  anySignalHandlers = [];
142
- mediaTrackHandlers = [];
143
- mediaTrackRemovedHandlers = [];
144
- mediaStateHandlers = [];
145
- mediaDeviceHandlers = [];
146
144
  reconnectHandlers = [];
145
+ recoveryFailureHandlers = [];
147
146
  connectionStateHandlers = [];
148
147
  state = {
149
148
  getShared: () => this.getSharedState(),
@@ -163,7 +162,7 @@ export class RoomClient {
163
162
  onAny: (handler) => this.onAnySignal(handler),
164
163
  };
165
164
  members = {
166
- list: () => cloneValue(this._members),
165
+ list: () => this._members.map((member) => cloneValue(member)),
167
166
  current: () => {
168
167
  const connectionId = this.currentConnectionId;
169
168
  if (connectionId) {
@@ -179,7 +178,9 @@ export class RoomClient {
179
178
  const member = this._members.find((entry) => entry.userId === userId) ?? null;
180
179
  return member ? cloneValue(member) : null;
181
180
  },
181
+ awaitCurrent: (timeoutMs = 10_000) => this.waitForCurrentMember(timeoutMs),
182
182
  onSync: (handler) => this.onMembersSync(handler),
183
+ onSnapshot: (handler) => this.onMemberSnapshot(handler),
183
184
  onJoin: (handler) => this.onMemberJoin(handler),
184
185
  onLeave: (handler) => this.onMemberLeave(handler),
185
186
  setState: (state) => this.sendMemberState(state),
@@ -188,63 +189,16 @@ export class RoomClient {
188
189
  };
189
190
  admin = {
190
191
  kick: (memberId) => this.sendAdmin('kick', memberId),
191
- mute: (memberId) => this.sendAdmin('mute', memberId),
192
192
  block: (memberId) => this.sendAdmin('block', memberId),
193
193
  setRole: (memberId, role) => this.sendAdmin('setRole', memberId, { role }),
194
- disableVideo: (memberId) => this.sendAdmin('disableVideo', memberId),
195
- stopScreenShare: (memberId) => this.sendAdmin('stopScreenShare', memberId),
196
- };
197
- media = {
198
- list: () => cloneValue(this._mediaMembers),
199
- audio: {
200
- enable: (payload) => this.sendMedia('publish', 'audio', payload),
201
- disable: () => this.sendMedia('unpublish', 'audio'),
202
- setMuted: (muted) => this.sendMedia('mute', 'audio', { muted }),
203
- },
204
- video: {
205
- enable: (payload) => this.sendMedia('publish', 'video', payload),
206
- disable: () => this.sendMedia('unpublish', 'video'),
207
- setMuted: (muted) => this.sendMedia('mute', 'video', { muted }),
208
- },
209
- screen: {
210
- start: (payload) => this.sendMedia('publish', 'screen', payload),
211
- stop: () => this.sendMedia('unpublish', 'screen'),
212
- },
213
- devices: {
214
- switch: (payload) => this.switchMediaDevices(payload),
215
- },
216
- realtime: {
217
- iceServers: (payload) => this.requestRealtimeMedia('turn', 'POST', payload),
218
- },
219
- cloudflareRealtimeKit: {
220
- createSession: (payload) => this.requestCloudflareRealtimeKitMedia('session', 'POST', payload),
221
- },
222
- checkReadiness: async (options) => {
223
- const transport = this.media.transport(options);
224
- return transport.getCapabilities();
225
- },
226
- transport: (options) => {
227
- // Infer provider from options: if cloudflareRealtimeKit config is present, use it;
228
- // otherwise default to p2p for zero-config local development.
229
- const hasCloudflareConfig = options && 'cloudflareRealtimeKit' in options && options.cloudflareRealtimeKit != null;
230
- const provider = options?.provider ?? (hasCloudflareConfig ? 'cloudflare_realtimekit' : 'p2p');
231
- if (provider === 'p2p') {
232
- const p2pOptions = options?.p2p;
233
- return new RoomP2PMediaTransport(this, p2pOptions);
234
- }
235
- const cloudflareOptions = options?.cloudflareRealtimeKit;
236
- return new RoomCloudflareMediaTransport(this, cloudflareOptions);
237
- },
238
- onTrack: (handler) => this.onMediaTrack(handler),
239
- onTrackRemoved: (handler) => this.onMediaTrackRemoved(handler),
240
- onStateChange: (handler) => this.onMediaStateChange(handler),
241
- onDeviceChange: (handler) => this.onMediaDeviceChange(handler),
242
194
  };
243
195
  session = {
244
196
  onError: (handler) => this.onError(handler),
245
197
  onKicked: (handler) => this.onKicked(handler),
246
198
  onReconnect: (handler) => this.onReconnect(handler),
247
199
  onConnectionStateChange: (handler) => this.onConnectionStateChange(handler),
200
+ onRecoveryFailure: (handler) => this.onRecoveryFailure(handler),
201
+ getDebugSnapshot: () => this.getDebugSnapshot(),
248
202
  };
249
203
  constructor(baseUrl, namespace, roomId, tokenManager, options) {
250
204
  this.baseUrl = baseUrl;
@@ -257,6 +211,11 @@ export class RoomClient {
257
211
  reconnectBaseDelay: options?.reconnectBaseDelay ?? 1000,
258
212
  sendTimeout: options?.sendTimeout ?? 10000,
259
213
  connectionTimeout: options?.connectionTimeout ?? 15000,
214
+ heartbeatIntervalMs: options?.heartbeatIntervalMs ?? ROOM_HEARTBEAT_INTERVAL_MS,
215
+ heartbeatStaleTimeoutMs: options?.heartbeatStaleTimeoutMs
216
+ ?? getDefaultHeartbeatStaleTimeoutMs(options?.heartbeatIntervalMs ?? ROOM_HEARTBEAT_INTERVAL_MS),
217
+ networkRecoveryGraceMs: options?.networkRecoveryGraceMs ?? 3500,
218
+ disconnectResetTimeoutMs: options?.disconnectResetTimeoutMs ?? 8000,
260
219
  };
261
220
  this.unsubAuthState = this.tokenManager.onAuthStateChange((user) => {
262
221
  this.handleAuthStateChange(user);
@@ -272,6 +231,17 @@ export class RoomClient {
272
231
  getPlayerState() {
273
232
  return cloneRecord(this._playerState);
274
233
  }
234
+ async waitForCurrentMember(timeoutMs = 10_000) {
235
+ const startedAt = Date.now();
236
+ while (Date.now() - startedAt < timeoutMs) {
237
+ const member = this.members.current();
238
+ if (member) {
239
+ return member;
240
+ }
241
+ await new Promise((resolve) => globalThis.setTimeout(resolve, 50));
242
+ }
243
+ return this.members.current();
244
+ }
275
245
  // ─── Metadata (HTTP, no WebSocket needed) ───
276
246
  /**
277
247
  * Get room metadata without joining (HTTP GET).
@@ -366,42 +336,6 @@ export class RoomClient {
366
336
  }
367
337
  return res.json();
368
338
  }
369
- async requestCloudflareRealtimeKitMedia(path, method, payload) {
370
- return this.requestRoomMedia('cloudflare_realtimekit', path, method, payload);
371
- }
372
- async requestRealtimeMedia(path, method, payload) {
373
- return this.requestRoomMedia('realtime', path, method, payload);
374
- }
375
- async requestRoomMedia(providerPath, path, method, payload) {
376
- const token = await this.tokenManager.getAccessToken((refreshToken) => refreshAccessToken(this.baseUrl, refreshToken));
377
- if (!token) {
378
- throw new EdgeBaseError(401, 'Authentication required before calling room media APIs. Sign in and join the room first.');
379
- }
380
- const url = new URL(`${this.baseUrl.replace(/\/$/, '')}/api/room/media/${providerPath}/${path}`);
381
- url.searchParams.set('namespace', this.namespace);
382
- url.searchParams.set('id', this.roomId);
383
- let response;
384
- try {
385
- response = await fetch(url.toString(), {
386
- method,
387
- headers: {
388
- Authorization: `Bearer ${token}`,
389
- 'Content-Type': 'application/json',
390
- },
391
- body: method === 'GET' ? undefined : JSON.stringify(payload ?? {}),
392
- });
393
- }
394
- catch (error) {
395
- throw networkError(`Room media request ${providerPath}/${path} could not reach ${this.baseUrl.replace(/\/$/, '')}. Make sure the EdgeBase server is running and reachable.`, { cause: error });
396
- }
397
- const data = (await response.json().catch(() => null));
398
- if (!response.ok) {
399
- const parsed = parseErrorResponse(response.status, data);
400
- parsed.message = `Room media request ${providerPath}/${path} failed: ${parsed.message}`;
401
- throw parsed;
402
- }
403
- return (data ?? {});
404
- }
405
339
  // ─── Connection Lifecycle ───
406
340
  /** Connect to the room, authenticate, and join */
407
341
  async join() {
@@ -419,6 +353,7 @@ export class RoomClient {
419
353
  this.joinRequested = false;
420
354
  this.waitingForAuth = false;
421
355
  this.stopHeartbeat();
356
+ this.clearDisconnectResetTimer();
422
357
  // Reject all pending send() requests
423
358
  this.rejectAllPendingRequests(new EdgeBaseError(499, 'Room left'));
424
359
  if (this.ws) {
@@ -436,7 +371,7 @@ export class RoomClient {
436
371
  this._playerState = {};
437
372
  this._playerVersion = 0;
438
373
  this._members = [];
439
- this._mediaMembers = [];
374
+ this.lastLocalMemberState = null;
440
375
  this.currentUserId = null;
441
376
  this.currentConnectionId = null;
442
377
  this.reconnectInfo = null;
@@ -470,10 +405,6 @@ export class RoomClient {
470
405
  this.memberStateHandlers.length = 0;
471
406
  this.signalHandlers.clear();
472
407
  this.anySignalHandlers.length = 0;
473
- this.mediaTrackHandlers.length = 0;
474
- this.mediaTrackRemovedHandlers.length = 0;
475
- this.mediaStateHandlers.length = 0;
476
- this.mediaDeviceHandlers.length = 0;
477
408
  this.reconnectHandlers.length = 0;
478
409
  this.connectionStateHandlers.length = 0;
479
410
  }
@@ -614,6 +545,14 @@ export class RoomClient {
614
545
  this.memberSyncHandlers.splice(index, 1);
615
546
  });
616
547
  }
548
+ onMemberSnapshot(handler) {
549
+ this.memberSnapshotHandlers.push(handler);
550
+ return createSubscription(() => {
551
+ const index = this.memberSnapshotHandlers.indexOf(handler);
552
+ if (index >= 0)
553
+ this.memberSnapshotHandlers.splice(index, 1);
554
+ });
555
+ }
617
556
  onMemberJoin(handler) {
618
557
  this.memberJoinHandlers.push(handler);
619
558
  return createSubscription(() => {
@@ -646,44 +585,20 @@ export class RoomClient {
646
585
  this.reconnectHandlers.splice(index, 1);
647
586
  });
648
587
  }
649
- onConnectionStateChange(handler) {
650
- this.connectionStateHandlers.push(handler);
651
- return createSubscription(() => {
652
- const index = this.connectionStateHandlers.indexOf(handler);
653
- if (index >= 0)
654
- this.connectionStateHandlers.splice(index, 1);
655
- });
656
- }
657
- onMediaTrack(handler) {
658
- this.mediaTrackHandlers.push(handler);
659
- return createSubscription(() => {
660
- const index = this.mediaTrackHandlers.indexOf(handler);
661
- if (index >= 0)
662
- this.mediaTrackHandlers.splice(index, 1);
663
- });
664
- }
665
- onMediaTrackRemoved(handler) {
666
- this.mediaTrackRemovedHandlers.push(handler);
667
- return createSubscription(() => {
668
- const index = this.mediaTrackRemovedHandlers.indexOf(handler);
669
- if (index >= 0)
670
- this.mediaTrackRemovedHandlers.splice(index, 1);
671
- });
672
- }
673
- onMediaStateChange(handler) {
674
- this.mediaStateHandlers.push(handler);
588
+ onRecoveryFailure(handler) {
589
+ this.recoveryFailureHandlers.push(handler);
675
590
  return createSubscription(() => {
676
- const index = this.mediaStateHandlers.indexOf(handler);
591
+ const index = this.recoveryFailureHandlers.indexOf(handler);
677
592
  if (index >= 0)
678
- this.mediaStateHandlers.splice(index, 1);
593
+ this.recoveryFailureHandlers.splice(index, 1);
679
594
  });
680
595
  }
681
- onMediaDeviceChange(handler) {
682
- this.mediaDeviceHandlers.push(handler);
596
+ onConnectionStateChange(handler) {
597
+ this.connectionStateHandlers.push(handler);
683
598
  return createSubscription(() => {
684
- const index = this.mediaDeviceHandlers.indexOf(handler);
599
+ const index = this.connectionStateHandlers.indexOf(handler);
685
600
  if (index >= 0)
686
- this.mediaDeviceHandlers.splice(index, 1);
601
+ this.connectionStateHandlers.splice(index, 1);
687
602
  });
688
603
  }
689
604
  async sendSignal(event, payload, options) {
@@ -706,17 +621,26 @@ export class RoomClient {
706
621
  });
707
622
  }
708
623
  async sendMemberState(state) {
624
+ const nextState = {
625
+ ...(this.lastLocalMemberState ?? {}),
626
+ ...cloneRecord(state),
627
+ };
709
628
  return this.sendMemberStateRequest({
710
629
  type: 'member_state',
711
630
  state,
631
+ }, () => {
632
+ this.lastLocalMemberState = nextState;
712
633
  });
713
634
  }
714
635
  async clearMemberState() {
636
+ const clearedState = {};
715
637
  return this.sendMemberStateRequest({
716
638
  type: 'member_state_clear',
639
+ }, () => {
640
+ this.lastLocalMemberState = clearedState;
717
641
  });
718
642
  }
719
- async sendMemberStateRequest(payload) {
643
+ async sendMemberStateRequest(payload, onSuccess) {
720
644
  this.assertConnected('updating member state');
721
645
  const requestId = generateRequestId();
722
646
  return new Promise((resolve, reject) => {
@@ -724,7 +648,7 @@ export class RoomClient {
724
648
  this.pendingMemberStateRequests.delete(requestId);
725
649
  reject(new EdgeBaseError(408, 'Member state update timed out'));
726
650
  }, this.options.sendTimeout);
727
- this.pendingMemberStateRequests.set(requestId, { resolve, reject, timeout });
651
+ this.pendingMemberStateRequests.set(requestId, { resolve, reject, timeout, onSuccess });
728
652
  this.sendRaw({ ...payload, requestId });
729
653
  });
730
654
  }
@@ -746,37 +670,6 @@ export class RoomClient {
746
670
  });
747
671
  });
748
672
  }
749
- async sendMedia(operation, kind, payload) {
750
- this.assertConnected(`running media operation '${operation}' for '${kind}'`);
751
- const requestId = generateRequestId();
752
- return new Promise((resolve, reject) => {
753
- const timeout = setTimeout(() => {
754
- this.pendingMediaRequests.delete(requestId);
755
- reject(new EdgeBaseError(408, `Media operation '${operation}' timed out`));
756
- }, this.options.sendTimeout);
757
- this.pendingMediaRequests.set(requestId, { resolve, reject, timeout });
758
- this.sendRaw({
759
- type: 'media',
760
- operation,
761
- kind,
762
- payload: payload ?? {},
763
- requestId,
764
- });
765
- });
766
- }
767
- async switchMediaDevices(payload) {
768
- const operations = [];
769
- if (payload.audioInputId) {
770
- operations.push(this.sendMedia('device', 'audio', { deviceId: payload.audioInputId }));
771
- }
772
- if (payload.videoInputId) {
773
- operations.push(this.sendMedia('device', 'video', { deviceId: payload.videoInputId }));
774
- }
775
- if (payload.screenInputId) {
776
- operations.push(this.sendMedia('device', 'screen', { deviceId: payload.screenInputId }));
777
- }
778
- await Promise.all(operations);
779
- }
780
673
  // ─── Private: Connection ───
781
674
  async establishConnection() {
782
675
  return new Promise((resolve, reject) => {
@@ -900,6 +793,7 @@ export class RoomClient {
900
793
  lastSharedVersion: this._sharedVersion,
901
794
  lastPlayerState: this._playerState,
902
795
  lastPlayerVersion: this._playerVersion,
796
+ lastMemberState: this.getReconnectMemberState(),
903
797
  });
904
798
  this.joined = true;
905
799
  resolve();
@@ -958,9 +852,6 @@ export class RoomClient {
958
852
  case 'members_sync':
959
853
  this.handleMembersSync(msg);
960
854
  break;
961
- case 'media_sync':
962
- this.handleMediaSync(msg);
963
- break;
964
855
  case 'member_join':
965
856
  this.handleMemberJoinFrame(msg);
966
857
  break;
@@ -973,24 +864,6 @@ export class RoomClient {
973
864
  case 'member_state_error':
974
865
  this.handleMemberStateError(msg);
975
866
  break;
976
- case 'media_track':
977
- this.handleMediaTrackFrame(msg);
978
- break;
979
- case 'media_track_removed':
980
- this.handleMediaTrackRemovedFrame(msg);
981
- break;
982
- case 'media_state':
983
- this.handleMediaStateFrame(msg);
984
- break;
985
- case 'media_device':
986
- this.handleMediaDeviceFrame(msg);
987
- break;
988
- case 'media_result':
989
- this.handleMediaResult(msg);
990
- break;
991
- case 'media_error':
992
- this.handleMediaError(msg);
993
- break;
994
867
  case 'admin_result':
995
868
  this.handleAdminResult(msg);
996
869
  break;
@@ -1005,6 +878,7 @@ export class RoomClient {
1005
878
  break;
1006
879
  case 'pong':
1007
880
  // Heartbeat response — no action needed
881
+ this.lastHeartbeatAckAt = Date.now();
1008
882
  break;
1009
883
  }
1010
884
  }
@@ -1134,23 +1008,19 @@ export class RoomClient {
1134
1008
  handleMembersSync(msg) {
1135
1009
  const members = this.normalizeMembers(msg.members);
1136
1010
  this._members = members;
1137
- for (const member of members) {
1138
- this.syncMediaMemberInfo(member);
1139
- }
1140
- const snapshot = cloneValue(this._members);
1011
+ const snapshot = this._members.map((member) => cloneValue(member));
1141
1012
  for (const handler of this.memberSyncHandlers) {
1142
1013
  handler(snapshot);
1143
1014
  }
1144
- }
1145
- handleMediaSync(msg) {
1146
- this._mediaMembers = this.normalizeMediaMembers(msg.members);
1015
+ for (const handler of this.memberSnapshotHandlers) {
1016
+ handler(snapshot);
1017
+ }
1147
1018
  }
1148
1019
  handleMemberJoinFrame(msg) {
1149
1020
  const member = this.normalizeMember(msg.member);
1150
1021
  if (!member)
1151
1022
  return;
1152
1023
  this.upsertMember(member);
1153
- this.syncMediaMemberInfo(member);
1154
1024
  const snapshot = cloneValue(member);
1155
1025
  for (const handler of this.memberJoinHandlers) {
1156
1026
  handler(snapshot);
@@ -1161,7 +1031,6 @@ export class RoomClient {
1161
1031
  if (!member)
1162
1032
  return;
1163
1033
  this.removeMember(member.memberId);
1164
- this.removeMediaMember(member.memberId);
1165
1034
  const reason = this.normalizeLeaveReason(msg.reason);
1166
1035
  const snapshot = cloneValue(member);
1167
1036
  for (const handler of this.memberLeaveHandlers) {
@@ -1175,13 +1044,13 @@ export class RoomClient {
1175
1044
  return;
1176
1045
  member.state = state;
1177
1046
  this.upsertMember(member);
1178
- this.syncMediaMemberInfo(member);
1179
1047
  const requestId = msg.requestId;
1180
1048
  if (requestId) {
1181
1049
  const pending = this.pendingMemberStateRequests.get(requestId);
1182
1050
  if (pending) {
1183
1051
  clearTimeout(pending.timeout);
1184
1052
  this.pendingMemberStateRequests.delete(requestId);
1053
+ pending.onSuccess?.();
1185
1054
  pending.resolve();
1186
1055
  }
1187
1056
  }
@@ -1202,115 +1071,6 @@ export class RoomClient {
1202
1071
  this.pendingMemberStateRequests.delete(requestId);
1203
1072
  pending.reject(new EdgeBaseError(400, msg.message || 'Member state update failed'));
1204
1073
  }
1205
- handleMediaTrackFrame(msg) {
1206
- const member = this.normalizeMember(msg.member);
1207
- const track = this.normalizeMediaTrack(msg.track);
1208
- if (!member || !track)
1209
- return;
1210
- const mediaMember = this.ensureMediaMember(member);
1211
- this.upsertMediaTrack(mediaMember, track);
1212
- this.mergeMediaState(mediaMember, track.kind, {
1213
- published: true,
1214
- muted: track.muted,
1215
- trackId: track.trackId,
1216
- deviceId: track.deviceId,
1217
- publishedAt: track.publishedAt,
1218
- adminDisabled: track.adminDisabled,
1219
- providerSessionId: track.providerSessionId,
1220
- });
1221
- const memberSnapshot = cloneValue(mediaMember.member);
1222
- const trackSnapshot = cloneValue(track);
1223
- for (const handler of this.mediaTrackHandlers) {
1224
- handler(trackSnapshot, memberSnapshot);
1225
- }
1226
- const stateSnapshot = cloneValue(mediaMember.state);
1227
- for (const handler of this.mediaStateHandlers) {
1228
- handler(memberSnapshot, stateSnapshot);
1229
- }
1230
- }
1231
- handleMediaTrackRemovedFrame(msg) {
1232
- const member = this.normalizeMember(msg.member);
1233
- const track = this.normalizeMediaTrack(msg.track);
1234
- if (!member || !track)
1235
- return;
1236
- const mediaMember = this.ensureMediaMember(member);
1237
- this.removeMediaTrack(mediaMember, track);
1238
- mediaMember.state = {
1239
- ...mediaMember.state,
1240
- [track.kind]: {
1241
- published: false,
1242
- muted: false,
1243
- adminDisabled: false,
1244
- providerSessionId: undefined,
1245
- },
1246
- };
1247
- const memberSnapshot = cloneValue(mediaMember.member);
1248
- const trackSnapshot = cloneValue(track);
1249
- for (const handler of this.mediaTrackRemovedHandlers) {
1250
- handler(trackSnapshot, memberSnapshot);
1251
- }
1252
- const stateSnapshot = cloneValue(mediaMember.state);
1253
- for (const handler of this.mediaStateHandlers) {
1254
- handler(memberSnapshot, stateSnapshot);
1255
- }
1256
- }
1257
- handleMediaStateFrame(msg) {
1258
- const member = this.normalizeMember(msg.member);
1259
- if (!member)
1260
- return;
1261
- const mediaMember = this.ensureMediaMember(member);
1262
- mediaMember.state = this.normalizeMediaState(msg.state);
1263
- const memberSnapshot = cloneValue(mediaMember.member);
1264
- const stateSnapshot = cloneValue(mediaMember.state);
1265
- for (const handler of this.mediaStateHandlers) {
1266
- handler(memberSnapshot, stateSnapshot);
1267
- }
1268
- }
1269
- handleMediaDeviceFrame(msg) {
1270
- const member = this.normalizeMember(msg.member);
1271
- const kind = this.normalizeMediaKind(msg.kind);
1272
- const deviceId = typeof msg.deviceId === 'string' ? msg.deviceId : '';
1273
- if (!member || !kind || !deviceId)
1274
- return;
1275
- const mediaMember = this.ensureMediaMember(member);
1276
- this.mergeMediaState(mediaMember, kind, { deviceId });
1277
- for (const track of mediaMember.tracks) {
1278
- if (track.kind === kind) {
1279
- track.deviceId = deviceId;
1280
- }
1281
- }
1282
- const memberSnapshot = cloneValue(mediaMember.member);
1283
- const change = { kind, deviceId };
1284
- for (const handler of this.mediaDeviceHandlers) {
1285
- handler(memberSnapshot, change);
1286
- }
1287
- const stateSnapshot = cloneValue(mediaMember.state);
1288
- for (const handler of this.mediaStateHandlers) {
1289
- handler(memberSnapshot, stateSnapshot);
1290
- }
1291
- }
1292
- handleMediaResult(msg) {
1293
- const requestId = msg.requestId;
1294
- if (!requestId)
1295
- return;
1296
- const pending = this.pendingMediaRequests.get(requestId);
1297
- if (!pending)
1298
- return;
1299
- clearTimeout(pending.timeout);
1300
- this.pendingMediaRequests.delete(requestId);
1301
- pending.resolve();
1302
- }
1303
- handleMediaError(msg) {
1304
- const requestId = msg.requestId;
1305
- if (!requestId)
1306
- return;
1307
- const pending = this.pendingMediaRequests.get(requestId);
1308
- if (!pending)
1309
- return;
1310
- clearTimeout(pending.timeout);
1311
- this.pendingMediaRequests.delete(requestId);
1312
- pending.reject(new EdgeBaseError(400, msg.message || 'Media operation failed'));
1313
- }
1314
1074
  handleAdminResult(msg) {
1315
1075
  const requestId = msg.requestId;
1316
1076
  if (!requestId)
@@ -1387,7 +1147,6 @@ export class RoomClient {
1387
1147
  this.connected = false;
1388
1148
  this.authenticated = false;
1389
1149
  this.joined = false;
1390
- this._mediaMembers = [];
1391
1150
  this.currentUserId = null;
1392
1151
  this.currentConnectionId = null;
1393
1152
  try {
@@ -1401,7 +1160,6 @@ export class RoomClient {
1401
1160
  this.connected = false;
1402
1161
  this.authenticated = false;
1403
1162
  this.joined = false;
1404
- this._mediaMembers = [];
1405
1163
  }
1406
1164
  handleAuthenticationFailure(error) {
1407
1165
  const authError = error instanceof EdgeBaseError
@@ -1437,8 +1195,7 @@ export class RoomClient {
1437
1195
  return this.pendingRequests.size > 0
1438
1196
  || this.pendingSignalRequests.size > 0
1439
1197
  || this.pendingAdminRequests.size > 0
1440
- || this.pendingMemberStateRequests.size > 0
1441
- || this.pendingMediaRequests.size > 0;
1198
+ || this.pendingMemberStateRequests.size > 0;
1442
1199
  }
1443
1200
  handleRoomAuthStateLoss(message) {
1444
1201
  const detail = message?.trim();
@@ -1464,14 +1221,6 @@ export class RoomClient {
1464
1221
  .map((member) => this.normalizeMember(member))
1465
1222
  .filter((member) => !!member);
1466
1223
  }
1467
- normalizeMediaMembers(value) {
1468
- if (!Array.isArray(value)) {
1469
- return [];
1470
- }
1471
- return value
1472
- .map((member) => this.normalizeMediaMember(member))
1473
- .filter((member) => !!member);
1474
- }
1475
1224
  normalizeMember(value) {
1476
1225
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
1477
1226
  return null;
@@ -1495,84 +1244,6 @@ export class RoomClient {
1495
1244
  }
1496
1245
  return cloneRecord(value);
1497
1246
  }
1498
- normalizeMediaMember(value) {
1499
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
1500
- return null;
1501
- }
1502
- const entry = value;
1503
- const member = this.normalizeMember(entry.member);
1504
- if (!member) {
1505
- return null;
1506
- }
1507
- return {
1508
- member,
1509
- state: this.normalizeMediaState(entry.state),
1510
- tracks: this.normalizeMediaTracks(entry.tracks),
1511
- };
1512
- }
1513
- normalizeMediaState(value) {
1514
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
1515
- return {};
1516
- }
1517
- const state = value;
1518
- return {
1519
- audio: this.normalizeMediaKindState(state.audio),
1520
- video: this.normalizeMediaKindState(state.video),
1521
- screen: this.normalizeMediaKindState(state.screen),
1522
- };
1523
- }
1524
- normalizeMediaKindState(value) {
1525
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
1526
- return undefined;
1527
- }
1528
- const state = value;
1529
- return {
1530
- published: state.published === true,
1531
- muted: state.muted === true,
1532
- trackId: typeof state.trackId === 'string' ? state.trackId : undefined,
1533
- deviceId: typeof state.deviceId === 'string' ? state.deviceId : undefined,
1534
- publishedAt: typeof state.publishedAt === 'number' ? state.publishedAt : undefined,
1535
- adminDisabled: state.adminDisabled === true,
1536
- providerSessionId: typeof state.providerSessionId === 'string' ? state.providerSessionId : undefined,
1537
- };
1538
- }
1539
- normalizeMediaTracks(value) {
1540
- if (!Array.isArray(value)) {
1541
- return [];
1542
- }
1543
- return value
1544
- .map((track) => this.normalizeMediaTrack(track))
1545
- .filter((track) => !!track);
1546
- }
1547
- normalizeMediaTrack(value) {
1548
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
1549
- return null;
1550
- }
1551
- const track = value;
1552
- const kind = this.normalizeMediaKind(track.kind);
1553
- if (!kind) {
1554
- return null;
1555
- }
1556
- return {
1557
- kind,
1558
- trackId: typeof track.trackId === 'string' ? track.trackId : undefined,
1559
- deviceId: typeof track.deviceId === 'string' ? track.deviceId : undefined,
1560
- muted: track.muted === true,
1561
- publishedAt: typeof track.publishedAt === 'number' ? track.publishedAt : undefined,
1562
- adminDisabled: track.adminDisabled === true,
1563
- providerSessionId: typeof track.providerSessionId === 'string' ? track.providerSessionId : undefined,
1564
- };
1565
- }
1566
- normalizeMediaKind(value) {
1567
- switch (value) {
1568
- case 'audio':
1569
- case 'video':
1570
- case 'screen':
1571
- return value;
1572
- default:
1573
- return null;
1574
- }
1575
- }
1576
1247
  normalizeSignalMeta(value) {
1577
1248
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
1578
1249
  return {};
@@ -1598,6 +1269,20 @@ export class RoomClient {
1598
1269
  return 'leave';
1599
1270
  }
1600
1271
  }
1272
+ getDebugSnapshot() {
1273
+ return {
1274
+ connectionState: this.connectionState,
1275
+ connected: this.connected,
1276
+ authenticated: this.authenticated,
1277
+ joined: this.joined,
1278
+ currentUserId: this.currentUserId,
1279
+ currentConnectionId: this.currentConnectionId,
1280
+ membersCount: this._members.length,
1281
+ reconnectAttempts: this.reconnectAttempts,
1282
+ joinRequested: this.joinRequested,
1283
+ waitingForAuth: this.waitingForAuth,
1284
+ };
1285
+ }
1601
1286
  upsertMember(member) {
1602
1287
  const index = this._members.findIndex((entry) => entry.memberId === member.memberId);
1603
1288
  if (index >= 0) {
@@ -1609,64 +1294,6 @@ export class RoomClient {
1609
1294
  removeMember(memberId) {
1610
1295
  this._members = this._members.filter((member) => member.memberId !== memberId);
1611
1296
  }
1612
- syncMediaMemberInfo(member) {
1613
- const mediaMember = this._mediaMembers.find((entry) => entry.member.memberId === member.memberId);
1614
- if (!mediaMember) {
1615
- return;
1616
- }
1617
- mediaMember.member = cloneValue(member);
1618
- }
1619
- ensureMediaMember(member) {
1620
- const existing = this._mediaMembers.find((entry) => entry.member.memberId === member.memberId);
1621
- if (existing) {
1622
- existing.member = cloneValue(member);
1623
- return existing;
1624
- }
1625
- const created = {
1626
- member: cloneValue(member),
1627
- state: {},
1628
- tracks: [],
1629
- };
1630
- this._mediaMembers.push(created);
1631
- return created;
1632
- }
1633
- removeMediaMember(memberId) {
1634
- this._mediaMembers = this._mediaMembers.filter((member) => member.member.memberId !== memberId);
1635
- }
1636
- upsertMediaTrack(mediaMember, track) {
1637
- const index = mediaMember.tracks.findIndex((entry) => entry.kind === track.kind &&
1638
- entry.trackId === track.trackId);
1639
- if (index >= 0) {
1640
- mediaMember.tracks[index] = cloneValue(track);
1641
- return;
1642
- }
1643
- mediaMember.tracks = mediaMember.tracks
1644
- .filter((entry) => !(entry.kind === track.kind && !track.trackId))
1645
- .concat(cloneValue(track));
1646
- }
1647
- removeMediaTrack(mediaMember, track) {
1648
- mediaMember.tracks = mediaMember.tracks.filter((entry) => {
1649
- if (track.trackId) {
1650
- return !(entry.kind === track.kind && entry.trackId === track.trackId);
1651
- }
1652
- return entry.kind !== track.kind;
1653
- });
1654
- }
1655
- mergeMediaState(mediaMember, kind, partial) {
1656
- const next = {
1657
- published: partial.published ?? mediaMember.state[kind]?.published ?? false,
1658
- muted: partial.muted ?? mediaMember.state[kind]?.muted ?? false,
1659
- trackId: partial.trackId ?? mediaMember.state[kind]?.trackId,
1660
- deviceId: partial.deviceId ?? mediaMember.state[kind]?.deviceId,
1661
- publishedAt: partial.publishedAt ?? mediaMember.state[kind]?.publishedAt,
1662
- adminDisabled: partial.adminDisabled ?? mediaMember.state[kind]?.adminDisabled,
1663
- providerSessionId: partial.providerSessionId ?? mediaMember.state[kind]?.providerSessionId,
1664
- };
1665
- mediaMember.state = {
1666
- ...mediaMember.state,
1667
- [kind]: next,
1668
- };
1669
- }
1670
1297
  /** Reject all 5 pending request maps at once. */
1671
1298
  rejectAllPendingRequests(error) {
1672
1299
  for (const [, pending] of this.pendingRequests) {
@@ -1677,7 +1304,6 @@ export class RoomClient {
1677
1304
  this.rejectPendingVoidRequests(this.pendingSignalRequests, error);
1678
1305
  this.rejectPendingVoidRequests(this.pendingAdminRequests, error);
1679
1306
  this.rejectPendingVoidRequests(this.pendingMemberStateRequests, error);
1680
- this.rejectPendingVoidRequests(this.pendingMediaRequests, error);
1681
1307
  }
1682
1308
  rejectPendingVoidRequests(pendingRequests, error) {
1683
1309
  for (const [, pending] of pendingRequests) {
@@ -1686,11 +1312,59 @@ export class RoomClient {
1686
1312
  }
1687
1313
  pendingRequests.clear();
1688
1314
  }
1315
+ shouldScheduleDisconnectReset(next) {
1316
+ if (this.intentionallyLeft || !this.joinRequested) {
1317
+ return false;
1318
+ }
1319
+ return next === 'disconnected';
1320
+ }
1321
+ clearDisconnectResetTimer() {
1322
+ if (this.disconnectResetTimer) {
1323
+ clearTimeout(this.disconnectResetTimer);
1324
+ this.disconnectResetTimer = null;
1325
+ }
1326
+ }
1327
+ scheduleDisconnectReset(stateAtSchedule) {
1328
+ this.clearDisconnectResetTimer();
1329
+ const timeoutMs = this.options.disconnectResetTimeoutMs;
1330
+ if (!(timeoutMs > 0)) {
1331
+ return;
1332
+ }
1333
+ this.disconnectResetTimer = setTimeout(() => {
1334
+ this.disconnectResetTimer = null;
1335
+ if (this.intentionallyLeft || !this.joinRequested) {
1336
+ return;
1337
+ }
1338
+ if (this.connectionState !== stateAtSchedule) {
1339
+ return;
1340
+ }
1341
+ if (this.connectionState === 'connected') {
1342
+ return;
1343
+ }
1344
+ for (const handler of this.recoveryFailureHandlers) {
1345
+ try {
1346
+ handler({
1347
+ state: this.connectionState,
1348
+ timeoutMs,
1349
+ });
1350
+ }
1351
+ catch {
1352
+ // Ignore recovery failure handler errors.
1353
+ }
1354
+ }
1355
+ }, timeoutMs);
1356
+ }
1689
1357
  setConnectionState(next) {
1690
1358
  if (this.connectionState === next) {
1691
1359
  return;
1692
1360
  }
1693
1361
  this.connectionState = next;
1362
+ if (this.shouldScheduleDisconnectReset(next)) {
1363
+ this.scheduleDisconnectReset(next);
1364
+ }
1365
+ else {
1366
+ this.clearDisconnectResetTimer();
1367
+ }
1694
1368
  for (const handler of this.connectionStateHandlers) {
1695
1369
  handler(next);
1696
1370
  }
@@ -1757,11 +1431,21 @@ export class RoomClient {
1757
1431
  }
1758
1432
  startHeartbeat() {
1759
1433
  this.stopHeartbeat();
1434
+ this.lastHeartbeatAckAt = Date.now();
1760
1435
  this.heartbeatTimer = setInterval(() => {
1761
1436
  if (this.ws && this.connected) {
1437
+ if (Date.now() - this.lastHeartbeatAckAt > this.options.heartbeatStaleTimeoutMs) {
1438
+ try {
1439
+ this.ws.close();
1440
+ }
1441
+ catch {
1442
+ // Socket may already be closing.
1443
+ }
1444
+ return;
1445
+ }
1762
1446
  this.ws.send(JSON.stringify({ type: 'ping' }));
1763
1447
  }
1764
- }, 30000);
1448
+ }, this.options.heartbeatIntervalMs);
1765
1449
  }
1766
1450
  stopHeartbeat() {
1767
1451
  if (this.heartbeatTimer) {
@@ -1769,5 +1453,11 @@ export class RoomClient {
1769
1453
  this.heartbeatTimer = null;
1770
1454
  }
1771
1455
  }
1456
+ getReconnectMemberState() {
1457
+ if (!this.lastLocalMemberState) {
1458
+ return undefined;
1459
+ }
1460
+ return cloneRecord(this.lastLocalMemberState);
1461
+ }
1772
1462
  }
1773
1463
  //# sourceMappingURL=room.js.map