livekit-client 2.15.16 → 2.16.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.
Files changed (88) hide show
  1. package/README.md +105 -1
  2. package/dist/livekit-client.e2ee.worker.js +1 -1
  3. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  4. package/dist/livekit-client.e2ee.worker.mjs +1 -0
  5. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  6. package/dist/livekit-client.esm.mjs +1175 -1341
  7. package/dist/livekit-client.esm.mjs.map +1 -1
  8. package/dist/livekit-client.umd.js +1 -1
  9. package/dist/livekit-client.umd.js.map +1 -1
  10. package/dist/src/api/SignalClient.d.ts.map +1 -1
  11. package/dist/src/api/utils.d.ts +1 -0
  12. package/dist/src/api/utils.d.ts.map +1 -1
  13. package/dist/src/connectionHelper/checks/turn.d.ts.map +1 -1
  14. package/dist/src/connectionHelper/checks/websocket.d.ts.map +1 -1
  15. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  16. package/dist/src/options.d.ts +4 -1
  17. package/dist/src/options.d.ts.map +1 -1
  18. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  19. package/dist/src/room/RTCEngine.d.ts +5 -0
  20. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  21. package/dist/src/room/RegionUrlProvider.d.ts +7 -0
  22. package/dist/src/room/RegionUrlProvider.d.ts.map +1 -1
  23. package/dist/src/room/Room.d.ts +1 -1
  24. package/dist/src/room/Room.d.ts.map +1 -1
  25. package/dist/src/room/data-stream/incoming/StreamReader.d.ts +3 -3
  26. package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
  27. package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +0 -1
  28. package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts.map +1 -1
  29. package/dist/src/room/errors.d.ts +74 -5
  30. package/dist/src/room/errors.d.ts.map +1 -1
  31. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  32. package/dist/src/room/participant/Participant.d.ts +1 -1
  33. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  34. package/dist/src/room/token-source/TokenSource.d.ts +10 -2
  35. package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
  36. package/dist/src/room/track/LocalTrack.d.ts +0 -4
  37. package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
  38. package/dist/src/room/track/TrackPublication.d.ts.map +1 -1
  39. package/dist/src/room/track/create.d.ts.map +1 -1
  40. package/dist/src/room/track/processor/types.d.ts +0 -6
  41. package/dist/src/room/track/processor/types.d.ts.map +1 -1
  42. package/dist/src/room/types.d.ts +1 -1
  43. package/dist/src/room/types.d.ts.map +1 -1
  44. package/dist/src/room/utils.d.ts +4 -4
  45. package/dist/src/room/utils.d.ts.map +1 -1
  46. package/dist/src/test/mocks.d.ts.map +1 -1
  47. package/dist/ts4.2/api/utils.d.ts +1 -0
  48. package/dist/ts4.2/options.d.ts +4 -1
  49. package/dist/ts4.2/room/RTCEngine.d.ts +5 -0
  50. package/dist/ts4.2/room/RegionUrlProvider.d.ts +7 -0
  51. package/dist/ts4.2/room/Room.d.ts +1 -1
  52. package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +3 -3
  53. package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +0 -1
  54. package/dist/ts4.2/room/errors.d.ts +74 -5
  55. package/dist/ts4.2/room/participant/Participant.d.ts +1 -1
  56. package/dist/ts4.2/room/token-source/TokenSource.d.ts +1 -1
  57. package/dist/ts4.2/room/track/LocalTrack.d.ts +0 -4
  58. package/dist/ts4.2/room/track/processor/types.d.ts +0 -6
  59. package/dist/ts4.2/room/types.d.ts +1 -1
  60. package/dist/ts4.2/room/utils.d.ts +3 -3
  61. package/package.json +10 -6
  62. package/src/api/SignalClient.test.ts +12 -19
  63. package/src/api/SignalClient.ts +13 -28
  64. package/src/api/utils.ts +1 -1
  65. package/src/connectionHelper/checks/turn.ts +7 -0
  66. package/src/connectionHelper/checks/websocket.ts +40 -11
  67. package/src/e2ee/E2eeManager.ts +6 -4
  68. package/src/options.ts +4 -4
  69. package/src/room/PCTransport.ts +1 -1
  70. package/src/room/PCTransportManager.ts +4 -19
  71. package/src/room/RTCEngine.ts +64 -20
  72. package/src/room/RegionUrlProvider.test.ts +183 -9
  73. package/src/room/RegionUrlProvider.ts +97 -12
  74. package/src/room/Room.ts +25 -17
  75. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +2 -2
  76. package/src/room/data-stream/incoming/StreamReader.ts +5 -5
  77. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +0 -3
  78. package/src/room/errors.ts +144 -16
  79. package/src/room/participant/LocalParticipant.ts +12 -12
  80. package/src/room/participant/Participant.ts +2 -2
  81. package/src/room/token-source/TokenSource.ts +5 -1
  82. package/src/room/track/LocalTrack.ts +0 -4
  83. package/src/room/track/TrackPublication.ts +1 -1
  84. package/src/room/track/create.ts +6 -4
  85. package/src/room/track/processor/types.ts +0 -6
  86. package/src/room/types.ts +1 -1
  87. package/src/room/utils.ts +5 -4
  88. package/src/test/mocks.ts +0 -1
