@edge-base/web 0.1.5 → 0.2.1

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,6 +1,8 @@
1
- import { EdgeBaseError } from '@edge-base/core';
1
+ import { EdgeBaseError, createSubscription } from '@edge-base/core';
2
2
  import { refreshAccessToken } from './auth-refresh.js';
3
- import { RoomRealtimeMediaTransport, } from './room-realtime-media.js';
3
+ import { RoomCloudflareMediaTransport, } from './room-cloudflare-media.js';
4
+ import { RoomP2PMediaTransport, } from './room-p2p-media.js';
5
+ export { createSubscription };
4
6
  // ─── Helpers ───
5
7
  const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
6
8
  function deepSet(obj, path, value) {
@@ -89,6 +91,34 @@ export class RoomClient {
89
91
  waitingForAuth = false;
90
92
  joinRequested = false;
91
93
  unsubAuthState = null;
94
+ browserNetworkListenersAttached = false;
95
+ browserOfflineHandler = () => {
96
+ if (this.intentionallyLeft || !this.joinRequested) {
97
+ return;
98
+ }
99
+ if (this.connectionState === 'connected') {
100
+ this.setConnectionState('reconnecting');
101
+ }
102
+ if (isSocketOpenOrConnecting(this.ws)) {
103
+ try {
104
+ this.ws?.close();
105
+ }
106
+ catch {
107
+ // Socket may already be closing.
108
+ }
109
+ }
110
+ };
111
+ browserOnlineHandler = () => {
112
+ if (this.intentionallyLeft
113
+ || !this.joinRequested
114
+ || this.connectingPromise
115
+ || isSocketOpenOrConnecting(this.ws)) {
116
+ return;
117
+ }
118
+ if (this.connectionState === 'reconnecting' || this.connectionState === 'disconnected') {
119
+ this.ensureConnection().catch(() => { });
120
+ }
121
+ };
92
122
  // ─── Pending send() requests (requestId → { resolve, reject, timeout }) ───
93
123
  pendingRequests = new Map();
94
124
  pendingSignalRequests = new Map();
@@ -132,6 +162,21 @@ export class RoomClient {
132
162
  };
133
163
  members = {
134
164
  list: () => cloneValue(this._members),
165
+ current: () => {
166
+ const connectionId = this.currentConnectionId;
167
+ if (connectionId) {
168
+ const byConnection = this._members.find((member) => member.connectionId === connectionId);
169
+ if (byConnection) {
170
+ return cloneValue(byConnection);
171
+ }
172
+ }
173
+ const userId = this.currentUserId;
174
+ if (!userId) {
175
+ return null;
176
+ }
177
+ const member = this._members.find((entry) => entry.userId === userId) ?? null;
178
+ return member ? cloneValue(member) : null;
179
+ },
135
180
  onSync: (handler) => this.onMembersSync(handler),
136
181
  onJoin: (handler) => this.onMemberJoin(handler),
137
182
  onLeave: (handler) => this.onMemberLeave(handler),
@@ -166,13 +211,20 @@ export class RoomClient {
166
211
  devices: {
167
212
  switch: (payload) => this.switchMediaDevices(payload),
168
213
  },
169
- realtime: {
170
- createSession: (payload) => this.requestRealtimeMedia('session', 'POST', payload),
171
- getIceServers: (payload) => this.requestRealtimeMedia('turn', 'POST', payload),
172
- addTracks: (payload) => this.requestRealtimeMedia('tracks/new', 'POST', payload),
173
- renegotiate: (payload) => this.requestRealtimeMedia('renegotiate', 'PUT', payload),
174
- closeTracks: (payload) => this.requestRealtimeMedia('tracks/close', 'PUT', payload),
175
- transport: (options) => new RoomRealtimeMediaTransport(this, options),
214
+ cloudflareRealtimeKit: {
215
+ createSession: (payload) => this.requestCloudflareRealtimeKitMedia('session', 'POST', payload),
216
+ },
217
+ transport: (options) => {
218
+ // Infer provider from options: if cloudflareRealtimeKit config is present, use it;
219
+ // otherwise default to p2p for zero-config local development.
220
+ const hasCloudflareConfig = options && 'cloudflareRealtimeKit' in options && options.cloudflareRealtimeKit != null;
221
+ const provider = options?.provider ?? (hasCloudflareConfig ? 'cloudflare_realtimekit' : 'p2p');
222
+ if (provider === 'p2p') {
223
+ const p2pOptions = options?.p2p;
224
+ return new RoomP2PMediaTransport(this, p2pOptions);
225
+ }
226
+ const cloudflareOptions = options?.cloudflareRealtimeKit;
227
+ return new RoomCloudflareMediaTransport(this, cloudflareOptions);
176
228
  },
177
229
  onTrack: (handler) => this.onMediaTrack(handler),
178
230
  onTrackRemoved: (handler) => this.onMediaTrackRemoved(handler),
@@ -195,10 +247,12 @@ export class RoomClient {
195
247
  maxReconnectAttempts: options?.maxReconnectAttempts ?? 10,
196
248
  reconnectBaseDelay: options?.reconnectBaseDelay ?? 1000,
197
249
  sendTimeout: options?.sendTimeout ?? 10000,
250
+ connectionTimeout: options?.connectionTimeout ?? 15000,
198
251
  };
199
252
  this.unsubAuthState = this.tokenManager.onAuthStateChange((user) => {
200
253
  this.handleAuthStateChange(user);
201
254
  });
255
+ this.attachBrowserNetworkListeners();
202
256
  }
203
257
  // ─── State Accessors ───
204
258
  /** Get current shared state (read-only snapshot) */
@@ -229,12 +283,15 @@ export class RoomClient {
229
283
  }
230
284
  return res.json();
231
285
  }
232
- async requestRealtimeMedia(path, method, payload) {
286
+ async requestCloudflareRealtimeKitMedia(path, method, payload) {
287
+ return this.requestRoomMedia('cloudflare_realtimekit', path, method, payload);
288
+ }
289
+ async requestRoomMedia(providerPath, path, method, payload) {
233
290
  const token = await this.tokenManager.getAccessToken((refreshToken) => refreshAccessToken(this.baseUrl, refreshToken));
234
291
  if (!token) {
235
292
  throw new EdgeBaseError(401, 'Authentication required');
236
293
  }
237
- const url = new URL(`${this.baseUrl.replace(/\/$/, '')}/api/room/media/realtime/${path}`);
294
+ const url = new URL(`${this.baseUrl.replace(/\/$/, '')}/api/room/media/${providerPath}/${path}`);
238
295
  url.searchParams.set('namespace', this.namespace);
239
296
  url.searchParams.set('id', this.roomId);
240
297
  const response = await fetch(url.toString(), {
@@ -247,7 +304,7 @@ export class RoomClient {
247
304
  });
248
305
  const data = (await response.json().catch(() => ({})));
249
306
  if (!response.ok) {
250
- throw new EdgeBaseError(response.status, (typeof data.message === 'string' && data.message) || `Realtime media request failed: ${response.statusText}`);
307
+ throw new EdgeBaseError(response.status, (typeof data.message === 'string' && data.message) || `Room media request failed: ${response.statusText}`);
251
308
  }
252
309
  return data;
253
310
  }
@@ -269,15 +326,7 @@ export class RoomClient {
269
326
  this.waitingForAuth = false;
270
327
  this.stopHeartbeat();
271
328
  // Reject all pending send() requests
272
- for (const [reqId, pending] of this.pendingRequests) {
273
- clearTimeout(pending.timeout);
274
- pending.reject(new EdgeBaseError(499, 'Room left'));
275
- }
276
- this.pendingRequests.clear();
277
- this.rejectPendingVoidRequests(this.pendingSignalRequests, new EdgeBaseError(499, 'Room left'));
278
- this.rejectPendingVoidRequests(this.pendingAdminRequests, new EdgeBaseError(499, 'Room left'));
279
- this.rejectPendingVoidRequests(this.pendingMemberStateRequests, new EdgeBaseError(499, 'Room left'));
280
- this.rejectPendingVoidRequests(this.pendingMediaRequests, new EdgeBaseError(499, 'Room left'));
329
+ this.rejectAllPendingRequests(new EdgeBaseError(499, 'Room left'));
281
330
  if (this.ws) {
282
331
  const socket = this.ws;
283
332
  this.sendRaw({ type: 'leave' });
@@ -299,6 +348,36 @@ export class RoomClient {
299
348
  this.reconnectInfo = null;
300
349
  this.setConnectionState('disconnected');
301
350
  }
351
+ /**
352
+ * Destroy the RoomClient and release all resources.
353
+ * Calls leave() if still connected, unsubscribes from auth state changes,
354
+ * and clears all handler arrays to allow garbage collection.
355
+ */
356
+ destroy() {
357
+ this.leave();
358
+ this.detachBrowserNetworkListeners();
359
+ this.unsubAuthState?.();
360
+ this.unsubAuthState = null;
361
+ // Clear all handler arrays to break references
362
+ this.sharedStateHandlers.length = 0;
363
+ this.playerStateHandlers.length = 0;
364
+ this.messageHandlers.clear();
365
+ this.allMessageHandlers.length = 0;
366
+ this.errorHandlers.length = 0;
367
+ this.kickedHandlers.length = 0;
368
+ this.memberSyncHandlers.length = 0;
369
+ this.memberJoinHandlers.length = 0;
370
+ this.memberLeaveHandlers.length = 0;
371
+ this.memberStateHandlers.length = 0;
372
+ this.signalHandlers.clear();
373
+ this.anySignalHandlers.length = 0;
374
+ this.mediaTrackHandlers.length = 0;
375
+ this.mediaTrackRemovedHandlers.length = 0;
376
+ this.mediaStateHandlers.length = 0;
377
+ this.mediaDeviceHandlers.length = 0;
378
+ this.reconnectHandlers.length = 0;
379
+ this.connectionStateHandlers.length = 0;
380
+ }
302
381
  // ─── Actions ───
303
382
  /**
304
383
  * Send an action to the server.
@@ -331,33 +410,29 @@ export class RoomClient {
331
410
  * Subscribe to shared state changes.
332
411
  * Called on full sync and on each shared_delta.
333
412
  *
334
- * @returns Subscription with unsubscribe()
413
+ * @returns Subscription (callable & .unsubscribe())
335
414
  */
336
415
  onSharedState(handler) {
337
416
  this.sharedStateHandlers.push(handler);
338
- return {
339
- unsubscribe: () => {
340
- const idx = this.sharedStateHandlers.indexOf(handler);
341
- if (idx >= 0)
342
- this.sharedStateHandlers.splice(idx, 1);
343
- },
344
- };
417
+ return createSubscription(() => {
418
+ const idx = this.sharedStateHandlers.indexOf(handler);
419
+ if (idx >= 0)
420
+ this.sharedStateHandlers.splice(idx, 1);
421
+ });
345
422
  }
346
423
  /**
347
424
  * Subscribe to player state changes.
348
425
  * Called on full sync and on each player_delta.
349
426
  *
350
- * @returns Subscription with unsubscribe()
427
+ * @returns Subscription (callable & .unsubscribe())
351
428
  */
352
429
  onPlayerState(handler) {
353
430
  this.playerStateHandlers.push(handler);
354
- return {
355
- unsubscribe: () => {
356
- const idx = this.playerStateHandlers.indexOf(handler);
357
- if (idx >= 0)
358
- this.playerStateHandlers.splice(idx, 1);
359
- },
360
- };
431
+ return createSubscription(() => {
432
+ const idx = this.playerStateHandlers.indexOf(handler);
433
+ if (idx >= 0)
434
+ this.playerStateHandlers.splice(idx, 1);
435
+ });
361
436
  }
362
437
  /**
363
438
  * Subscribe to messages of a specific type sent by room.sendMessage().
@@ -365,186 +440,154 @@ export class RoomClient {
365
440
  * @example
366
441
  * room.onMessage('game_over', (data) => { console.log(data.winner); });
367
442
  *
368
- * @returns Subscription with unsubscribe()
443
+ * @returns Subscription (callable & .unsubscribe())
369
444
  */
370
445
  onMessage(messageType, handler) {
371
446
  if (!this.messageHandlers.has(messageType)) {
372
447
  this.messageHandlers.set(messageType, []);
373
448
  }
374
449
  this.messageHandlers.get(messageType).push(handler);
375
- return {
376
- unsubscribe: () => {
377
- const handlers = this.messageHandlers.get(messageType);
378
- if (handlers) {
379
- const idx = handlers.indexOf(handler);
380
- if (idx >= 0)
381
- handlers.splice(idx, 1);
382
- }
383
- },
384
- };
450
+ return createSubscription(() => {
451
+ const handlers = this.messageHandlers.get(messageType);
452
+ if (handlers) {
453
+ const idx = handlers.indexOf(handler);
454
+ if (idx >= 0)
455
+ handlers.splice(idx, 1);
456
+ }
457
+ });
385
458
  }
386
459
  /**
387
460
  * Subscribe to ALL messages regardless of type.
388
461
  *
389
- * @returns Subscription with unsubscribe()
462
+ * @returns Subscription (callable & .unsubscribe())
390
463
  */
391
464
  onAnyMessage(handler) {
392
465
  this.allMessageHandlers.push(handler);
393
- return {
394
- unsubscribe: () => {
395
- const idx = this.allMessageHandlers.indexOf(handler);
396
- if (idx >= 0)
397
- this.allMessageHandlers.splice(idx, 1);
398
- },
399
- };
466
+ return createSubscription(() => {
467
+ const idx = this.allMessageHandlers.indexOf(handler);
468
+ if (idx >= 0)
469
+ this.allMessageHandlers.splice(idx, 1);
470
+ });
400
471
  }
401
472
  /** Subscribe to errors */
402
473
  onError(handler) {
403
474
  this.errorHandlers.push(handler);
404
- return {
405
- unsubscribe: () => {
406
- const idx = this.errorHandlers.indexOf(handler);
407
- if (idx >= 0)
408
- this.errorHandlers.splice(idx, 1);
409
- },
410
- };
475
+ return createSubscription(() => {
476
+ const idx = this.errorHandlers.indexOf(handler);
477
+ if (idx >= 0)
478
+ this.errorHandlers.splice(idx, 1);
479
+ });
411
480
  }
412
481
  /** Subscribe to kick events */
413
482
  onKicked(handler) {
414
483
  this.kickedHandlers.push(handler);
415
- return {
416
- unsubscribe: () => {
417
- const idx = this.kickedHandlers.indexOf(handler);
418
- if (idx >= 0)
419
- this.kickedHandlers.splice(idx, 1);
420
- },
421
- };
484
+ return createSubscription(() => {
485
+ const idx = this.kickedHandlers.indexOf(handler);
486
+ if (idx >= 0)
487
+ this.kickedHandlers.splice(idx, 1);
488
+ });
422
489
  }
423
490
  onSignal(event, handler) {
424
491
  if (!this.signalHandlers.has(event)) {
425
492
  this.signalHandlers.set(event, []);
426
493
  }
427
494
  this.signalHandlers.get(event).push(handler);
428
- return {
429
- unsubscribe: () => {
430
- const handlers = this.signalHandlers.get(event);
431
- if (!handlers)
432
- return;
433
- const index = handlers.indexOf(handler);
434
- if (index >= 0)
435
- handlers.splice(index, 1);
436
- },
437
- };
495
+ return createSubscription(() => {
496
+ const handlers = this.signalHandlers.get(event);
497
+ if (!handlers)
498
+ return;
499
+ const index = handlers.indexOf(handler);
500
+ if (index >= 0)
501
+ handlers.splice(index, 1);
502
+ });
438
503
  }
439
504
  onAnySignal(handler) {
440
505
  this.anySignalHandlers.push(handler);
441
- return {
442
- unsubscribe: () => {
443
- const index = this.anySignalHandlers.indexOf(handler);
444
- if (index >= 0)
445
- this.anySignalHandlers.splice(index, 1);
446
- },
447
- };
506
+ return createSubscription(() => {
507
+ const index = this.anySignalHandlers.indexOf(handler);
508
+ if (index >= 0)
509
+ this.anySignalHandlers.splice(index, 1);
510
+ });
448
511
  }
449
512
  onMembersSync(handler) {
450
513
  this.memberSyncHandlers.push(handler);
451
- return {
452
- unsubscribe: () => {
453
- const index = this.memberSyncHandlers.indexOf(handler);
454
- if (index >= 0)
455
- this.memberSyncHandlers.splice(index, 1);
456
- },
457
- };
514
+ return createSubscription(() => {
515
+ const index = this.memberSyncHandlers.indexOf(handler);
516
+ if (index >= 0)
517
+ this.memberSyncHandlers.splice(index, 1);
518
+ });
458
519
  }
459
520
  onMemberJoin(handler) {
460
521
  this.memberJoinHandlers.push(handler);
461
- return {
462
- unsubscribe: () => {
463
- const index = this.memberJoinHandlers.indexOf(handler);
464
- if (index >= 0)
465
- this.memberJoinHandlers.splice(index, 1);
466
- },
467
- };
522
+ return createSubscription(() => {
523
+ const index = this.memberJoinHandlers.indexOf(handler);
524
+ if (index >= 0)
525
+ this.memberJoinHandlers.splice(index, 1);
526
+ });
468
527
  }
469
528
  onMemberLeave(handler) {
470
529
  this.memberLeaveHandlers.push(handler);
471
- return {
472
- unsubscribe: () => {
473
- const index = this.memberLeaveHandlers.indexOf(handler);
474
- if (index >= 0)
475
- this.memberLeaveHandlers.splice(index, 1);
476
- },
477
- };
530
+ return createSubscription(() => {
531
+ const index = this.memberLeaveHandlers.indexOf(handler);
532
+ if (index >= 0)
533
+ this.memberLeaveHandlers.splice(index, 1);
534
+ });
478
535
  }
479
536
  onMemberStateChange(handler) {
480
537
  this.memberStateHandlers.push(handler);
481
- return {
482
- unsubscribe: () => {
483
- const index = this.memberStateHandlers.indexOf(handler);
484
- if (index >= 0)
485
- this.memberStateHandlers.splice(index, 1);
486
- },
487
- };
538
+ return createSubscription(() => {
539
+ const index = this.memberStateHandlers.indexOf(handler);
540
+ if (index >= 0)
541
+ this.memberStateHandlers.splice(index, 1);
542
+ });
488
543
  }
489
544
  onReconnect(handler) {
490
545
  this.reconnectHandlers.push(handler);
491
- return {
492
- unsubscribe: () => {
493
- const index = this.reconnectHandlers.indexOf(handler);
494
- if (index >= 0)
495
- this.reconnectHandlers.splice(index, 1);
496
- },
497
- };
546
+ return createSubscription(() => {
547
+ const index = this.reconnectHandlers.indexOf(handler);
548
+ if (index >= 0)
549
+ this.reconnectHandlers.splice(index, 1);
550
+ });
498
551
  }
499
552
  onConnectionStateChange(handler) {
500
553
  this.connectionStateHandlers.push(handler);
501
- return {
502
- unsubscribe: () => {
503
- const index = this.connectionStateHandlers.indexOf(handler);
504
- if (index >= 0)
505
- this.connectionStateHandlers.splice(index, 1);
506
- },
507
- };
554
+ return createSubscription(() => {
555
+ const index = this.connectionStateHandlers.indexOf(handler);
556
+ if (index >= 0)
557
+ this.connectionStateHandlers.splice(index, 1);
558
+ });
508
559
  }
509
560
  onMediaTrack(handler) {
510
561
  this.mediaTrackHandlers.push(handler);
511
- return {
512
- unsubscribe: () => {
513
- const index = this.mediaTrackHandlers.indexOf(handler);
514
- if (index >= 0)
515
- this.mediaTrackHandlers.splice(index, 1);
516
- },
517
- };
562
+ return createSubscription(() => {
563
+ const index = this.mediaTrackHandlers.indexOf(handler);
564
+ if (index >= 0)
565
+ this.mediaTrackHandlers.splice(index, 1);
566
+ });
518
567
  }
519
568
  onMediaTrackRemoved(handler) {
520
569
  this.mediaTrackRemovedHandlers.push(handler);
521
- return {
522
- unsubscribe: () => {
523
- const index = this.mediaTrackRemovedHandlers.indexOf(handler);
524
- if (index >= 0)
525
- this.mediaTrackRemovedHandlers.splice(index, 1);
526
- },
527
- };
570
+ return createSubscription(() => {
571
+ const index = this.mediaTrackRemovedHandlers.indexOf(handler);
572
+ if (index >= 0)
573
+ this.mediaTrackRemovedHandlers.splice(index, 1);
574
+ });
528
575
  }
529
576
  onMediaStateChange(handler) {
530
577
  this.mediaStateHandlers.push(handler);
531
- return {
532
- unsubscribe: () => {
533
- const index = this.mediaStateHandlers.indexOf(handler);
534
- if (index >= 0)
535
- this.mediaStateHandlers.splice(index, 1);
536
- },
537
- };
578
+ return createSubscription(() => {
579
+ const index = this.mediaStateHandlers.indexOf(handler);
580
+ if (index >= 0)
581
+ this.mediaStateHandlers.splice(index, 1);
582
+ });
538
583
  }
539
584
  onMediaDeviceChange(handler) {
540
585
  this.mediaDeviceHandlers.push(handler);
541
- return {
542
- unsubscribe: () => {
543
- const index = this.mediaDeviceHandlers.indexOf(handler);
544
- if (index >= 0)
545
- this.mediaDeviceHandlers.splice(index, 1);
546
- },
547
- };
586
+ return createSubscription(() => {
587
+ const index = this.mediaDeviceHandlers.indexOf(handler);
588
+ if (index >= 0)
589
+ this.mediaDeviceHandlers.splice(index, 1);
590
+ });
548
591
  }
549
592
  async sendSignal(event, payload, options) {
550
593
  if (!this.ws || !this.connected || !this.authenticated) {
@@ -651,29 +694,53 @@ export class RoomClient {
651
694
  const wsUrl = this.buildWsUrl();
652
695
  const ws = new WebSocket(wsUrl);
653
696
  this.ws = ws;
697
+ let settled = false;
698
+ const connectionTimer = setTimeout(() => {
699
+ if (!settled) {
700
+ settled = true;
701
+ try {
702
+ ws.close();
703
+ }
704
+ catch (_) { /* ignore */ }
705
+ this.ws = null;
706
+ reject(new EdgeBaseError(408, `Room WebSocket connection timed out after ${this.options.connectionTimeout}ms. Is the server running?`));
707
+ }
708
+ }, this.options.connectionTimeout);
654
709
  ws.onopen = () => {
710
+ clearTimeout(connectionTimer);
655
711
  this.connected = true;
656
712
  this.reconnectAttempts = 0;
657
713
  this.startHeartbeat();
658
714
  this.authenticate()
659
715
  .then(() => {
660
- this.waitingForAuth = false;
661
- resolve();
716
+ if (!settled) {
717
+ settled = true;
718
+ this.waitingForAuth = false;
719
+ resolve();
720
+ }
662
721
  })
663
722
  .catch((error) => {
664
- this.handleAuthenticationFailure(error);
665
- reject(error);
723
+ if (!settled) {
724
+ settled = true;
725
+ this.handleAuthenticationFailure(error);
726
+ reject(error);
727
+ }
666
728
  });
667
729
  };
668
730
  ws.onmessage = (event) => {
669
731
  this.handleMessage(event.data);
670
732
  };
671
733
  ws.onclose = (event) => {
734
+ clearTimeout(connectionTimer);
672
735
  this.connected = false;
673
736
  this.authenticated = false;
674
737
  this.joined = false;
675
738
  this.ws = null;
676
739
  this.stopHeartbeat();
740
+ // Reject pending requests immediately so callers don't hang until timeout
741
+ if (!this.intentionallyLeft) {
742
+ this.rejectAllPendingRequests(new EdgeBaseError(499, 'WebSocket connection lost'));
743
+ }
677
744
  if (event.code === 4004 && this.connectionState !== 'kicked') {
678
745
  this.handleKicked();
679
746
  }
@@ -688,7 +755,11 @@ export class RoomClient {
688
755
  }
689
756
  };
690
757
  ws.onerror = () => {
691
- reject(new EdgeBaseError(500, 'Room WebSocket connection error'));
758
+ clearTimeout(connectionTimer);
759
+ if (!settled) {
760
+ settled = true;
761
+ reject(new EdgeBaseError(500, 'Room WebSocket connection error'));
762
+ }
692
763
  };
693
764
  });
694
765
  }
@@ -1008,7 +1079,7 @@ export class RoomClient {
1008
1079
  this.upsertMember(member);
1009
1080
  this.syncMediaMemberInfo(member);
1010
1081
  const requestId = msg.requestId;
1011
- if (requestId && member.memberId === this.currentUserId) {
1082
+ if (requestId) {
1012
1083
  const pending = this.pendingMemberStateRequests.get(requestId);
1013
1084
  if (pending) {
1014
1085
  clearTimeout(pending.timeout);
@@ -1191,6 +1262,8 @@ export class RoomClient {
1191
1262
  this.waitingForAuth = this.joinRequested;
1192
1263
  this.reconnectInfo = null;
1193
1264
  this.setConnectionState('auth_lost');
1265
+ // Reject pending requests — auth is gone, server won't respond
1266
+ this.rejectAllPendingRequests(new EdgeBaseError(401, 'Auth state lost'));
1194
1267
  if (this.ws) {
1195
1268
  const socket = this.ws;
1196
1269
  this.sendRaw({ type: 'leave' });
@@ -1447,6 +1520,18 @@ export class RoomClient {
1447
1520
  [kind]: next,
1448
1521
  };
1449
1522
  }
1523
+ /** Reject all 5 pending request maps at once. */
1524
+ rejectAllPendingRequests(error) {
1525
+ for (const [, pending] of this.pendingRequests) {
1526
+ clearTimeout(pending.timeout);
1527
+ pending.reject(error);
1528
+ }
1529
+ this.pendingRequests.clear();
1530
+ this.rejectPendingVoidRequests(this.pendingSignalRequests, error);
1531
+ this.rejectPendingVoidRequests(this.pendingAdminRequests, error);
1532
+ this.rejectPendingVoidRequests(this.pendingMemberStateRequests, error);
1533
+ this.rejectPendingVoidRequests(this.pendingMediaRequests, error);
1534
+ }
1450
1535
  rejectPendingVoidRequests(pendingRequests, error) {
1451
1536
  for (const [, pending] of pendingRequests) {
1452
1537
  clearTimeout(pending.timeout);
@@ -1475,9 +1560,41 @@ export class RoomClient {
1475
1560
  const wsUrl = httpUrl.replace(/^http/, 'ws');
1476
1561
  return `${wsUrl}/api/room?namespace=${encodeURIComponent(this.namespace)}&id=${encodeURIComponent(this.roomId)}`;
1477
1562
  }
1563
+ attachBrowserNetworkListeners() {
1564
+ if (this.browserNetworkListenersAttached) {
1565
+ return;
1566
+ }
1567
+ const eventTarget = typeof globalThis !== 'undefined'
1568
+ && typeof globalThis.addEventListener === 'function'
1569
+ ? globalThis
1570
+ : null;
1571
+ if (!eventTarget) {
1572
+ return;
1573
+ }
1574
+ eventTarget.addEventListener('offline', this.browserOfflineHandler);
1575
+ eventTarget.addEventListener('online', this.browserOnlineHandler);
1576
+ this.browserNetworkListenersAttached = true;
1577
+ }
1578
+ detachBrowserNetworkListeners() {
1579
+ if (!this.browserNetworkListenersAttached) {
1580
+ return;
1581
+ }
1582
+ const eventTarget = typeof globalThis !== 'undefined'
1583
+ && typeof globalThis.removeEventListener === 'function'
1584
+ ? globalThis
1585
+ : null;
1586
+ if (!eventTarget) {
1587
+ return;
1588
+ }
1589
+ eventTarget.removeEventListener('offline', this.browserOfflineHandler);
1590
+ eventTarget.removeEventListener('online', this.browserOnlineHandler);
1591
+ this.browserNetworkListenersAttached = false;
1592
+ }
1478
1593
  scheduleReconnect() {
1479
1594
  const attempt = this.reconnectAttempts + 1;
1480
- const delay = this.options.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts);
1595
+ const baseDelay = this.options.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts);
1596
+ const jitter = Math.random() * baseDelay * 0.25;
1597
+ const delay = baseDelay + jitter;
1481
1598
  this.reconnectAttempts++;
1482
1599
  this.reconnectInfo = { attempt };
1483
1600
  this.setConnectionState('reconnecting');