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.
- package/README.md +105 -1
- package/dist/livekit-client.e2ee.worker.js +1 -1
- package/dist/livekit-client.e2ee.worker.js.map +1 -1
- package/dist/livekit-client.e2ee.worker.mjs +1 -0
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +1175 -1341
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/api/SignalClient.d.ts.map +1 -1
- package/dist/src/api/utils.d.ts +1 -0
- package/dist/src/api/utils.d.ts.map +1 -1
- package/dist/src/connectionHelper/checks/turn.d.ts.map +1 -1
- package/dist/src/connectionHelper/checks/websocket.d.ts.map +1 -1
- package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
- package/dist/src/options.d.ts +4 -1
- package/dist/src/options.d.ts.map +1 -1
- package/dist/src/room/PCTransportManager.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +5 -0
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/RegionUrlProvider.d.ts +7 -0
- package/dist/src/room/RegionUrlProvider.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +1 -1
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/data-stream/incoming/StreamReader.d.ts +3 -3
- package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
- package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +0 -1
- package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts.map +1 -1
- package/dist/src/room/errors.d.ts +74 -5
- package/dist/src/room/errors.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/Participant.d.ts +1 -1
- package/dist/src/room/participant/Participant.d.ts.map +1 -1
- package/dist/src/room/token-source/TokenSource.d.ts +10 -2
- package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
- package/dist/src/room/track/LocalTrack.d.ts +0 -4
- package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
- package/dist/src/room/track/TrackPublication.d.ts.map +1 -1
- package/dist/src/room/track/create.d.ts.map +1 -1
- package/dist/src/room/track/processor/types.d.ts +0 -6
- package/dist/src/room/track/processor/types.d.ts.map +1 -1
- package/dist/src/room/types.d.ts +1 -1
- package/dist/src/room/types.d.ts.map +1 -1
- package/dist/src/room/utils.d.ts +4 -4
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/src/test/mocks.d.ts.map +1 -1
- package/dist/ts4.2/api/utils.d.ts +1 -0
- package/dist/ts4.2/options.d.ts +4 -1
- package/dist/ts4.2/room/RTCEngine.d.ts +5 -0
- package/dist/ts4.2/room/RegionUrlProvider.d.ts +7 -0
- package/dist/ts4.2/room/Room.d.ts +1 -1
- package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +3 -3
- package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +0 -1
- package/dist/ts4.2/room/errors.d.ts +74 -5
- package/dist/ts4.2/room/participant/Participant.d.ts +1 -1
- package/dist/ts4.2/room/token-source/TokenSource.d.ts +1 -1
- package/dist/ts4.2/room/track/LocalTrack.d.ts +0 -4
- package/dist/ts4.2/room/track/processor/types.d.ts +0 -6
- package/dist/ts4.2/room/types.d.ts +1 -1
- package/dist/ts4.2/room/utils.d.ts +3 -3
- package/package.json +10 -6
- package/src/api/SignalClient.test.ts +12 -19
- package/src/api/SignalClient.ts +13 -28
- package/src/api/utils.ts +1 -1
- package/src/connectionHelper/checks/turn.ts +7 -0
- package/src/connectionHelper/checks/websocket.ts +40 -11
- package/src/e2ee/E2eeManager.ts +6 -4
- package/src/options.ts +4 -4
- package/src/room/PCTransport.ts +1 -1
- package/src/room/PCTransportManager.ts +4 -19
- package/src/room/RTCEngine.ts +64 -20
- package/src/room/RegionUrlProvider.test.ts +183 -9
- package/src/room/RegionUrlProvider.ts +97 -12
- package/src/room/Room.ts +25 -17
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +2 -2
- package/src/room/data-stream/incoming/StreamReader.ts +5 -5
- package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +0 -3
- package/src/room/errors.ts +144 -16
- package/src/room/participant/LocalParticipant.ts +12 -12
- package/src/room/participant/Participant.ts +2 -2
- package/src/room/token-source/TokenSource.ts +5 -1
- package/src/room/track/LocalTrack.ts +0 -4
- package/src/room/track/TrackPublication.ts +1 -1
- package/src/room/track/create.ts +6 -4
- package/src/room/track/processor/types.ts +0 -6
- package/src/room/types.ts +1 -1
- package/src/room/utils.ts +5 -4
- 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
|
|
184
|
-
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
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(
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
64
|
+
throw ConnectionError.cancelled(`Region fetching was aborted`);
|
|
54
65
|
} else {
|
|
55
|
-
// wrap other errors as connection errors
|
|
56
|
-
throw
|
|
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
|
|
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
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|