@@ -1,5 +1,5 @@
1
- import type { RegionInfo, RegionSettings } from '@livekit/protocol';
2
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { RegionInfo, RegionSettings } from '@livekit/protocol';
3
3
  import { RegionUrlProvider } from './RegionUrlProvider';
4
4
  import { ConnectionError, ConnectionErrorReason } from './errors';
5
5
 
@@ -180,8 +180,9 @@ describe('RegionUrlProvider', () => {
180
180
  const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token');
181
181
  fetchMock.mockResolvedValue(createMockResponse(401));
182
182
 
183
- await expect(provider.fetchRegionSettings()).rejects.toThrow(ConnectionError);
184
- await expect(provider.fetchRegionSettings()).rejects.toMatchObject({
183
+ const error = await provider.fetchRegionSettings().catch((e) => e);
184
+ expect(error).toBeInstanceOf(ConnectionError);
185
+ expect(error).toMatchObject({
185
186
  reason: ConnectionErrorReason.NotAllowed,
186
187
  status: 401,
187
188
  });
@@ -191,11 +192,9 @@ describe('RegionUrlProvider', () => {
191
192
  const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token');
192
193
  fetchMock.mockResolvedValue(createMockResponse(500));
193
194
 
194
- await expect(provider.fetchRegionSettings()).rejects.toThrow(ConnectionError);
195
- await expect(provider.fetchRegionSettings()).rejects.toMatchObject({
196
- reason: ConnectionErrorReason.InternalError,
197
- status: 500,
198
- });
195
+ const error = await provider.fetchRegionSettings().catch((e) => e);
196
+ expect(error).toBeInstanceOf(ConnectionError);
197
+ expect(error.reason).toBe(ConnectionErrorReason.InternalError);
199
198
  });
200
199
 
201
200
  it('extracts max-age from Cache-Control header', async () => {
@@ -725,7 +724,7 @@ describe('RegionUrlProvider', () => {
725
724
 
726
725
  expect(error).toBeInstanceOf(ConnectionError);
727
726
  expect(error.reason).toBe(ConnectionErrorReason.ServerUnreachable);
728
- expect(error.status).toBe(500);
727
+ expect(error.status).toBe(undefined);
729
728
  expect(error.message).toContain('Failed to fetch');
730
729
  });
731
730
 
@@ -828,4 +827,179 @@ describe('RegionUrlProvider', () => {
828
827
  expect(region2).toBeNull(); // Filtered out because same URL was already attempted
829
828
  });
830
829
  });
830
+
831
+ describe('connection tracking and auto-refetch cleanup', () => {
832
+ beforeEach(() => {
833
+ // Reset connection tracking maps
834
+ // @ts-ignore - accessing private static field for testing
835
+ RegionUrlProvider.connectionTrackers = new Map();
836
+ });
837
+
838
+ it('stops auto-refetch 30s after last connection disconnects', async () => {
839
+ const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token');
840
+ const mockSettings = createMockRegionSettings([
841
+ { region: 'us-west', url: 'wss://us-west.livekit.cloud' },
842
+ ]);
843
+
844
+ fetchMock.mockResolvedValue(
845
+ createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=100' }),
846
+ );
847
+
848
+ // Initial fetch to start auto-refetch
849
+ await provider.getNextBestRegionUrl();
850
+ expect(fetchMock).toHaveBeenCalledTimes(1);
851
+
852
+ const hostname = provider.getServerUrl().hostname;
853
+
854
+ // Simulate connection
855
+ provider.notifyConnected();
856
+
857
+ // Verify auto-refetch is running
858
+ const timersBeforeDisconnect = vi.getTimerCount();
859
+ expect(timersBeforeDisconnect).toBeGreaterThan(0);
860
+
861
+ // Simulate disconnect
862
+ provider.notifyDisconnected();
863
+
864
+ // Should schedule cleanup timeout (30s)
865
+ // Advance time by 29s - refetch should still be running
866
+ vi.advanceTimersByTime(29000);
867
+ expect(fetchMock).toHaveBeenCalledTimes(1); // No additional fetches
868
+
869
+ // Advance by 1 more second (total 30s) - cleanup should trigger
870
+ await vi.advanceTimersByTimeAsync(1000);
871
+
872
+ // Auto-refetch should be stopped, so no new timers for refetch
873
+ // @ts-ignore - accessing private static field for testing
874
+ const refetchTimeout = RegionUrlProvider.settingsTimeouts.get(hostname);
875
+ expect(refetchTimeout).toBeUndefined();
876
+ });
877
+
878
+ it('cancels cleanup when reconnecting before 30s delay', async () => {
879
+ const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token');
880
+ const mockSettings = createMockRegionSettings([
881
+ { region: 'us-west', url: 'wss://us-west.livekit.cloud' },
882
+ ]);
883
+
884
+ fetchMock.mockResolvedValue(
885
+ createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=100' }),
886
+ );
887
+
888
+ await provider.getNextBestRegionUrl();
889
+ const hostname = provider.getServerUrl().hostname;
890
+
891
+ // Connect and disconnect
892
+ provider.notifyConnected();
893
+ provider.notifyDisconnected();
894
+
895
+ // Advance time by 15s (less than 30s)
896
+ vi.advanceTimersByTime(15000);
897
+
898
+ // Reconnect before cleanup triggers
899
+ provider.notifyConnected();
900
+
901
+ // @ts-ignore - accessing private static field for testing
902
+ const tracker = RegionUrlProvider.connectionTrackers.get(hostname);
903
+ expect(tracker?.cleanupTimeout).toBeUndefined(); // Cleanup should be cancelled
904
+
905
+ // Advance past the original 30s mark
906
+ vi.advanceTimersByTime(20000);
907
+
908
+ // Auto-refetch should still be running
909
+ // @ts-ignore - accessing private static field for testing
910
+ const refetchTimeout = RegionUrlProvider.settingsTimeouts.get(hostname);
911
+ expect(refetchTimeout).toBeDefined();
912
+ });
913
+
914
+ it('tracks multiple connections correctly', async () => {
915
+ const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token');
916
+ const mockSettings = createMockRegionSettings([
917
+ { region: 'us-west', url: 'wss://us-west.livekit.cloud' },
918
+ ]);
919
+
920
+ fetchMock.mockResolvedValue(
921
+ createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=100' }),
922
+ );
923
+
924
+ await provider.getNextBestRegionUrl();
925
+ const hostname = provider.getServerUrl().hostname;
926
+
927
+ // Simulate 3 connections
928
+ provider.notifyConnected();
929
+ provider.notifyConnected();
930
+ provider.notifyConnected();
931
+
932
+ // @ts-ignore - accessing private static field for testing
933
+ const tracker = RegionUrlProvider.connectionTrackers.get(hostname);
934
+ expect(tracker?.connectionCount).toBe(3);
935
+
936
+ // Disconnect first connection
937
+ provider.notifyDisconnected();
938
+
939
+ // @ts-ignore - accessing private static field for testing
940
+ expect(tracker?.connectionCount).toBe(2);
941
+
942
+ // Should NOT schedule cleanup yet (still have active connections)
943
+ expect(tracker?.cleanupTimeout).toBeUndefined();
944
+
945
+ // Disconnect second connection
946
+ provider.notifyDisconnected();
947
+ // @ts-ignore - accessing private static field for testing
948
+ expect(tracker?.connectionCount).toBe(1);
949
+
950
+ // Disconnect last connection
951
+ provider.notifyDisconnected();
952
+ // @ts-ignore - accessing private static field for testing
953
+ expect(tracker?.connectionCount).toBe(0);
954
+
955
+ // NOW cleanup should be scheduled
956
+ expect(tracker?.cleanupTimeout).toBeDefined();
957
+ });
958
+
959
+ it('handles disconnect without prior connect gracefully', () => {
960
+ const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token');
961
+ const hostname = provider.getServerUrl().hostname;
962
+
963
+ // Disconnect without connect should not throw
964
+ expect(() => {
965
+ provider.notifyDisconnected();
966
+ }).not.toThrow();
967
+
968
+ // Should not create a tracker
969
+ // @ts-ignore - accessing private static field for testing
970
+ const tracker = RegionUrlProvider.connectionTrackers.get(hostname);
971
+ expect(tracker).toBeUndefined();
972
+ });
973
+
974
+ it('clears cleanup timeout when scheduling new cleanup', async () => {
975
+ const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token');
976
+ const mockSettings = createMockRegionSettings([
977
+ { region: 'us-west', url: 'wss://us-west.livekit.cloud' },
978
+ ]);
979
+
980
+ fetchMock.mockResolvedValue(
981
+ createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=100' }),
982
+ );
983
+
984
+ await provider.getNextBestRegionUrl();
985
+ const hostname = provider.getServerUrl().hostname;
986
+
987
+ // Connect and disconnect (schedules cleanup)
988
+ provider.notifyConnected();
989
+ provider.notifyDisconnected();
990
+
991
+ // @ts-ignore - accessing private static field for testing
992
+ const tracker = RegionUrlProvider.connectionTrackers.get(hostname);
993
+ const firstCleanupTimeout = tracker?.cleanupTimeout;
994
+ expect(firstCleanupTimeout).toBeDefined();
995
+
996
+ // Reconnect and disconnect again (should cancel first and schedule new)
997
+ provider.notifyConnected();
998
+ provider.notifyDisconnected();
999
+
1000
+ const secondCleanupTimeout = tracker?.cleanupTimeout;
1001
+ expect(secondCleanupTimeout).toBeDefined();
1002
+ expect(secondCleanupTimeout).not.toBe(firstCleanupTimeout);
1003
+ });
1004
+ });
831
1005
  });
@@ -5,6 +5,7 @@ import { ConnectionError, ConnectionErrorReason } from './errors';
5
5
  import { extractMaxAgeFromRequestHeaders, isCloud } from './utils';
6
6
 
7
7
  export const DEFAULT_MAX_AGE_MS = 5_000;
8
+ export const STOP_REFETCH_DELAY_MS = 30_000;
8
9
 
9
10
  type CachedRegionSettings = {
10
11
  regionSettings: RegionSettings;
@@ -12,11 +13,18 @@ type CachedRegionSettings = {
12
13
  maxAgeInMs: number;
13
14
  };
14
15
 
16
+ type ConnectionTracker = {
17
+ connectionCount: number;
18
+ cleanupTimeout?: ReturnType<typeof setTimeout>;
19
+ };
20
+
15
21
  export class RegionUrlProvider {
16
22
  private static readonly cache: Map<string, CachedRegionSettings> = new Map();
17
23
 
18
24
  private static settingsTimeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
19
25
 
26
+ private static connectionTrackers: Map<string, ConnectionTracker> = new Map();
27
+
20
28
  private static fetchLock = new Mutex();
21
29
 
22
30
  private static async fetchRegionSettings(
@@ -37,26 +45,27 @@ export class RegionUrlProvider {
37
45
  const regionSettings = (await regionSettingsResponse.json()) as RegionSettings;
38
46
  return { regionSettings, updatedAtInMs: Date.now(), maxAgeInMs };
39
47
  } else {
40
- throw new ConnectionError(
41
- `Could not fetch region settings: ${regionSettingsResponse.statusText}`,
42
- regionSettingsResponse.status === 401
43
- ? ConnectionErrorReason.NotAllowed
44
- : ConnectionErrorReason.InternalError,
45
- regionSettingsResponse.status,
46
- );
48
+ if (regionSettingsResponse.status === 401) {
49
+ throw ConnectionError.notAllowed(
50
+ `Could not fetch region settings: ${regionSettingsResponse.statusText}`,
51
+ regionSettingsResponse.status,
52
+ );
53
+ } else {
54
+ throw ConnectionError.internal(
55
+ `Could not fetch region settings: ${regionSettingsResponse.statusText}`,
56
+ );
57
+ }
47
58
  }
48
59
  } catch (e: unknown) {
49
60
  if (e instanceof ConnectionError) {
50
61
  // rethrow connection errors
51
62
  throw e;
52
63
  } else if (signal?.aborted) {
53
- throw new ConnectionError(`Region fetching was aborted`, ConnectionErrorReason.Cancelled);
64
+ throw ConnectionError.cancelled(`Region fetching was aborted`);
54
65
  } else {
55
- // wrap other errors as connection errors (e.g. timeouts)
56
- throw new ConnectionError(
66
+ // wrap other errors as connection errors
67
+ throw ConnectionError.serverUnreachable(
57
68
  `Could not fetch region settings, ${e instanceof Error ? `${e.name}: ${e.message}` : e}`,
58
- ConnectionErrorReason.ServerUnreachable,
59
- 500, // using 500 as a catch-all manually set error code here
60
69
  );
61
70
  }
62
71
  } finally {
@@ -74,6 +83,13 @@ export class RegionUrlProvider {
74
83
  const newSettings = await RegionUrlProvider.fetchRegionSettings(url, token);
75
84
  RegionUrlProvider.updateCachedRegionSettings(url, token, newSettings);
76
85
  } catch (error: unknown) {
86
+ if (
87
+ error instanceof ConnectionError &&
88
+ error.reason === ConnectionErrorReason.NotAllowed
89
+ ) {
90
+ log.debug('token is not valid, cancelling auto region refresh');
91
+ return;
92
+ }
77
93
  log.debug('auto refetching of region settings failed', { error });
78
94
  // continue retrying with the same max age
79
95
  RegionUrlProvider.scheduleRefetch(url, token, maxAgeInMs);
@@ -91,6 +107,75 @@ export class RegionUrlProvider {
91
107
  RegionUrlProvider.scheduleRefetch(url, token, settings.maxAgeInMs);
92
108
  }
93
109
 
110
+ private static stopRefetch(hostname: string) {
111
+ const timeout = RegionUrlProvider.settingsTimeouts.get(hostname);
112
+ if (timeout) {
113
+ clearTimeout(timeout);
114
+ RegionUrlProvider.settingsTimeouts.delete(hostname);
115
+ }
116
+ }
117
+
118
+ private static scheduleCleanup(hostname: string) {
119
+ let tracker = RegionUrlProvider.connectionTrackers.get(hostname);
120
+ if (!tracker) {
121
+ return;
122
+ }
123
+
124
+ // Cancel any existing cleanup timeout
125
+ if (tracker.cleanupTimeout) {
126
+ clearTimeout(tracker.cleanupTimeout);
127
+ }
128
+
129
+ // Schedule cleanup to stop refetch after delay
130
+ tracker.cleanupTimeout = setTimeout(() => {
131
+ const currentTracker = RegionUrlProvider.connectionTrackers.get(hostname);
132
+ if (currentTracker && currentTracker.connectionCount === 0) {
133
+ log.debug('stopping region refetch after disconnect delay', { hostname });
134
+ RegionUrlProvider.stopRefetch(hostname);
135
+ }
136
+ if (currentTracker) {
137
+ currentTracker.cleanupTimeout = undefined;
138
+ }
139
+ }, STOP_REFETCH_DELAY_MS);
140
+ }
141
+
142
+ private static cancelCleanup(hostname: string) {
143
+ const tracker = RegionUrlProvider.connectionTrackers.get(hostname);
144
+ if (tracker?.cleanupTimeout) {
145
+ clearTimeout(tracker.cleanupTimeout);
146
+ tracker.cleanupTimeout = undefined;
147
+ }
148
+ }
149
+
150
+ notifyConnected() {
151
+ const hostname = this.serverUrl.hostname;
152
+ let tracker = RegionUrlProvider.connectionTrackers.get(hostname);
153
+ if (!tracker) {
154
+ tracker = { connectionCount: 0 };
155
+ RegionUrlProvider.connectionTrackers.set(hostname, tracker);
156
+ }
157
+
158
+ tracker.connectionCount++;
159
+
160
+ // Cancel any scheduled cleanup since we have an active connection
161
+ RegionUrlProvider.cancelCleanup(hostname);
162
+ }
163
+
164
+ notifyDisconnected() {
165
+ const hostname = this.serverUrl.hostname;
166
+ const tracker = RegionUrlProvider.connectionTrackers.get(hostname);
167
+ if (!tracker) {
168
+ return;
169
+ }
170
+
171
+ tracker.connectionCount = Math.max(0, tracker.connectionCount - 1);
172
+
173
+ // If no more connections, schedule cleanup
174
+ if (tracker.connectionCount === 0) {
175
+ RegionUrlProvider.scheduleCleanup(hostname);
176
+ }
177
+ }
178
+
94
179
  private serverUrl: URL;
95
180
 
96
181
  private token: string;
package/src/room/Room.ts CHANGED
@@ -31,8 +31,9 @@ import {
31
31
  protoInt64,
32
32
  } from '@livekit/protocol';
33
33
  import { EventEmitter } from 'events';
34
- import type TypedEmitter from 'typed-emitter';
35
34
  import 'webrtc-adapter';
35
+ import type TypedEmitter from 'typed-emitter';
36
+ import { ensureTrailingSlash } from '../api/utils';
36
37
  import { EncryptionEvent } from '../e2ee';
37
38
  import { type BaseE2EEManager, E2EEManager } from '../e2ee/E2eeManager';
38
39
  import log, { LoggerNames, getLogger } from '../logger';
@@ -169,7 +170,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
169
170
  private abortController?: AbortController;
170
171
 
171
172
  /** future holding client initiated connection attempt */
172
- private connectFuture?: Future<void>;
173
+ private connectFuture?: Future<void, Error>;
173
174
 
174
175
  private disconnectLock: Mutex;
175
176
 
@@ -644,7 +645,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
644
645
  }
645
646
 
646
647
  this.setAndEmitConnectionState(ConnectionState.Connecting);
647
- if (this.regionUrlProvider?.getServerUrl().toString() !== url) {
648
+ if (this.regionUrlProvider?.getServerUrl().toString() !== ensureTrailingSlash(url)) {
648
649
  this.regionUrl = undefined;
649
650
  this.regionUrlProvider = undefined;
650
651
  }
@@ -686,7 +687,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
686
687
  try {
687
688
  await BackOffStrategy.getInstance().getBackOffPromise(url);
688
689
  if (abortController.signal.aborted) {
689
- throw new ConnectionError('Connection attempt aborted', ConnectionErrorReason.Cancelled);
690
+ throw ConnectionError.cancelled('Connection attempt aborted');
690
691
  }
691
692
  await this.attemptConnection(regionUrl ?? url, token, opts, abortController);
692
693
  this.abortController = undefined;
@@ -894,12 +895,11 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
894
895
  } catch (err) {
895
896
  await this.engine.close();
896
897
  this.recreateEngine();
897
- const resultingError = new ConnectionError(
898
- `could not establish signal connection`,
899
- abortController.signal.aborted
900
- ? ConnectionErrorReason.Cancelled
901
- : ConnectionErrorReason.ServerUnreachable,
902
- );
898
+
899
+ const resultingError = abortController.signal.aborted
900
+ ? ConnectionError.cancelled('Signal connection aborted')
901
+ : ConnectionError.serverUnreachable('could not establish signal connection');
902
+
903
903
  if (err instanceof Error) {
904
904
  resultingError.message = `${resultingError.message}: ${err.message}`;
905
905
  }
@@ -917,7 +917,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
917
917
  if (abortController.signal.aborted) {
918
918
  await this.engine.close();
919
919
  this.recreateEngine();
920
- throw new ConnectionError(`Connection attempt aborted`, ConnectionErrorReason.Cancelled);
920
+ throw ConnectionError.cancelled(`Connection attempt aborted`);
921
921
  }
922
922
 
923
923
  try {
@@ -938,12 +938,17 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
938
938
  window.addEventListener('beforeunload', this.onPageLeave);
939
939
  }
940
940
  if (isWeb()) {
941
- document.addEventListener('freeze', this.onPageLeave);
941
+ window.addEventListener('freeze', this.onPageLeave);
942
942
  }
943
943
  this.setAndEmitConnectionState(ConnectionState.Connected);
944
944
  this.emit(RoomEvent.Connected);
945
945
  BackOffStrategy.getInstance().resetFailedConnectionAttempts(url);
946
946
  this.registerConnectionReconcile();
947
+
948
+ // Notify region provider about successful connection
949
+ if (this.regionUrlProvider) {
950
+ this.regionUrlProvider.notifyConnected();
951
+ }
947
952
  };
948
953
 
949
954
  /**
@@ -969,9 +974,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
969
974
  this.log.warn(msg, this.logContext);
970
975
  this.abortController?.abort(msg);
971
976
  // in case the abort controller didn't manage to cancel the connection attempt, reject the connect promise explicitly
972
- this.connectFuture?.reject?.(
973
- new ConnectionError('Client initiated disconnect', ConnectionErrorReason.Cancelled),
974
- );
977
+ this.connectFuture?.reject?.(ConnectionError.cancelled('Client initiated disconnect'));
975
978
  this.connectFuture = undefined;
976
979
  }
977
980
 
@@ -1557,6 +1560,11 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1557
1560
 
1558
1561
  this.regionUrl = undefined;
1559
1562
 
1563
+ // Notify region provider about disconnect to potentially stop auto-refetch
1564
+ if (this.regionUrlProvider) {
1565
+ this.regionUrlProvider.notifyDisconnected();
1566
+ }
1567
+
1560
1568
  try {
1561
1569
  this.remoteParticipants.forEach((p) => {
1562
1570
  p.trackPublications.forEach((pub) => {
@@ -1915,7 +1923,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1915
1923
  });
1916
1924
  if (byteLength(response) > MAX_PAYLOAD_BYTES) {
1917
1925
  responseError = RpcError.builtIn('RESPONSE_PAYLOAD_TOO_LARGE');
1918
- console.warn(`RPC Response payload too large for ${method}`);
1926
+ this.log.warn(`RPC Response payload too large for ${method}`);
1919
1927
  } else {
1920
1928
  responsePayload = response;
1921
1929
  }
@@ -1923,7 +1931,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1923
1931
  if (error instanceof RpcError) {
1924
1932
  responseError = error;
1925
1933
  } else {
1926
- console.warn(
1934
+ this.log.warn(
1927
1935
  `Uncaught error returned by RPC handler for ${method}. Returning APPLICATION_ERROR instead.`,
1928
1936
  error,
1929
1937
  );
@@ -121,7 +121,7 @@ export default class IncomingDataStreamManager {
121
121
  }
122
122
 
123
123
  let streamController: ReadableStreamDefaultController<DataStream_Chunk>;
124
- const outOfBandFailureRejectingFuture = new Future<never>();
124
+ const outOfBandFailureRejectingFuture = new Future<never, Error>();
125
125
  outOfBandFailureRejectingFuture.promise.catch((err) => {
126
126
  this.log.error(err);
127
127
  });
@@ -178,7 +178,7 @@ export default class IncomingDataStreamManager {
178
178
  }
179
179
 
180
180
  let streamController: ReadableStreamDefaultController<DataStream_Chunk>;
181
- const outOfBandFailureRejectingFuture = new Future<never>();
181
+ const outOfBandFailureRejectingFuture = new Future<never, Error>();
182
182
  outOfBandFailureRejectingFuture.promise.catch((err) => {
183
183
  this.log.error(err);
184
184
  });
@@ -17,7 +17,7 @@ abstract class BaseStreamReader<T extends BaseStreamInfo> {
17
17
 
18
18
  protected bytesReceived: number;
19
19
 
20
- protected outOfBandFailureRejectingFuture?: Future<never>;
20
+ protected outOfBandFailureRejectingFuture?: Future<never, Error>;
21
21
 
22
22
  get info() {
23
23
  return this._info;
@@ -46,7 +46,7 @@ abstract class BaseStreamReader<T extends BaseStreamInfo> {
46
46
  info: T,
47
47
  stream: ReadableStream<DataStream_Chunk>,
48
48
  totalByteSize?: number,
49
- outOfBandFailureRejectingFuture?: Future<never>,
49
+ outOfBandFailureRejectingFuture?: Future<never, Error>,
50
50
  ) {
51
51
  this.reader = stream;
52
52
  this.totalByteSize = totalByteSize;
@@ -80,7 +80,7 @@ export class ByteStreamReader extends BaseStreamReader<ByteStreamInfo> {
80
80
  [Symbol.asyncIterator]() {
81
81
  const reader = this.reader.getReader();
82
82
 
83
- let rejectingSignalFuture = new Future<never>();
83
+ let rejectingSignalFuture = new Future<never, Error>();
84
84
  let activeSignal: AbortSignal | null = null;
85
85
  let onAbort: (() => void) | null = null;
86
86
  if (this.signal) {
@@ -175,7 +175,7 @@ export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
175
175
  info: TextStreamInfo,
176
176
  stream: ReadableStream<DataStream_Chunk>,
177
177
  totalChunkCount?: number,
178
- outOfBandFailureRejectingFuture?: Future<never>,
178
+ outOfBandFailureRejectingFuture?: Future<never, Error>,
179
179
  ) {
180
180
  super(info, stream, totalChunkCount, outOfBandFailureRejectingFuture);
181
181
  this.receivedChunks = new Map();
@@ -213,7 +213,7 @@ export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
213
213
  const reader = this.reader.getReader();
214
214
  const decoder = new TextDecoder('utf-8', { fatal: true });
215
215
 
216
- let rejectingSignalFuture = new Future<never>();
216
+ let rejectingSignalFuture = new Future<never, Error>();
217
217
  let activeSignal: AbortSignal | null = null;
218
218
  let onAbort: (() => void) | null = null;
219
219
  if (this.signal) {
@@ -93,7 +93,6 @@ export default class OutgoingDataStreamManager {
93
93
 
94
94
  /**
95
95
  * @internal
96
- * @experimental CAUTION, might get removed in a minor release
97
96
  */
98
97
  async streamText(options?: StreamTextOptions): Promise<TextStreamWriter> {
99
98
  const streamId = options?.streamId ?? crypto.randomUUID();
@@ -146,7 +145,6 @@ export default class OutgoingDataStreamManager {
146
145
  // Implement the sink
147
146
  async write(text) {
148
147
  for (const textByteChunk of splitUtf8(text, STREAM_CHUNK_SIZE)) {
149
- await engine.waitForBufferStatusLow(DataPacket_Kind.RELIABLE);
150
148
  const chunk = new DataStream_Chunk({
151
149
  content: textByteChunk,
152
150
  streamId,
@@ -278,7 +276,6 @@ export default class OutgoingDataStreamManager {
278
276
  try {
279
277
  while (byteOffset < chunk.byteLength) {
280
278
  const subChunk = chunk.slice(byteOffset, byteOffset + STREAM_CHUNK_SIZE);
281
- await engine.waitForBufferStatusLow(DataPacket_Kind.RELIABLE);
282
279
  const chunkPacket = new DataPacket({
283
280
  destinationIdentities,
284
281
  value: {