@edge-base/web 0.2.5 → 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
- import { EdgeBaseError, createSubscription } from '@edge-base/core';
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(),
@@ -154,6 +153,7 @@ export class RoomClient {
154
153
  };
155
154
  meta = {
156
155
  get: () => this.getMetadata(),
156
+ summary: () => this.getSummary(),
157
157
  };
158
158
  signals = {
159
159
  send: (event, payload, options) => this.sendSignal(event, payload, options),
@@ -162,7 +162,7 @@ export class RoomClient {
162
162
  onAny: (handler) => this.onAnySignal(handler),
163
163
  };
164
164
  members = {
165
- list: () => cloneValue(this._members),
165
+ list: () => this._members.map((member) => cloneValue(member)),
166
166
  current: () => {
167
167
  const connectionId = this.currentConnectionId;
168
168
  if (connectionId) {
@@ -178,7 +178,9 @@ export class RoomClient {
178
178
  const member = this._members.find((entry) => entry.userId === userId) ?? null;
179
179
  return member ? cloneValue(member) : null;
180
180
  },
181
+ awaitCurrent: (timeoutMs = 10_000) => this.waitForCurrentMember(timeoutMs),
181
182
  onSync: (handler) => this.onMembersSync(handler),
183
+ onSnapshot: (handler) => this.onMemberSnapshot(handler),
182
184
  onJoin: (handler) => this.onMemberJoin(handler),
183
185
  onLeave: (handler) => this.onMemberLeave(handler),
184
186
  setState: (state) => this.sendMemberState(state),
@@ -187,59 +189,16 @@ export class RoomClient {
187
189
  };
188
190
  admin = {
189
191
  kick: (memberId) => this.sendAdmin('kick', memberId),
190
- mute: (memberId) => this.sendAdmin('mute', memberId),
191
192
  block: (memberId) => this.sendAdmin('block', memberId),
192
193
  setRole: (memberId, role) => this.sendAdmin('setRole', memberId, { role }),
193
- disableVideo: (memberId) => this.sendAdmin('disableVideo', memberId),
194
- stopScreenShare: (memberId) => this.sendAdmin('stopScreenShare', memberId),
195
- };
196
- media = {
197
- list: () => cloneValue(this._mediaMembers),
198
- audio: {
199
- enable: (payload) => this.sendMedia('publish', 'audio', payload),
200
- disable: () => this.sendMedia('unpublish', 'audio'),
201
- setMuted: (muted) => this.sendMedia('mute', 'audio', { muted }),
202
- },
203
- video: {
204
- enable: (payload) => this.sendMedia('publish', 'video', payload),
205
- disable: () => this.sendMedia('unpublish', 'video'),
206
- setMuted: (muted) => this.sendMedia('mute', 'video', { muted }),
207
- },
208
- screen: {
209
- start: (payload) => this.sendMedia('publish', 'screen', payload),
210
- stop: () => this.sendMedia('unpublish', 'screen'),
211
- },
212
- devices: {
213
- switch: (payload) => this.switchMediaDevices(payload),
214
- },
215
- realtime: {
216
- iceServers: (payload) => this.requestRealtimeMedia('turn', 'POST', payload),
217
- },
218
- cloudflareRealtimeKit: {
219
- createSession: (payload) => this.requestCloudflareRealtimeKitMedia('session', 'POST', payload),
220
- },
221
- transport: (options) => {
222
- // Infer provider from options: if cloudflareRealtimeKit config is present, use it;
223
- // otherwise default to p2p for zero-config local development.
224
- const hasCloudflareConfig = options && 'cloudflareRealtimeKit' in options && options.cloudflareRealtimeKit != null;
225
- const provider = options?.provider ?? (hasCloudflareConfig ? 'cloudflare_realtimekit' : 'p2p');
226
- if (provider === 'p2p') {
227
- const p2pOptions = options?.p2p;
228
- return new RoomP2PMediaTransport(this, p2pOptions);
229
- }
230
- const cloudflareOptions = options?.cloudflareRealtimeKit;
231
- return new RoomCloudflareMediaTransport(this, cloudflareOptions);
232
- },
233
- onTrack: (handler) => this.onMediaTrack(handler),
234
- onTrackRemoved: (handler) => this.onMediaTrackRemoved(handler),
235
- onStateChange: (handler) => this.onMediaStateChange(handler),
236
- onDeviceChange: (handler) => this.onMediaDeviceChange(handler),
237
194
  };
238
195
  session = {
239
196
  onError: (handler) => this.onError(handler),
240
197
  onKicked: (handler) => this.onKicked(handler),
241
198
  onReconnect: (handler) => this.onReconnect(handler),
242
199
  onConnectionStateChange: (handler) => this.onConnectionStateChange(handler),
200
+ onRecoveryFailure: (handler) => this.onRecoveryFailure(handler),
201
+ getDebugSnapshot: () => this.getDebugSnapshot(),
243
202
  };
244
203
  constructor(baseUrl, namespace, roomId, tokenManager, options) {
245
204
  this.baseUrl = baseUrl;
@@ -252,6 +211,11 @@ export class RoomClient {
252
211
  reconnectBaseDelay: options?.reconnectBaseDelay ?? 1000,
253
212
  sendTimeout: options?.sendTimeout ?? 10000,
254
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,
255
219
  };
256
220
  this.unsubAuthState = this.tokenManager.onAuthStateChange((user) => {
257
221
  this.handleAuthStateChange(user);
@@ -267,6 +231,17 @@ export class RoomClient {
267
231
  getPlayerState() {
268
232
  return cloneRecord(this._playerState);
269
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
+ }
270
245
  // ─── Metadata (HTTP, no WebSocket needed) ───
271
246
  /**
272
247
  * Get room metadata without joining (HTTP GET).
@@ -275,45 +250,91 @@ export class RoomClient {
275
250
  async getMetadata() {
276
251
  return RoomClient.getMetadata(this.baseUrl, this.namespace, this.roomId);
277
252
  }
253
+ async getSummary() {
254
+ return RoomClient.getSummary(this.baseUrl, this.namespace, this.roomId);
255
+ }
256
+ async checkConnection() {
257
+ return RoomClient.checkConnection(this.baseUrl, this.namespace, this.roomId);
258
+ }
278
259
  /**
279
260
  * Static: Get room metadata without creating a RoomClient instance.
280
261
  * Useful for lobby screens where you need room info before joining.
281
262
  */
282
263
  static async getMetadata(baseUrl, namespace, roomId) {
283
- const url = `${baseUrl.replace(/\/$/, '')}/api/room/metadata?namespace=${encodeURIComponent(namespace)}&id=${encodeURIComponent(roomId)}`;
284
- const res = await fetch(url);
285
- if (!res.ok) {
286
- throw new EdgeBaseError(res.status, `Failed to get room metadata: ${res.statusText}`);
287
- }
288
- return res.json();
264
+ return RoomClient.requestPublicRoomResource(baseUrl, 'metadata', namespace, roomId, 'Failed to get room metadata');
289
265
  }
290
- async requestCloudflareRealtimeKitMedia(path, method, payload) {
291
- return this.requestRoomMedia('cloudflare_realtimekit', path, method, payload);
266
+ static async getSummary(baseUrl, namespace, roomId) {
267
+ return RoomClient.requestPublicRoomResource(baseUrl, 'summary', namespace, roomId, 'Failed to get room summary');
292
268
  }
293
- async requestRealtimeMedia(path, method, payload) {
294
- return this.requestRoomMedia('realtime', path, method, payload);
269
+ static async getSummaries(baseUrl, namespace, roomIds) {
270
+ const url = `${baseUrl.replace(/\/$/, '')}/api/room/summaries`;
271
+ let res;
272
+ try {
273
+ res = await fetch(url, {
274
+ method: 'POST',
275
+ headers: { 'Content-Type': 'application/json' },
276
+ body: JSON.stringify({ namespace, ids: roomIds }),
277
+ });
278
+ }
279
+ catch (error) {
280
+ throw networkError(`Failed to get room summaries. Could not reach ${baseUrl.replace(/\/$/, '')}. Make sure the EdgeBase server is running and reachable.`, { cause: error });
281
+ }
282
+ const data = (await res.json().catch(() => null));
283
+ if (!res.ok) {
284
+ const parsed = parseErrorResponse(res.status, data);
285
+ parsed.message = `Failed to get room summaries: ${parsed.message}`;
286
+ throw parsed;
287
+ }
288
+ return data;
295
289
  }
296
- async requestRoomMedia(providerPath, path, method, payload) {
297
- const token = await this.tokenManager.getAccessToken((refreshToken) => refreshAccessToken(this.baseUrl, refreshToken));
298
- if (!token) {
299
- throw new EdgeBaseError(401, 'Authentication required');
300
- }
301
- const url = new URL(`${this.baseUrl.replace(/\/$/, '')}/api/room/media/${providerPath}/${path}`);
302
- url.searchParams.set('namespace', this.namespace);
303
- url.searchParams.set('id', this.roomId);
304
- const response = await fetch(url.toString(), {
305
- method,
306
- headers: {
307
- Authorization: `Bearer ${token}`,
308
- 'Content-Type': 'application/json',
309
- },
310
- body: method === 'GET' ? undefined : JSON.stringify(payload ?? {}),
311
- });
312
- const data = (await response.json().catch(() => ({})));
290
+ static async checkConnection(baseUrl, namespace, roomId) {
291
+ const url = `${baseUrl.replace(/\/$/, '')}/api/room/connect-check?namespace=${encodeURIComponent(namespace)}&id=${encodeURIComponent(roomId)}`;
292
+ let response;
293
+ try {
294
+ response = await fetch(url);
295
+ }
296
+ catch (error) {
297
+ throw networkError(`Room connect-check could not reach ${baseUrl.replace(/\/$/, '')}. Make sure the EdgeBase server is running and reachable.`, { cause: error });
298
+ }
299
+ const data = (await response.json().catch(() => null));
313
300
  if (!response.ok) {
314
- throw new EdgeBaseError(response.status, (typeof data.message === 'string' && data.message) || `Room media request failed: ${response.statusText}`);
301
+ throw parseErrorResponse(response.status, data);
315
302
  }
316
- return data;
303
+ if (typeof data?.ok !== 'boolean'
304
+ || typeof data?.type !== 'string'
305
+ || typeof data?.category !== 'string'
306
+ || typeof data?.message !== 'string') {
307
+ throw new EdgeBaseError(response.status || 500, 'Room connect-check returned an unexpected response. The EdgeBase server and SDK may be out of sync.');
308
+ }
309
+ const diagnostic = data;
310
+ return {
311
+ ok: diagnostic.ok,
312
+ type: diagnostic.type,
313
+ category: diagnostic.category,
314
+ message: diagnostic.message,
315
+ namespace: typeof diagnostic.namespace === 'string' ? diagnostic.namespace : undefined,
316
+ roomId: typeof diagnostic.roomId === 'string' ? diagnostic.roomId : undefined,
317
+ runtime: typeof diagnostic.runtime === 'string' ? diagnostic.runtime : undefined,
318
+ pendingCount: typeof diagnostic.pendingCount === 'number' ? diagnostic.pendingCount : undefined,
319
+ maxPending: typeof diagnostic.maxPending === 'number' ? diagnostic.maxPending : undefined,
320
+ };
321
+ }
322
+ static async requestPublicRoomResource(baseUrl, resource, namespace, roomId, failureMessage) {
323
+ const url = `${baseUrl.replace(/\/$/, '')}/api/room/${resource}?namespace=${encodeURIComponent(namespace)}&id=${encodeURIComponent(roomId)}`;
324
+ let res;
325
+ try {
326
+ res = await fetch(url);
327
+ }
328
+ catch (error) {
329
+ throw networkError(`${failureMessage}. Could not reach ${baseUrl.replace(/\/$/, '')}. Make sure the EdgeBase server is running and reachable.`, { cause: error });
330
+ }
331
+ if (!res.ok) {
332
+ const data = await res.json().catch(() => null);
333
+ const parsed = parseErrorResponse(res.status, data);
334
+ parsed.message = `${failureMessage}: ${parsed.message}`;
335
+ throw parsed;
336
+ }
337
+ return res.json();
317
338
  }
318
339
  // ─── Connection Lifecycle ───
319
340
  /** Connect to the room, authenticate, and join */
@@ -332,6 +353,7 @@ export class RoomClient {
332
353
  this.joinRequested = false;
333
354
  this.waitingForAuth = false;
334
355
  this.stopHeartbeat();
356
+ this.clearDisconnectResetTimer();
335
357
  // Reject all pending send() requests
336
358
  this.rejectAllPendingRequests(new EdgeBaseError(499, 'Room left'));
337
359
  if (this.ws) {
@@ -349,12 +371,17 @@ export class RoomClient {
349
371
  this._playerState = {};
350
372
  this._playerVersion = 0;
351
373
  this._members = [];
352
- this._mediaMembers = [];
374
+ this.lastLocalMemberState = null;
353
375
  this.currentUserId = null;
354
376
  this.currentConnectionId = null;
355
377
  this.reconnectInfo = null;
356
378
  this.setConnectionState('disconnected');
357
379
  }
380
+ assertConnected(action) {
381
+ if (!this.ws || !this.connected || !this.authenticated) {
382
+ throw new EdgeBaseError(400, `Room connection required before ${action}. Call room.join() and wait for the connection to finish.`);
383
+ }
384
+ }
358
385
  /**
359
386
  * Destroy the RoomClient and release all resources.
360
387
  * Calls leave() if still connected, unsubscribes from auth state changes,
@@ -378,10 +405,6 @@ export class RoomClient {
378
405
  this.memberStateHandlers.length = 0;
379
406
  this.signalHandlers.clear();
380
407
  this.anySignalHandlers.length = 0;
381
- this.mediaTrackHandlers.length = 0;
382
- this.mediaTrackRemovedHandlers.length = 0;
383
- this.mediaStateHandlers.length = 0;
384
- this.mediaDeviceHandlers.length = 0;
385
408
  this.reconnectHandlers.length = 0;
386
409
  this.connectionStateHandlers.length = 0;
387
410
  }
@@ -394,9 +417,7 @@ export class RoomClient {
394
417
  * const result = await room.send('SET_SCORE', { score: 42 });
395
418
  */
396
419
  async send(actionType, payload) {
397
- if (!this.ws || !this.connected || !this.authenticated) {
398
- throw new EdgeBaseError(400, 'Not connected to room');
399
- }
420
+ this.assertConnected(`sending action '${actionType}'`);
400
421
  const requestId = generateRequestId();
401
422
  return new Promise((resolve, reject) => {
402
423
  const timeout = setTimeout(() => {
@@ -524,6 +545,14 @@ export class RoomClient {
524
545
  this.memberSyncHandlers.splice(index, 1);
525
546
  });
526
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
+ }
527
556
  onMemberJoin(handler) {
528
557
  this.memberJoinHandlers.push(handler);
529
558
  return createSubscription(() => {
@@ -556,50 +585,24 @@ export class RoomClient {
556
585
  this.reconnectHandlers.splice(index, 1);
557
586
  });
558
587
  }
559
- onConnectionStateChange(handler) {
560
- this.connectionStateHandlers.push(handler);
561
- return createSubscription(() => {
562
- const index = this.connectionStateHandlers.indexOf(handler);
563
- if (index >= 0)
564
- this.connectionStateHandlers.splice(index, 1);
565
- });
566
- }
567
- onMediaTrack(handler) {
568
- this.mediaTrackHandlers.push(handler);
569
- return createSubscription(() => {
570
- const index = this.mediaTrackHandlers.indexOf(handler);
571
- if (index >= 0)
572
- this.mediaTrackHandlers.splice(index, 1);
573
- });
574
- }
575
- onMediaTrackRemoved(handler) {
576
- this.mediaTrackRemovedHandlers.push(handler);
577
- return createSubscription(() => {
578
- const index = this.mediaTrackRemovedHandlers.indexOf(handler);
579
- if (index >= 0)
580
- this.mediaTrackRemovedHandlers.splice(index, 1);
581
- });
582
- }
583
- onMediaStateChange(handler) {
584
- this.mediaStateHandlers.push(handler);
588
+ onRecoveryFailure(handler) {
589
+ this.recoveryFailureHandlers.push(handler);
585
590
  return createSubscription(() => {
586
- const index = this.mediaStateHandlers.indexOf(handler);
591
+ const index = this.recoveryFailureHandlers.indexOf(handler);
587
592
  if (index >= 0)
588
- this.mediaStateHandlers.splice(index, 1);
593
+ this.recoveryFailureHandlers.splice(index, 1);
589
594
  });
590
595
  }
591
- onMediaDeviceChange(handler) {
592
- this.mediaDeviceHandlers.push(handler);
596
+ onConnectionStateChange(handler) {
597
+ this.connectionStateHandlers.push(handler);
593
598
  return createSubscription(() => {
594
- const index = this.mediaDeviceHandlers.indexOf(handler);
599
+ const index = this.connectionStateHandlers.indexOf(handler);
595
600
  if (index >= 0)
596
- this.mediaDeviceHandlers.splice(index, 1);
601
+ this.connectionStateHandlers.splice(index, 1);
597
602
  });
598
603
  }
599
604
  async sendSignal(event, payload, options) {
600
- if (!this.ws || !this.connected || !this.authenticated) {
601
- throw new EdgeBaseError(400, 'Not connected to room');
602
- }
605
+ this.assertConnected(`sending signal '${event}'`);
603
606
  const requestId = generateRequestId();
604
607
  return new Promise((resolve, reject) => {
605
608
  const timeout = setTimeout(() => {
@@ -618,34 +621,39 @@ export class RoomClient {
618
621
  });
619
622
  }
620
623
  async sendMemberState(state) {
624
+ const nextState = {
625
+ ...(this.lastLocalMemberState ?? {}),
626
+ ...cloneRecord(state),
627
+ };
621
628
  return this.sendMemberStateRequest({
622
629
  type: 'member_state',
623
630
  state,
631
+ }, () => {
632
+ this.lastLocalMemberState = nextState;
624
633
  });
625
634
  }
626
635
  async clearMemberState() {
636
+ const clearedState = {};
627
637
  return this.sendMemberStateRequest({
628
638
  type: 'member_state_clear',
639
+ }, () => {
640
+ this.lastLocalMemberState = clearedState;
629
641
  });
630
642
  }
631
- async sendMemberStateRequest(payload) {
632
- if (!this.ws || !this.connected || !this.authenticated) {
633
- throw new EdgeBaseError(400, 'Not connected to room');
634
- }
643
+ async sendMemberStateRequest(payload, onSuccess) {
644
+ this.assertConnected('updating member state');
635
645
  const requestId = generateRequestId();
636
646
  return new Promise((resolve, reject) => {
637
647
  const timeout = setTimeout(() => {
638
648
  this.pendingMemberStateRequests.delete(requestId);
639
649
  reject(new EdgeBaseError(408, 'Member state update timed out'));
640
650
  }, this.options.sendTimeout);
641
- this.pendingMemberStateRequests.set(requestId, { resolve, reject, timeout });
651
+ this.pendingMemberStateRequests.set(requestId, { resolve, reject, timeout, onSuccess });
642
652
  this.sendRaw({ ...payload, requestId });
643
653
  });
644
654
  }
645
655
  async sendAdmin(operation, memberId, payload) {
646
- if (!this.ws || !this.connected || !this.authenticated) {
647
- throw new EdgeBaseError(400, 'Not connected to room');
648
- }
656
+ this.assertConnected(`running admin operation '${operation}'`);
649
657
  const requestId = generateRequestId();
650
658
  return new Promise((resolve, reject) => {
651
659
  const timeout = setTimeout(() => {
@@ -662,39 +670,6 @@ export class RoomClient {
662
670
  });
663
671
  });
664
672
  }
665
- async sendMedia(operation, kind, payload) {
666
- if (!this.ws || !this.connected || !this.authenticated) {
667
- throw new EdgeBaseError(400, 'Not connected to room');
668
- }
669
- const requestId = generateRequestId();
670
- return new Promise((resolve, reject) => {
671
- const timeout = setTimeout(() => {
672
- this.pendingMediaRequests.delete(requestId);
673
- reject(new EdgeBaseError(408, `Media operation '${operation}' timed out`));
674
- }, this.options.sendTimeout);
675
- this.pendingMediaRequests.set(requestId, { resolve, reject, timeout });
676
- this.sendRaw({
677
- type: 'media',
678
- operation,
679
- kind,
680
- payload: payload ?? {},
681
- requestId,
682
- });
683
- });
684
- }
685
- async switchMediaDevices(payload) {
686
- const operations = [];
687
- if (payload.audioInputId) {
688
- operations.push(this.sendMedia('device', 'audio', { deviceId: payload.audioInputId }));
689
- }
690
- if (payload.videoInputId) {
691
- operations.push(this.sendMedia('device', 'video', { deviceId: payload.videoInputId }));
692
- }
693
- if (payload.screenInputId) {
694
- operations.push(this.sendMedia('device', 'screen', { deviceId: payload.screenInputId }));
695
- }
696
- await Promise.all(operations);
697
- }
698
673
  // ─── Private: Connection ───
699
674
  async establishConnection() {
700
675
  return new Promise((resolve, reject) => {
@@ -794,11 +769,11 @@ export class RoomClient {
794
769
  async authenticate() {
795
770
  const token = await this.tokenManager.getAccessToken((refreshToken) => refreshAccessToken(this.baseUrl, refreshToken));
796
771
  if (!token) {
797
- throw new EdgeBaseError(401, 'No access token available. Sign in first.');
772
+ throw new EdgeBaseError(401, 'Room authentication requires a signed-in session. Sign in before joining the room.');
798
773
  }
799
774
  return new Promise((resolve, reject) => {
800
775
  const timeout = setTimeout(() => {
801
- reject(new EdgeBaseError(401, 'Room auth timeout'));
776
+ reject(new EdgeBaseError(401, 'Room authentication timed out. Check the server auth response and room connectivity.'));
802
777
  }, 10000);
803
778
  const originalOnMessage = this.ws?.onmessage;
804
779
  if (this.ws) {
@@ -818,6 +793,7 @@ export class RoomClient {
818
793
  lastSharedVersion: this._sharedVersion,
819
794
  lastPlayerState: this._playerState,
820
795
  lastPlayerVersion: this._playerVersion,
796
+ lastMemberState: this.getReconnectMemberState(),
821
797
  });
822
798
  this.joined = true;
823
799
  resolve();
@@ -876,9 +852,6 @@ export class RoomClient {
876
852
  case 'members_sync':
877
853
  this.handleMembersSync(msg);
878
854
  break;
879
- case 'media_sync':
880
- this.handleMediaSync(msg);
881
- break;
882
855
  case 'member_join':
883
856
  this.handleMemberJoinFrame(msg);
884
857
  break;
@@ -891,24 +864,6 @@ export class RoomClient {
891
864
  case 'member_state_error':
892
865
  this.handleMemberStateError(msg);
893
866
  break;
894
- case 'media_track':
895
- this.handleMediaTrackFrame(msg);
896
- break;
897
- case 'media_track_removed':
898
- this.handleMediaTrackRemovedFrame(msg);
899
- break;
900
- case 'media_state':
901
- this.handleMediaStateFrame(msg);
902
- break;
903
- case 'media_device':
904
- this.handleMediaDeviceFrame(msg);
905
- break;
906
- case 'media_result':
907
- this.handleMediaResult(msg);
908
- break;
909
- case 'media_error':
910
- this.handleMediaError(msg);
911
- break;
912
867
  case 'admin_result':
913
868
  this.handleAdminResult(msg);
914
869
  break;
@@ -923,6 +878,7 @@ export class RoomClient {
923
878
  break;
924
879
  case 'pong':
925
880
  // Heartbeat response — no action needed
881
+ this.lastHeartbeatAckAt = Date.now();
926
882
  break;
927
883
  }
928
884
  }
@@ -1052,23 +1008,19 @@ export class RoomClient {
1052
1008
  handleMembersSync(msg) {
1053
1009
  const members = this.normalizeMembers(msg.members);
1054
1010
  this._members = members;
1055
- for (const member of members) {
1056
- this.syncMediaMemberInfo(member);
1057
- }
1058
- const snapshot = cloneValue(this._members);
1011
+ const snapshot = this._members.map((member) => cloneValue(member));
1059
1012
  for (const handler of this.memberSyncHandlers) {
1060
1013
  handler(snapshot);
1061
1014
  }
1062
- }
1063
- handleMediaSync(msg) {
1064
- this._mediaMembers = this.normalizeMediaMembers(msg.members);
1015
+ for (const handler of this.memberSnapshotHandlers) {
1016
+ handler(snapshot);
1017
+ }
1065
1018
  }
1066
1019
  handleMemberJoinFrame(msg) {
1067
1020
  const member = this.normalizeMember(msg.member);
1068
1021
  if (!member)
1069
1022
  return;
1070
1023
  this.upsertMember(member);
1071
- this.syncMediaMemberInfo(member);
1072
1024
  const snapshot = cloneValue(member);
1073
1025
  for (const handler of this.memberJoinHandlers) {
1074
1026
  handler(snapshot);
@@ -1079,7 +1031,6 @@ export class RoomClient {
1079
1031
  if (!member)
1080
1032
  return;
1081
1033
  this.removeMember(member.memberId);
1082
- this.removeMediaMember(member.memberId);
1083
1034
  const reason = this.normalizeLeaveReason(msg.reason);
1084
1035
  const snapshot = cloneValue(member);
1085
1036
  for (const handler of this.memberLeaveHandlers) {
@@ -1093,13 +1044,13 @@ export class RoomClient {
1093
1044
  return;
1094
1045
  member.state = state;
1095
1046
  this.upsertMember(member);
1096
- this.syncMediaMemberInfo(member);
1097
1047
  const requestId = msg.requestId;
1098
1048
  if (requestId) {
1099
1049
  const pending = this.pendingMemberStateRequests.get(requestId);
1100
1050
  if (pending) {
1101
1051
  clearTimeout(pending.timeout);
1102
1052
  this.pendingMemberStateRequests.delete(requestId);
1053
+ pending.onSuccess?.();
1103
1054
  pending.resolve();
1104
1055
  }
1105
1056
  }
@@ -1120,115 +1071,6 @@ export class RoomClient {
1120
1071
  this.pendingMemberStateRequests.delete(requestId);
1121
1072
  pending.reject(new EdgeBaseError(400, msg.message || 'Member state update failed'));
1122
1073
  }
1123
- handleMediaTrackFrame(msg) {
1124
- const member = this.normalizeMember(msg.member);
1125
- const track = this.normalizeMediaTrack(msg.track);
1126
- if (!member || !track)
1127
- return;
1128
- const mediaMember = this.ensureMediaMember(member);
1129
- this.upsertMediaTrack(mediaMember, track);
1130
- this.mergeMediaState(mediaMember, track.kind, {
1131
- published: true,
1132
- muted: track.muted,
1133
- trackId: track.trackId,
1134
- deviceId: track.deviceId,
1135
- publishedAt: track.publishedAt,
1136
- adminDisabled: track.adminDisabled,
1137
- providerSessionId: track.providerSessionId,
1138
- });
1139
- const memberSnapshot = cloneValue(mediaMember.member);
1140
- const trackSnapshot = cloneValue(track);
1141
- for (const handler of this.mediaTrackHandlers) {
1142
- handler(trackSnapshot, memberSnapshot);
1143
- }
1144
- const stateSnapshot = cloneValue(mediaMember.state);
1145
- for (const handler of this.mediaStateHandlers) {
1146
- handler(memberSnapshot, stateSnapshot);
1147
- }
1148
- }
1149
- handleMediaTrackRemovedFrame(msg) {
1150
- const member = this.normalizeMember(msg.member);
1151
- const track = this.normalizeMediaTrack(msg.track);
1152
- if (!member || !track)
1153
- return;
1154
- const mediaMember = this.ensureMediaMember(member);
1155
- this.removeMediaTrack(mediaMember, track);
1156
- mediaMember.state = {
1157
- ...mediaMember.state,
1158
- [track.kind]: {
1159
- published: false,
1160
- muted: false,
1161
- adminDisabled: false,
1162
- providerSessionId: undefined,
1163
- },
1164
- };
1165
- const memberSnapshot = cloneValue(mediaMember.member);
1166
- const trackSnapshot = cloneValue(track);
1167
- for (const handler of this.mediaTrackRemovedHandlers) {
1168
- handler(trackSnapshot, memberSnapshot);
1169
- }
1170
- const stateSnapshot = cloneValue(mediaMember.state);
1171
- for (const handler of this.mediaStateHandlers) {
1172
- handler(memberSnapshot, stateSnapshot);
1173
- }
1174
- }
1175
- handleMediaStateFrame(msg) {
1176
- const member = this.normalizeMember(msg.member);
1177
- if (!member)
1178
- return;
1179
- const mediaMember = this.ensureMediaMember(member);
1180
- mediaMember.state = this.normalizeMediaState(msg.state);
1181
- const memberSnapshot = cloneValue(mediaMember.member);
1182
- const stateSnapshot = cloneValue(mediaMember.state);
1183
- for (const handler of this.mediaStateHandlers) {
1184
- handler(memberSnapshot, stateSnapshot);
1185
- }
1186
- }
1187
- handleMediaDeviceFrame(msg) {
1188
- const member = this.normalizeMember(msg.member);
1189
- const kind = this.normalizeMediaKind(msg.kind);
1190
- const deviceId = typeof msg.deviceId === 'string' ? msg.deviceId : '';
1191
- if (!member || !kind || !deviceId)
1192
- return;
1193
- const mediaMember = this.ensureMediaMember(member);
1194
- this.mergeMediaState(mediaMember, kind, { deviceId });
1195
- for (const track of mediaMember.tracks) {
1196
- if (track.kind === kind) {
1197
- track.deviceId = deviceId;
1198
- }
1199
- }
1200
- const memberSnapshot = cloneValue(mediaMember.member);
1201
- const change = { kind, deviceId };
1202
- for (const handler of this.mediaDeviceHandlers) {
1203
- handler(memberSnapshot, change);
1204
- }
1205
- const stateSnapshot = cloneValue(mediaMember.state);
1206
- for (const handler of this.mediaStateHandlers) {
1207
- handler(memberSnapshot, stateSnapshot);
1208
- }
1209
- }
1210
- handleMediaResult(msg) {
1211
- const requestId = msg.requestId;
1212
- if (!requestId)
1213
- return;
1214
- const pending = this.pendingMediaRequests.get(requestId);
1215
- if (!pending)
1216
- return;
1217
- clearTimeout(pending.timeout);
1218
- this.pendingMediaRequests.delete(requestId);
1219
- pending.resolve();
1220
- }
1221
- handleMediaError(msg) {
1222
- const requestId = msg.requestId;
1223
- if (!requestId)
1224
- return;
1225
- const pending = this.pendingMediaRequests.get(requestId);
1226
- if (!pending)
1227
- return;
1228
- clearTimeout(pending.timeout);
1229
- this.pendingMediaRequests.delete(requestId);
1230
- pending.reject(new EdgeBaseError(400, msg.message || 'Media operation failed'));
1231
- }
1232
1074
  handleAdminResult(msg) {
1233
1075
  const requestId = msg.requestId;
1234
1076
  if (!requestId)
@@ -1305,7 +1147,6 @@ export class RoomClient {
1305
1147
  this.connected = false;
1306
1148
  this.authenticated = false;
1307
1149
  this.joined = false;
1308
- this._mediaMembers = [];
1309
1150
  this.currentUserId = null;
1310
1151
  this.currentConnectionId = null;
1311
1152
  try {
@@ -1319,7 +1160,6 @@ export class RoomClient {
1319
1160
  this.connected = false;
1320
1161
  this.authenticated = false;
1321
1162
  this.joined = false;
1322
- this._mediaMembers = [];
1323
1163
  }
1324
1164
  handleAuthenticationFailure(error) {
1325
1165
  const authError = error instanceof EdgeBaseError
@@ -1355,8 +1195,7 @@ export class RoomClient {
1355
1195
  return this.pendingRequests.size > 0
1356
1196
  || this.pendingSignalRequests.size > 0
1357
1197
  || this.pendingAdminRequests.size > 0
1358
- || this.pendingMemberStateRequests.size > 0
1359
- || this.pendingMediaRequests.size > 0;
1198
+ || this.pendingMemberStateRequests.size > 0;
1360
1199
  }
1361
1200
  handleRoomAuthStateLoss(message) {
1362
1201
  const detail = message?.trim();
@@ -1382,14 +1221,6 @@ export class RoomClient {
1382
1221
  .map((member) => this.normalizeMember(member))
1383
1222
  .filter((member) => !!member);
1384
1223
  }
1385
- normalizeMediaMembers(value) {
1386
- if (!Array.isArray(value)) {
1387
- return [];
1388
- }
1389
- return value
1390
- .map((member) => this.normalizeMediaMember(member))
1391
- .filter((member) => !!member);
1392
- }
1393
1224
  normalizeMember(value) {
1394
1225
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
1395
1226
  return null;
@@ -1413,84 +1244,6 @@ export class RoomClient {
1413
1244
  }
1414
1245
  return cloneRecord(value);
1415
1246
  }
1416
- normalizeMediaMember(value) {
1417
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
1418
- return null;
1419
- }
1420
- const entry = value;
1421
- const member = this.normalizeMember(entry.member);
1422
- if (!member) {
1423
- return null;
1424
- }
1425
- return {
1426
- member,
1427
- state: this.normalizeMediaState(entry.state),
1428
- tracks: this.normalizeMediaTracks(entry.tracks),
1429
- };
1430
- }
1431
- normalizeMediaState(value) {
1432
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
1433
- return {};
1434
- }
1435
- const state = value;
1436
- return {
1437
- audio: this.normalizeMediaKindState(state.audio),
1438
- video: this.normalizeMediaKindState(state.video),
1439
- screen: this.normalizeMediaKindState(state.screen),
1440
- };
1441
- }
1442
- normalizeMediaKindState(value) {
1443
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
1444
- return undefined;
1445
- }
1446
- const state = value;
1447
- return {
1448
- published: state.published === true,
1449
- muted: state.muted === true,
1450
- trackId: typeof state.trackId === 'string' ? state.trackId : undefined,
1451
- deviceId: typeof state.deviceId === 'string' ? state.deviceId : undefined,
1452
- publishedAt: typeof state.publishedAt === 'number' ? state.publishedAt : undefined,
1453
- adminDisabled: state.adminDisabled === true,
1454
- providerSessionId: typeof state.providerSessionId === 'string' ? state.providerSessionId : undefined,
1455
- };
1456
- }
1457
- normalizeMediaTracks(value) {
1458
- if (!Array.isArray(value)) {
1459
- return [];
1460
- }
1461
- return value
1462
- .map((track) => this.normalizeMediaTrack(track))
1463
- .filter((track) => !!track);
1464
- }
1465
- normalizeMediaTrack(value) {
1466
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
1467
- return null;
1468
- }
1469
- const track = value;
1470
- const kind = this.normalizeMediaKind(track.kind);
1471
- if (!kind) {
1472
- return null;
1473
- }
1474
- return {
1475
- kind,
1476
- trackId: typeof track.trackId === 'string' ? track.trackId : undefined,
1477
- deviceId: typeof track.deviceId === 'string' ? track.deviceId : undefined,
1478
- muted: track.muted === true,
1479
- publishedAt: typeof track.publishedAt === 'number' ? track.publishedAt : undefined,
1480
- adminDisabled: track.adminDisabled === true,
1481
- providerSessionId: typeof track.providerSessionId === 'string' ? track.providerSessionId : undefined,
1482
- };
1483
- }
1484
- normalizeMediaKind(value) {
1485
- switch (value) {
1486
- case 'audio':
1487
- case 'video':
1488
- case 'screen':
1489
- return value;
1490
- default:
1491
- return null;
1492
- }
1493
- }
1494
1247
  normalizeSignalMeta(value) {
1495
1248
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
1496
1249
  return {};
@@ -1516,6 +1269,20 @@ export class RoomClient {
1516
1269
  return 'leave';
1517
1270
  }
1518
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
+ }
1519
1286
  upsertMember(member) {
1520
1287
  const index = this._members.findIndex((entry) => entry.memberId === member.memberId);
1521
1288
  if (index >= 0) {
@@ -1527,64 +1294,6 @@ export class RoomClient {
1527
1294
  removeMember(memberId) {
1528
1295
  this._members = this._members.filter((member) => member.memberId !== memberId);
1529
1296
  }
1530
- syncMediaMemberInfo(member) {
1531
- const mediaMember = this._mediaMembers.find((entry) => entry.member.memberId === member.memberId);
1532
- if (!mediaMember) {
1533
- return;
1534
- }
1535
- mediaMember.member = cloneValue(member);
1536
- }
1537
- ensureMediaMember(member) {
1538
- const existing = this._mediaMembers.find((entry) => entry.member.memberId === member.memberId);
1539
- if (existing) {
1540
- existing.member = cloneValue(member);
1541
- return existing;
1542
- }
1543
- const created = {
1544
- member: cloneValue(member),
1545
- state: {},
1546
- tracks: [],
1547
- };
1548
- this._mediaMembers.push(created);
1549
- return created;
1550
- }
1551
- removeMediaMember(memberId) {
1552
- this._mediaMembers = this._mediaMembers.filter((member) => member.member.memberId !== memberId);
1553
- }
1554
- upsertMediaTrack(mediaMember, track) {
1555
- const index = mediaMember.tracks.findIndex((entry) => entry.kind === track.kind &&
1556
- entry.trackId === track.trackId);
1557
- if (index >= 0) {
1558
- mediaMember.tracks[index] = cloneValue(track);
1559
- return;
1560
- }
1561
- mediaMember.tracks = mediaMember.tracks
1562
- .filter((entry) => !(entry.kind === track.kind && !track.trackId))
1563
- .concat(cloneValue(track));
1564
- }
1565
- removeMediaTrack(mediaMember, track) {
1566
- mediaMember.tracks = mediaMember.tracks.filter((entry) => {
1567
- if (track.trackId) {
1568
- return !(entry.kind === track.kind && entry.trackId === track.trackId);
1569
- }
1570
- return entry.kind !== track.kind;
1571
- });
1572
- }
1573
- mergeMediaState(mediaMember, kind, partial) {
1574
- const next = {
1575
- published: partial.published ?? mediaMember.state[kind]?.published ?? false,
1576
- muted: partial.muted ?? mediaMember.state[kind]?.muted ?? false,
1577
- trackId: partial.trackId ?? mediaMember.state[kind]?.trackId,
1578
- deviceId: partial.deviceId ?? mediaMember.state[kind]?.deviceId,
1579
- publishedAt: partial.publishedAt ?? mediaMember.state[kind]?.publishedAt,
1580
- adminDisabled: partial.adminDisabled ?? mediaMember.state[kind]?.adminDisabled,
1581
- providerSessionId: partial.providerSessionId ?? mediaMember.state[kind]?.providerSessionId,
1582
- };
1583
- mediaMember.state = {
1584
- ...mediaMember.state,
1585
- [kind]: next,
1586
- };
1587
- }
1588
1297
  /** Reject all 5 pending request maps at once. */
1589
1298
  rejectAllPendingRequests(error) {
1590
1299
  for (const [, pending] of this.pendingRequests) {
@@ -1595,7 +1304,6 @@ export class RoomClient {
1595
1304
  this.rejectPendingVoidRequests(this.pendingSignalRequests, error);
1596
1305
  this.rejectPendingVoidRequests(this.pendingAdminRequests, error);
1597
1306
  this.rejectPendingVoidRequests(this.pendingMemberStateRequests, error);
1598
- this.rejectPendingVoidRequests(this.pendingMediaRequests, error);
1599
1307
  }
1600
1308
  rejectPendingVoidRequests(pendingRequests, error) {
1601
1309
  for (const [, pending] of pendingRequests) {
@@ -1604,11 +1312,59 @@ export class RoomClient {
1604
1312
  }
1605
1313
  pendingRequests.clear();
1606
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
+ }
1607
1357
  setConnectionState(next) {
1608
1358
  if (this.connectionState === next) {
1609
1359
  return;
1610
1360
  }
1611
1361
  this.connectionState = next;
1362
+ if (this.shouldScheduleDisconnectReset(next)) {
1363
+ this.scheduleDisconnectReset(next);
1364
+ }
1365
+ else {
1366
+ this.clearDisconnectResetTimer();
1367
+ }
1612
1368
  for (const handler of this.connectionStateHandlers) {
1613
1369
  handler(next);
1614
1370
  }
@@ -1675,11 +1431,21 @@ export class RoomClient {
1675
1431
  }
1676
1432
  startHeartbeat() {
1677
1433
  this.stopHeartbeat();
1434
+ this.lastHeartbeatAckAt = Date.now();
1678
1435
  this.heartbeatTimer = setInterval(() => {
1679
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
+ }
1680
1446
  this.ws.send(JSON.stringify({ type: 'ping' }));
1681
1447
  }
1682
- }, 30000);
1448
+ }, this.options.heartbeatIntervalMs);
1683
1449
  }
1684
1450
  stopHeartbeat() {
1685
1451
  if (this.heartbeatTimer) {
@@ -1687,5 +1453,11 @@ export class RoomClient {
1687
1453
  this.heartbeatTimer = null;
1688
1454
  }
1689
1455
  }
1456
+ getReconnectMemberState() {
1457
+ if (!this.lastLocalMemberState) {
1458
+ return undefined;
1459
+ }
1460
+ return cloneRecord(this.lastLocalMemberState);
1461
+ }
1690
1462
  }
1691
1463
  //# sourceMappingURL=room.js.map