livekit-client 2.18.3 → 2.18.5
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/livekit-client.esm.mjs +703 -334
- 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/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +12 -4
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/RegionUrlProvider.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +3 -0
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts +4 -0
- package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts.map +1 -1
- package/dist/src/room/events.d.ts +3 -1
- package/dist/src/room/events.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts +8 -0
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/RemoteParticipant.d.ts +4 -3
- package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
- package/dist/src/room/track/LocalTrack.d.ts +7 -0
- package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
- package/dist/src/room/track/LocalVideoTrack.d.ts +12 -1
- package/dist/src/room/track/LocalVideoTrack.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/ts4.2/room/RTCEngine.d.ts +12 -4
- package/dist/ts4.2/room/Room.d.ts +3 -0
- package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +4 -0
- package/dist/ts4.2/room/events.d.ts +3 -1
- package/dist/ts4.2/room/participant/LocalParticipant.d.ts +8 -0
- package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +4 -3
- package/dist/ts4.2/room/track/LocalTrack.d.ts +7 -0
- package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
- package/dist/ts4.2/room/types.d.ts +1 -1
- package/package.json +3 -3
- package/src/api/SignalClient.ts +4 -0
- package/src/room/PCTransport.ts +10 -8
- package/src/room/RTCEngine.ts +59 -28
- package/src/room/RegionUrlProvider.ts +7 -0
- package/src/room/Room.ts +93 -23
- package/src/room/data-track/incoming/IncomingDataTrackManager.test.ts +331 -16
- package/src/room/data-track/incoming/IncomingDataTrackManager.ts +92 -41
- package/src/room/events.ts +2 -0
- package/src/room/participant/LocalParticipant.ts +70 -5
- package/src/room/participant/RemoteParticipant.ts +14 -2
- package/src/room/token-source/TokenSource.test.ts +337 -0
- package/src/room/token-source/test-tokens.ts +28 -0
- package/src/room/token-source/utils.test.ts +12 -20
- package/src/room/track/LocalTrack.ts +15 -1
- package/src/room/track/LocalVideoTrack.ts +126 -2
- package/src/room/track/RemoteVideoTrack.ts +8 -2
- package/src/room/types.ts +2 -1
- package/src/utils/deferrable-map.ts +2 -2
|
@@ -39,6 +39,7 @@ import { defaultVideoCodec } from '../defaults';
|
|
|
39
39
|
import {
|
|
40
40
|
DeviceUnsupportedError,
|
|
41
41
|
LivekitError,
|
|
42
|
+
NegotiationError,
|
|
42
43
|
PublishTrackError,
|
|
43
44
|
SignalRequestError,
|
|
44
45
|
TrackInvalidError,
|
|
@@ -656,11 +657,12 @@ export default class LocalParticipant extends Participant {
|
|
|
656
657
|
if (track && track.track) {
|
|
657
658
|
// screenshare cannot be muted, unpublish instead
|
|
658
659
|
if (source === Track.Source.ScreenShare) {
|
|
659
|
-
|
|
660
|
+
const unpublishPromises = [this.unpublishTrack(track.track)];
|
|
660
661
|
const screenAudioTrack = this.getTrackPublication(Track.Source.ScreenShareAudio);
|
|
661
662
|
if (screenAudioTrack && screenAudioTrack.track) {
|
|
662
|
-
this.unpublishTrack(screenAudioTrack.track);
|
|
663
|
+
unpublishPromises.push(this.unpublishTrack(screenAudioTrack.track));
|
|
663
664
|
}
|
|
665
|
+
[track] = await Promise.all(unpublishPromises);
|
|
664
666
|
} else {
|
|
665
667
|
await track.mute();
|
|
666
668
|
}
|
|
@@ -806,10 +808,42 @@ export default class LocalParticipant extends Participant {
|
|
|
806
808
|
return this.publishOrRepublishTrack(track, options);
|
|
807
809
|
}
|
|
808
810
|
|
|
811
|
+
/**
|
|
812
|
+
* Waits for the engine's next `Restarted` event. Unlike `engine.waitForRestarted`, this does
|
|
813
|
+
* not short-circuit when `pcState === Connected` — at the point this is called (right after a
|
|
814
|
+
* `NegotiationError`) the PC transport is still connected, but `fullReconnectOnNext` has been
|
|
815
|
+
* set and `attemptReconnect` is queued via setTimeout. We need to wait for that restart to
|
|
816
|
+
* actually complete (which clears `pendingTrackResolvers` via `cleanupClient`) before retrying.
|
|
817
|
+
*/
|
|
818
|
+
private waitForNextEngineRestart(timeoutMs = 15_000): Promise<void> {
|
|
819
|
+
return new Promise<void>((resolve, reject) => {
|
|
820
|
+
const cleanup = () => {
|
|
821
|
+
clearTimeout(timeout);
|
|
822
|
+
this.engine.off(EngineEvent.Restarted, onRestarted);
|
|
823
|
+
this.engine.off(EngineEvent.Closing, onClosing);
|
|
824
|
+
};
|
|
825
|
+
const onRestarted = () => {
|
|
826
|
+
cleanup();
|
|
827
|
+
resolve();
|
|
828
|
+
};
|
|
829
|
+
const onClosing = () => {
|
|
830
|
+
cleanup();
|
|
831
|
+
reject(new Error('engine closed before restart completed'));
|
|
832
|
+
};
|
|
833
|
+
const timeout = setTimeout(() => {
|
|
834
|
+
cleanup();
|
|
835
|
+
reject(new Error('timed out waiting for engine restart'));
|
|
836
|
+
}, timeoutMs);
|
|
837
|
+
this.engine.once(EngineEvent.Restarted, onRestarted);
|
|
838
|
+
this.engine.once(EngineEvent.Closing, onClosing);
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
809
842
|
private async publishOrRepublishTrack(
|
|
810
843
|
track: LocalTrack | MediaStreamTrack,
|
|
811
844
|
options?: TrackPublishOptions,
|
|
812
845
|
isRepublish = false,
|
|
846
|
+
hasRetriedAfterNegotiationError = false,
|
|
813
847
|
): Promise<LocalTrackPublication> {
|
|
814
848
|
if (isLocalAudioTrack(track)) {
|
|
815
849
|
track.setAudioContext(this.audioContext);
|
|
@@ -978,6 +1012,15 @@ export default class LocalParticipant extends Participant {
|
|
|
978
1012
|
const publication = await publishPromise;
|
|
979
1013
|
return publication;
|
|
980
1014
|
} catch (e) {
|
|
1015
|
+
if (!hasRetriedAfterNegotiationError && e instanceof NegotiationError) {
|
|
1016
|
+
this.log.warn('negotiation due to track publish failed, retrying after reconnect', {
|
|
1017
|
+
...this.logContext,
|
|
1018
|
+
error: e,
|
|
1019
|
+
});
|
|
1020
|
+
this.pendingPublishPromises.delete(track);
|
|
1021
|
+
await this.waitForNextEngineRestart();
|
|
1022
|
+
return await this.publishOrRepublishTrack(track, options, isRepublish, true);
|
|
1023
|
+
}
|
|
981
1024
|
throw e;
|
|
982
1025
|
} finally {
|
|
983
1026
|
this.pendingPublishPromises.delete(track);
|
|
@@ -1272,7 +1315,11 @@ export default class LocalParticipant extends Participant {
|
|
|
1272
1315
|
resolve(ti);
|
|
1273
1316
|
} catch (err) {
|
|
1274
1317
|
if (track.sender && this.engine.pcManager?.publisher) {
|
|
1275
|
-
|
|
1318
|
+
try {
|
|
1319
|
+
this.engine.pcManager.publisher.removeTrack(track.sender);
|
|
1320
|
+
} catch (e) {
|
|
1321
|
+
this.log.error(e, this.logContext);
|
|
1322
|
+
}
|
|
1276
1323
|
await this.engine.negotiate().catch((negotiateErr) => {
|
|
1277
1324
|
this.log.error(
|
|
1278
1325
|
'failed to negotiate after removing track due to failed add track request',
|
|
@@ -1333,6 +1380,17 @@ export default class LocalParticipant extends Participant {
|
|
|
1333
1380
|
publication.options = opts;
|
|
1334
1381
|
track.sid = ti.sid;
|
|
1335
1382
|
|
|
1383
|
+
// keep publish options on the video track so that it can recompute encoding
|
|
1384
|
+
// parameters when the MediaStreamTrack is restarted (e.g. after switching cameras).
|
|
1385
|
+
// Seed the dimensions we encoded at publish time so the first no-op restart
|
|
1386
|
+
// (e.g. unmute with unchanged constraints) can skip the recompute.
|
|
1387
|
+
if (isLocalVideoTrack(track)) {
|
|
1388
|
+
track.publishOptions = opts;
|
|
1389
|
+
if (req.width && req.height) {
|
|
1390
|
+
track.lastEncodedDimensions = { width: req.width, height: req.height };
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1336
1394
|
this.log.debug(`publishing ${track.kind} with encodings`, {
|
|
1337
1395
|
...this.logContext,
|
|
1338
1396
|
encodings,
|
|
@@ -1587,13 +1645,20 @@ export default class LocalParticipant extends Participant {
|
|
|
1587
1645
|
negotiationNeeded = true;
|
|
1588
1646
|
}
|
|
1589
1647
|
}
|
|
1590
|
-
|
|
1648
|
+
try {
|
|
1649
|
+
negotiationNeeded = this.engine.removeTrack(trackSender);
|
|
1650
|
+
} catch (e) {
|
|
1651
|
+
this.log.warn(e, this.logContext);
|
|
1591
1652
|
negotiationNeeded = true;
|
|
1592
1653
|
}
|
|
1654
|
+
|
|
1593
1655
|
if (isLocalVideoTrack(track)) {
|
|
1594
1656
|
for (const [, trackInfo] of track.simulcastCodecs) {
|
|
1595
1657
|
if (trackInfo.sender) {
|
|
1596
|
-
|
|
1658
|
+
try {
|
|
1659
|
+
negotiationNeeded = this.engine.removeTrack(trackInfo.sender);
|
|
1660
|
+
} catch (e) {
|
|
1661
|
+
this.log.warn(e, this.logContext);
|
|
1597
1662
|
negotiationNeeded = true;
|
|
1598
1663
|
}
|
|
1599
1664
|
trackInfo.sender = undefined;
|
|
@@ -6,7 +6,9 @@ import type {
|
|
|
6
6
|
} from '@livekit/protocol';
|
|
7
7
|
import type { SignalClient } from '../../api/SignalClient';
|
|
8
8
|
import { DeferrableMap } from '../../utils/deferrable-map';
|
|
9
|
-
import
|
|
9
|
+
import RemoteDataTrack from '../data-track/RemoteDataTrack';
|
|
10
|
+
import type IncomingDataTrackManager from '../data-track/incoming/IncomingDataTrackManager';
|
|
11
|
+
import { DataTrackInfo } from '../data-track/types';
|
|
10
12
|
import { ParticipantEvent, TrackEvent } from '../events';
|
|
11
13
|
import RemoteAudioTrack from '../track/RemoteAudioTrack';
|
|
12
14
|
import type RemoteTrack from '../track/RemoteTrack';
|
|
@@ -48,6 +50,7 @@ export default class RemoteParticipant extends Participant {
|
|
|
48
50
|
signalClient: SignalClient,
|
|
49
51
|
pi: ParticipantInfo,
|
|
50
52
|
loggerOptions: LoggerOptions,
|
|
53
|
+
manager: IncomingDataTrackManager,
|
|
51
54
|
): RemoteParticipant {
|
|
52
55
|
return new RemoteParticipant(
|
|
53
56
|
signalClient,
|
|
@@ -58,6 +61,10 @@ export default class RemoteParticipant extends Participant {
|
|
|
58
61
|
pi.attributes,
|
|
59
62
|
loggerOptions,
|
|
60
63
|
pi.kind,
|
|
64
|
+
pi.dataTracks.map((dti) => {
|
|
65
|
+
const info = DataTrackInfo.from(dti);
|
|
66
|
+
return new RemoteDataTrack(info, manager, { publisherIdentity: pi.identity });
|
|
67
|
+
}),
|
|
61
68
|
);
|
|
62
69
|
}
|
|
63
70
|
|
|
@@ -79,13 +86,18 @@ export default class RemoteParticipant extends Participant {
|
|
|
79
86
|
attributes?: Record<string, string>,
|
|
80
87
|
loggerOptions?: LoggerOptions,
|
|
81
88
|
kind: ParticipantKind = ParticipantKind.STANDARD,
|
|
89
|
+
remoteDataTracks: Array<RemoteDataTrack> = [],
|
|
82
90
|
) {
|
|
83
91
|
super(sid, identity || '', name, metadata, attributes, loggerOptions, kind);
|
|
84
92
|
this.signalClient = signalClient;
|
|
85
93
|
this.trackPublications = new Map();
|
|
86
94
|
this.audioTrackPublications = new Map();
|
|
87
95
|
this.videoTrackPublications = new Map();
|
|
88
|
-
this.dataTracks = new DeferrableMap(
|
|
96
|
+
this.dataTracks = new DeferrableMap(
|
|
97
|
+
remoteDataTracks.map((remoteDataTrack) => {
|
|
98
|
+
return [remoteDataTrack.info.name, remoteDataTrack];
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
89
101
|
this.volumeMap = new Map();
|
|
90
102
|
}
|
|
91
103
|
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { sleep } from '../utils';
|
|
4
|
+
import { TokenSource } from './TokenSource';
|
|
5
|
+
import { TOKENS } from './test-tokens';
|
|
6
|
+
import { TokenSourceFetchOptions, TokenSourceResponseObject } from './types';
|
|
7
|
+
|
|
8
|
+
const EXAMPLE_FETCH_OPTIONS: TokenSourceFetchOptions = {
|
|
9
|
+
roomName: 'room name',
|
|
10
|
+
participantName: 'participant name',
|
|
11
|
+
participantIdentity: 'participant identity',
|
|
12
|
+
participantMetadata: '{"example": "metadata here"}',
|
|
13
|
+
participantAttributes: {},
|
|
14
|
+
|
|
15
|
+
agentName: 'agent name',
|
|
16
|
+
agentMetadata: '{"example": "agent metadata here"}',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const EXAMPLE_TOKEN_ENDPOINT_RESPONSE_JSON = {
|
|
20
|
+
server_url: 'wss://localhost:7000',
|
|
21
|
+
participant_token: 'bogus token',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function makeResponseObject(token: string = TOKENS.VALID): TokenSourceResponseObject {
|
|
25
|
+
return {
|
|
26
|
+
serverUrl: 'wss://localhost:7000',
|
|
27
|
+
participantToken: token,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function mockGlobalFetchResponse(
|
|
32
|
+
responseJson: any = EXAMPLE_TOKEN_ENDPOINT_RESPONSE_JSON,
|
|
33
|
+
responseOptions?: ResponseInit,
|
|
34
|
+
) {
|
|
35
|
+
return mockGlobalFetchResponses([{ responseJson, responseOptions }]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mockGlobalFetchResponses(
|
|
39
|
+
responses: Array<{
|
|
40
|
+
responseJson?: any;
|
|
41
|
+
responseOptions?: ResponseInit;
|
|
42
|
+
}>,
|
|
43
|
+
) {
|
|
44
|
+
const oldFetch = globalThis.fetch;
|
|
45
|
+
|
|
46
|
+
const fetchMock = vi.fn();
|
|
47
|
+
for (const {
|
|
48
|
+
responseJson = EXAMPLE_TOKEN_ENDPOINT_RESPONSE_JSON,
|
|
49
|
+
responseOptions,
|
|
50
|
+
} of responses) {
|
|
51
|
+
const response = new Response(JSON.stringify(responseJson), {
|
|
52
|
+
status: 200,
|
|
53
|
+
headers: { 'Content-Type': 'application/json' },
|
|
54
|
+
...responseOptions,
|
|
55
|
+
});
|
|
56
|
+
fetchMock.mockResolvedValueOnce(response);
|
|
57
|
+
}
|
|
58
|
+
globalThis.fetch = fetchMock;
|
|
59
|
+
|
|
60
|
+
const teardown = () => {
|
|
61
|
+
globalThis.fetch = oldFetch;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return { fetchMock: fetchMock.mock, teardown };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('TokenSource.endpoint', () => {
|
|
68
|
+
it('tests happy path with all options', async () => {
|
|
69
|
+
const { teardown, fetchMock } = mockGlobalFetchResponse();
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint');
|
|
73
|
+
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
|
|
74
|
+
expect(fetchMock.lastCall).toStrictEqual([
|
|
75
|
+
'https://example.com/my/token/endpoint',
|
|
76
|
+
{
|
|
77
|
+
method: 'POST',
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
room_name: 'room name',
|
|
80
|
+
participant_name: 'participant name',
|
|
81
|
+
participant_identity: 'participant identity',
|
|
82
|
+
participant_metadata: '{"example": "metadata here"}',
|
|
83
|
+
room_config: {
|
|
84
|
+
agents: [
|
|
85
|
+
{
|
|
86
|
+
agent_name: 'agent name',
|
|
87
|
+
metadata: '{"example": "agent metadata here"}',
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
headers: { 'Content-Type': 'application/json' },
|
|
93
|
+
},
|
|
94
|
+
]);
|
|
95
|
+
} finally {
|
|
96
|
+
teardown();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('tests happy path with no options', async () => {
|
|
101
|
+
const { teardown, fetchMock } = mockGlobalFetchResponse();
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint');
|
|
105
|
+
await tokenSource.fetch({});
|
|
106
|
+
expect(fetchMock.lastCall).toStrictEqual([
|
|
107
|
+
'https://example.com/my/token/endpoint',
|
|
108
|
+
{
|
|
109
|
+
method: 'POST',
|
|
110
|
+
body: JSON.stringify({}),
|
|
111
|
+
headers: { 'Content-Type': 'application/json' },
|
|
112
|
+
},
|
|
113
|
+
]);
|
|
114
|
+
} finally {
|
|
115
|
+
teardown();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('throws on non-200 response', async () => {
|
|
120
|
+
const { teardown } = mockGlobalFetchResponse(
|
|
121
|
+
{ error: 'forbidden' },
|
|
122
|
+
{ status: 403, headers: { 'Content-Type': 'application/json' } },
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint');
|
|
127
|
+
await expect(tokenSource.fetch(EXAMPLE_FETCH_OPTIONS)).rejects.toThrow(/received 403/);
|
|
128
|
+
} finally {
|
|
129
|
+
teardown();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('merges custom headers from EndpointOptions', async () => {
|
|
134
|
+
const { teardown, fetchMock } = mockGlobalFetchResponse();
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint', {
|
|
138
|
+
headers: { Authorization: 'Bearer my-token', 'X-Custom': 'value' },
|
|
139
|
+
});
|
|
140
|
+
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
|
|
141
|
+
expect((fetchMock.lastCall![1] as RequestInit).headers).toStrictEqual({
|
|
142
|
+
'Content-Type': 'application/json',
|
|
143
|
+
Authorization: 'Bearer my-token',
|
|
144
|
+
'X-Custom': 'value',
|
|
145
|
+
});
|
|
146
|
+
} finally {
|
|
147
|
+
teardown();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('sends only provided fields in request body', async () => {
|
|
152
|
+
const { teardown, fetchMock } = mockGlobalFetchResponse();
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint');
|
|
156
|
+
await tokenSource.fetch({ roomName: 'my-room' });
|
|
157
|
+
const body = JSON.parse((fetchMock.lastCall![1] as RequestInit).body as string);
|
|
158
|
+
expect(body.room_name).toStrictEqual('my-room');
|
|
159
|
+
// Agent-related fields should not be present since they weren't provided
|
|
160
|
+
expect(body.room_config).toBeUndefined();
|
|
161
|
+
} finally {
|
|
162
|
+
teardown();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('deserializes response with extra unknown fields without error', async () => {
|
|
167
|
+
const { teardown } = mockGlobalFetchResponse({
|
|
168
|
+
server_url: 'wss://localhost:7000',
|
|
169
|
+
participant_token: TOKENS.VALID,
|
|
170
|
+
some_future_field: 'should be ignored',
|
|
171
|
+
another_unknown: 42,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint');
|
|
176
|
+
const result = await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
|
|
177
|
+
expect(result.serverUrl).toStrictEqual('wss://localhost:7000');
|
|
178
|
+
expect(result.participantToken).toStrictEqual(TOKENS.VALID);
|
|
179
|
+
} finally {
|
|
180
|
+
teardown();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('TokenSource.custom', () => {
|
|
186
|
+
it('calls custom function and resolves result', async () => {
|
|
187
|
+
const customFn = vi.fn().mockResolvedValue(makeResponseObject());
|
|
188
|
+
|
|
189
|
+
const tokenSource = TokenSource.custom(customFn);
|
|
190
|
+
const result = await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
|
|
191
|
+
|
|
192
|
+
expect(customFn).toHaveBeenCalledWith(EXAMPLE_FETCH_OPTIONS);
|
|
193
|
+
expect(result.serverUrl).toStrictEqual('wss://localhost:7000');
|
|
194
|
+
expect(result.participantToken).toStrictEqual(TOKENS.VALID);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('deserializes response with extra unknown fields without error', async () => {
|
|
198
|
+
const customFn = vi.fn().mockResolvedValue({
|
|
199
|
+
...makeResponseObject(),
|
|
200
|
+
someFutureField: 'should be ignored',
|
|
201
|
+
anotherUnknown: 42,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const tokenSource = TokenSource.custom(customFn);
|
|
205
|
+
const result = await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
|
|
206
|
+
|
|
207
|
+
expect(result.serverUrl).toStrictEqual('wss://localhost:7000');
|
|
208
|
+
expect(result.participantToken).toStrictEqual(TOKENS.VALID);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('TokenSourceConfigurable caching behavior (via TokenSource.custom)', () => {
|
|
213
|
+
it('returns cached value on second call with same options', async () => {
|
|
214
|
+
const customFn = vi.fn().mockResolvedValue(makeResponseObject());
|
|
215
|
+
|
|
216
|
+
const tokenSource = TokenSource.custom(customFn);
|
|
217
|
+
const result1 = await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
|
|
218
|
+
const result2 = await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
|
|
219
|
+
|
|
220
|
+
expect(customFn).toHaveBeenCalledTimes(1);
|
|
221
|
+
expect(result1).toStrictEqual(result2);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('refetches when fetch options change', async () => {
|
|
225
|
+
const customFn = vi.fn().mockResolvedValue(makeResponseObject());
|
|
226
|
+
|
|
227
|
+
const tokenSource = TokenSource.custom(customFn);
|
|
228
|
+
await tokenSource.fetch({ roomName: 'room-1' });
|
|
229
|
+
await tokenSource.fetch({ roomName: 'room-2' });
|
|
230
|
+
|
|
231
|
+
expect(customFn).toHaveBeenCalledTimes(2);
|
|
232
|
+
expect(customFn).toHaveBeenNthCalledWith(1, { roomName: 'room-1' });
|
|
233
|
+
expect(customFn).toHaveBeenNthCalledWith(2, { roomName: 'room-2' });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('refetches when force is true even with same options', async () => {
|
|
237
|
+
const customFn = vi.fn().mockResolvedValue(makeResponseObject());
|
|
238
|
+
|
|
239
|
+
const tokenSource = TokenSource.custom(customFn);
|
|
240
|
+
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
|
|
241
|
+
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS, true);
|
|
242
|
+
|
|
243
|
+
expect(customFn).toHaveBeenCalledTimes(2);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('refetches when cached token is expired', async () => {
|
|
247
|
+
const customFn = vi
|
|
248
|
+
.fn()
|
|
249
|
+
.mockResolvedValueOnce(makeResponseObject(TOKENS.EXP_IN_PAST))
|
|
250
|
+
.mockResolvedValueOnce(makeResponseObject(TOKENS.VALID));
|
|
251
|
+
|
|
252
|
+
const tokenSource = TokenSource.custom(customFn);
|
|
253
|
+
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
|
|
254
|
+
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
|
|
255
|
+
|
|
256
|
+
// Should have called twice because the first token was expired
|
|
257
|
+
expect(customFn).toHaveBeenCalledTimes(2);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('caches across multiple calls when token remains valid', async () => {
|
|
261
|
+
const customFn = vi.fn().mockResolvedValue(makeResponseObject());
|
|
262
|
+
|
|
263
|
+
const tokenSource = TokenSource.custom(customFn);
|
|
264
|
+
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
|
|
265
|
+
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
|
|
266
|
+
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
|
|
267
|
+
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
|
|
268
|
+
|
|
269
|
+
expect(customFn).toHaveBeenCalledTimes(1);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('refetches when any single option field changes', async () => {
|
|
273
|
+
const customFn = vi.fn().mockResolvedValue(makeResponseObject());
|
|
274
|
+
const tokenSource = TokenSource.custom(customFn);
|
|
275
|
+
|
|
276
|
+
const baseOptions: TokenSourceFetchOptions = {
|
|
277
|
+
roomName: 'room',
|
|
278
|
+
participantName: 'name',
|
|
279
|
+
participantIdentity: 'identity',
|
|
280
|
+
participantMetadata: 'meta',
|
|
281
|
+
participantAttributes: { key: 'value' },
|
|
282
|
+
agentName: 'agent',
|
|
283
|
+
agentMetadata: 'agent-meta',
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
await tokenSource.fetch(baseOptions);
|
|
287
|
+
expect(customFn).toHaveBeenCalledTimes(1);
|
|
288
|
+
|
|
289
|
+
// Changing participantIdentity should invalidate cache
|
|
290
|
+
await tokenSource.fetch({ ...baseOptions, participantIdentity: 'different-identity' });
|
|
291
|
+
expect(customFn).toHaveBeenCalledTimes(2);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('getCachedResponseJwtPayload returns null before first fetch', () => {
|
|
295
|
+
const tokenSource = TokenSource.custom(async () => makeResponseObject());
|
|
296
|
+
expect(tokenSource.getCachedResponseJwtPayload()).toBeNull();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('getCachedResponseJwtPayload returns decoded payload after fetch', async () => {
|
|
300
|
+
const tokenSource = TokenSource.custom(async () => makeResponseObject());
|
|
301
|
+
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
|
|
302
|
+
|
|
303
|
+
const payload = tokenSource.getCachedResponseJwtPayload();
|
|
304
|
+
expect(payload).not.toBeNull();
|
|
305
|
+
expect(payload!.sub).toStrictEqual('1234567890');
|
|
306
|
+
expect(payload!.roomConfig?.name).toStrictEqual('test room name');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('serializes concurrent fetches via mutex', async () => {
|
|
310
|
+
let concurrentCalls = 0;
|
|
311
|
+
let maxConcurrentCalls = 0;
|
|
312
|
+
|
|
313
|
+
const customFn = vi.fn().mockImplementation(async () => {
|
|
314
|
+
concurrentCalls += 1;
|
|
315
|
+
maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);
|
|
316
|
+
|
|
317
|
+
// Simulate async work
|
|
318
|
+
await sleep(10);
|
|
319
|
+
|
|
320
|
+
concurrentCalls -= 1;
|
|
321
|
+
return makeResponseObject();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const tokenSource = TokenSource.custom(customFn);
|
|
325
|
+
|
|
326
|
+
// Launch concurrent fetches with different options so caching doesn't short-circuit
|
|
327
|
+
await Promise.all([
|
|
328
|
+
tokenSource.fetch({ roomName: 'room-1' }),
|
|
329
|
+
tokenSource.fetch({ roomName: 'room-2' }),
|
|
330
|
+
tokenSource.fetch({ roomName: 'room-3' }),
|
|
331
|
+
]);
|
|
332
|
+
|
|
333
|
+
// The mutex should ensure only one fetch runs at a time
|
|
334
|
+
expect(maxConcurrentCalls).toStrictEqual(1);
|
|
335
|
+
expect(customFn).toHaveBeenCalledTimes(3);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Test JWTs created for test purposes only.
|
|
2
|
+
// None of these actually auth against anything.
|
|
3
|
+
export const TOKENS = {
|
|
4
|
+
// Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
|
|
5
|
+
// Exp date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
|
|
6
|
+
// A dummy roomConfig value is also set, with room_config.name = "test room name", and room_config.agents = [{"agentName": "test agent name","metadata":"test agent metadata"}]
|
|
7
|
+
VALID:
|
|
8
|
+
'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjEwLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MCwicm9vbUNvbmZpZyI6eyJuYW1lIjoidGVzdCByb29tIG5hbWUiLCJlbXB0eVRpbWVvdXQiOjAsImRlcGFydHVyZVRpbWVvdXQiOjAsIm1heFBhcnRpY2lwYW50cyI6MCwibWluUGxheW91dERlbGF5IjowLCJtYXhQbGF5b3V0RGVsYXkiOjAsInN5bmNTdHJlYW1zIjpmYWxzZSwiYWdlbnRzIjpbeyJhZ2VudE5hbWUiOiJ0ZXN0IGFnZW50IG5hbWUiLCJtZXRhZGF0YSI6InRlc3QgYWdlbnQgbWV0YWRhdGEifV0sIm1ldGFkYXRhIjoiIn19.EDetpHG8cSubaApzgWJaQrpCiSy9KDBlfCfVdIydbQ-_CHiNnXOK_f_mCJbTf9A-duT1jmvPOkLrkkWFT60XPQ',
|
|
9
|
+
|
|
10
|
+
// Nbf date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
|
|
11
|
+
// Exp date set at 9876543211 seconds (Fri Dec 22 2282 20:13:31 GMT+0000)
|
|
12
|
+
NBF_IN_FUTURE:
|
|
13
|
+
'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjExLCJuYmYiOjk4NzY1NDMyMTAsImlhdCI6MTIzNDU2Nzg5MH0.DcMmdKrD76eJg7IUBZqoTRDvBaXtCcwtuE5h7IwVXhG_6nvgxN_ix30_AmLgnYhvhkN-x9dTRPoHg-CME72AbQ',
|
|
14
|
+
|
|
15
|
+
// Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
|
|
16
|
+
// Exp date set at 1234567891 seconds (Fri Feb 13 2009 23:31:31 GMT+0000)
|
|
17
|
+
EXP_IN_PAST:
|
|
18
|
+
'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoxMjM0NTY3ODkxLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MH0.OYP1NITayotBYt0mioInLJmaIM0bHyyR-yG6iwKyQDzhoGha15qbsc7dOJlzz4za1iW5EzCgjc2_xGxqaSu5XA',
|
|
19
|
+
|
|
20
|
+
// This token contains extra fields embedded within which aren't part of the protobuf data
|
|
21
|
+
// structure. These could be new fields added in a future protocol version, etc.
|
|
22
|
+
//
|
|
23
|
+
// Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
|
|
24
|
+
// Exp date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
|
|
25
|
+
// A dummy roomConfig value is also set, with room_config.name = "test room name", room_config.extraField = "extra field value", and room_config.agents = [{"agentName": "test agent name","metadata":"test agent metadata","extraField":"extra field value"}]
|
|
26
|
+
EXTRA_FIELDS:
|
|
27
|
+
'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjEwLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MCwicm9vbUNvbmZpZyI6eyJuYW1lIjoidGVzdCByb29tIG5hbWUiLCJlbXB0eVRpbWVvdXQiOjAsImRlcGFydHVyZVRpbWVvdXQiOjAsIm1heFBhcnRpY2lwYW50cyI6MCwibWluUGxheW91dERlbGF5IjowLCJtYXhQbGF5b3V0RGVsYXkiOjAsInN5bmNTdHJlYW1zIjpmYWxzZSwiYWdlbnRzIjpbeyJhZ2VudE5hbWUiOiJ0ZXN0IGFnZW50IG5hbWUiLCJtZXRhZGF0YSI6InRlc3QgYWdlbnQgbWV0YWRhdGEiLCJleHRyYUZpZWxkIjoiZXh0cmEgZmllbGQgdmFsdWUifV0sIm1ldGFkYXRhIjoiIiwiZXh0cmFGaWVsZCI6ImV4dHJhIGZpZWxkIHZhbHVlIn19Cg.EDetpHG8cSubaApzgWJaQrpCiSy9KDBlfCfVdIydbQ-_CHiNnXOK_f_mCJbTf9A-duT1jmvPOkLrkkWFT60XPQ',
|
|
28
|
+
};
|
|
@@ -1,27 +1,8 @@
|
|
|
1
1
|
import { TokenSourceResponse } from '@livekit/protocol';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { TOKENS } from './test-tokens';
|
|
3
4
|
import { areTokenSourceFetchOptionsEqual, decodeTokenPayload, isResponseTokenValid } from './utils';
|
|
4
5
|
|
|
5
|
-
// Test JWTs created for test purposes only.
|
|
6
|
-
// None of these actually auth against anything.
|
|
7
|
-
const TOKENS = {
|
|
8
|
-
// Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
|
|
9
|
-
// Exp date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
|
|
10
|
-
// A dummy roomConfig value is also set, with room_config.name = "test room name", and room_config.agents = [{"agentName": "test agent name","metadata":"test agent metadata"}]
|
|
11
|
-
VALID:
|
|
12
|
-
'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjEwLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MCwicm9vbUNvbmZpZyI6eyJuYW1lIjoidGVzdCByb29tIG5hbWUiLCJlbXB0eVRpbWVvdXQiOjAsImRlcGFydHVyZVRpbWVvdXQiOjAsIm1heFBhcnRpY2lwYW50cyI6MCwibWluUGxheW91dERlbGF5IjowLCJtYXhQbGF5b3V0RGVsYXkiOjAsInN5bmNTdHJlYW1zIjpmYWxzZSwiYWdlbnRzIjpbeyJhZ2VudE5hbWUiOiJ0ZXN0IGFnZW50IG5hbWUiLCJtZXRhZGF0YSI6InRlc3QgYWdlbnQgbWV0YWRhdGEifV0sIm1ldGFkYXRhIjoiIn19.EDetpHG8cSubaApzgWJaQrpCiSy9KDBlfCfVdIydbQ-_CHiNnXOK_f_mCJbTf9A-duT1jmvPOkLrkkWFT60XPQ',
|
|
13
|
-
|
|
14
|
-
// Nbf date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
|
|
15
|
-
// Exp date set at 9876543211 seconds (Fri Dec 22 2282 20:13:31 GMT+0000)
|
|
16
|
-
NBF_IN_FUTURE:
|
|
17
|
-
'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjExLCJuYmYiOjk4NzY1NDMyMTAsImlhdCI6MTIzNDU2Nzg5MH0.DcMmdKrD76eJg7IUBZqoTRDvBaXtCcwtuE5h7IwVXhG_6nvgxN_ix30_AmLgnYhvhkN-x9dTRPoHg-CME72AbQ',
|
|
18
|
-
|
|
19
|
-
// Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
|
|
20
|
-
// Exp date set at 1234567891 seconds (Fri Feb 13 2009 23:31:31 GMT+0000)
|
|
21
|
-
EXP_IN_PAST:
|
|
22
|
-
'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoxMjM0NTY3ODkxLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MH0.OYP1NITayotBYt0mioInLJmaIM0bHyyR-yG6iwKyQDzhoGha15qbsc7dOJlzz4za1iW5EzCgjc2_xGxqaSu5XA',
|
|
23
|
-
};
|
|
24
|
-
|
|
25
6
|
describe('isResponseTokenValid', () => {
|
|
26
7
|
it('should find a valid jwt not expired', () => {
|
|
27
8
|
const isValid = isResponseTokenValid(
|
|
@@ -60,6 +41,17 @@ describe('decodeTokenPayload', () => {
|
|
|
60
41
|
expect(payload.roomConfig?.agents![0].agentName).toBe('test agent name');
|
|
61
42
|
expect(payload.roomConfig?.agents![0].metadata).toBe('test agent metadata');
|
|
62
43
|
});
|
|
44
|
+
it('should extract roomconfig metadata from a token with extra fields', () => {
|
|
45
|
+
const payload = decodeTokenPayload(TOKENS.EXTRA_FIELDS);
|
|
46
|
+
expect(payload.roomConfig?.name).toBe('test room name');
|
|
47
|
+
expect(payload.roomConfig?.agents).toHaveLength(1);
|
|
48
|
+
expect(payload.roomConfig?.agents![0].agentName).toBe('test agent name');
|
|
49
|
+
expect(payload.roomConfig?.agents![0].metadata).toBe('test agent metadata');
|
|
50
|
+
|
|
51
|
+
// Make sure the extra fields aren't in the payload, just the ones in the protobuf
|
|
52
|
+
expect((payload.roomConfig as any)?.extraField).toBeUndefined();
|
|
53
|
+
expect((payload.roomConfig?.agents![0] as any)?.extraField).toBeUndefined();
|
|
54
|
+
});
|
|
63
55
|
});
|
|
64
56
|
|
|
65
57
|
describe('areTokenSourceFetchOptionsEqual', () => {
|
|
@@ -322,10 +322,22 @@ export default abstract class LocalTrack<
|
|
|
322
322
|
if (stopProcessor && this.processor) {
|
|
323
323
|
await this.internalStopProcessor();
|
|
324
324
|
}
|
|
325
|
-
return this;
|
|
326
325
|
} finally {
|
|
327
326
|
unlock();
|
|
328
327
|
}
|
|
328
|
+
await this.onSenderTrackSwapped();
|
|
329
|
+
return this;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Hook invoked after the MediaStreamTrack on the sender has been swapped
|
|
334
|
+
* (via replaceTrack, setProcessor, or stopProcessor). Fires outside the
|
|
335
|
+
* trackChangeLock so subclasses can do asynchronous work such as polling
|
|
336
|
+
* for new dimensions without blocking other track operations.
|
|
337
|
+
*/
|
|
338
|
+
protected async onSenderTrackSwapped(): Promise<void> {
|
|
339
|
+
// base implementation is a no-op; LocalVideoTrack overrides this to
|
|
340
|
+
// recompute sender encoding parameters.
|
|
329
341
|
}
|
|
330
342
|
|
|
331
343
|
protected async restart(constraints?: MediaTrackConstraints, isUnmuting?: boolean) {
|
|
@@ -589,6 +601,7 @@ export default abstract class LocalTrack<
|
|
|
589
601
|
} finally {
|
|
590
602
|
unlock();
|
|
591
603
|
}
|
|
604
|
+
await this.onSenderTrackSwapped();
|
|
592
605
|
}
|
|
593
606
|
|
|
594
607
|
getProcessor() {
|
|
@@ -607,6 +620,7 @@ export default abstract class LocalTrack<
|
|
|
607
620
|
} finally {
|
|
608
621
|
unlock();
|
|
609
622
|
}
|
|
623
|
+
await this.onSenderTrackSwapped();
|
|
610
624
|
}
|
|
611
625
|
|
|
612
626
|
/**
|