livekit-client 2.18.4 → 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 +448 -224
- 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 +10 -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 +1 -0
- package/dist/src/room/Room.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/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/ts4.2/room/RTCEngine.d.ts +10 -4
- package/dist/ts4.2/room/Room.d.ts +1 -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/track/LocalTrack.d.ts +7 -0
- package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
- package/package.json +2 -2
- package/src/api/SignalClient.ts +4 -0
- package/src/room/PCTransport.ts +6 -5
- package/src/room/RTCEngine.ts +41 -29
- package/src/room/RegionUrlProvider.ts +7 -0
- package/src/room/Room.ts +21 -3
- package/src/room/events.ts +2 -0
- package/src/room/participant/LocalParticipant.ts +70 -5
- 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
|
@@ -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
|
/**
|
|
@@ -8,14 +8,19 @@ import {
|
|
|
8
8
|
import type { SignalClient } from '../../api/SignalClient';
|
|
9
9
|
import type { StructuredLogger } from '../../logger';
|
|
10
10
|
import { TrackEvent } from '../events';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
ScalabilityMode,
|
|
13
|
+
computeTrackBackupEncodings,
|
|
14
|
+
computeVideoEncodings,
|
|
15
|
+
} from '../participant/publishUtils';
|
|
12
16
|
import type { VideoSenderStats } from '../stats';
|
|
13
17
|
import { computeBitrate, monitorFrequency } from '../stats';
|
|
14
18
|
import type { LoggerOptions } from '../types';
|
|
15
19
|
import { isFireFox, isMobile, isSVCCodec, isWeb } from '../utils';
|
|
16
20
|
import LocalTrack from './LocalTrack';
|
|
17
21
|
import { Track, VideoQuality } from './Track';
|
|
18
|
-
import type { VideoCaptureOptions, VideoCodec } from './options';
|
|
22
|
+
import type { TrackPublishOptions, VideoCaptureOptions, VideoCodec } from './options';
|
|
23
|
+
import { isBackupVideoCodec } from './options';
|
|
19
24
|
import type { TrackProcessor } from './processor/types';
|
|
20
25
|
import { constraintsForOptions } from './utils';
|
|
21
26
|
|
|
@@ -61,6 +66,12 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
|
|
|
61
66
|
|
|
62
67
|
private optimizeForPerformance: boolean = false;
|
|
63
68
|
|
|
69
|
+
/* @internal */
|
|
70
|
+
publishOptions?: TrackPublishOptions;
|
|
71
|
+
|
|
72
|
+
/* @internal */
|
|
73
|
+
lastEncodedDimensions?: Track.Dimensions;
|
|
74
|
+
|
|
64
75
|
get sender(): RTCRtpSender | undefined {
|
|
65
76
|
return this._sender;
|
|
66
77
|
}
|
|
@@ -265,6 +276,119 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
|
|
|
265
276
|
await sc.sender.replaceTrack(sc.mediaStreamTrack);
|
|
266
277
|
}
|
|
267
278
|
}
|
|
279
|
+
|
|
280
|
+
// The new MediaStreamTrack may have different dimensions than the previous one
|
|
281
|
+
// (e.g. switching between cameras with different native resolutions), which would
|
|
282
|
+
// leave the sender's encoding parameters (scaleResolutionDownBy, maxBitrate, etc.)
|
|
283
|
+
// based on the old dimensions. Recompute them so the encoded output matches the
|
|
284
|
+
// new source.
|
|
285
|
+
await this.onSenderTrackSwapped();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
protected override async onSenderTrackSwapped(): Promise<void> {
|
|
289
|
+
await this.refreshSenderEncodings();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Recomputes encoding parameters for this track's senders based on the current
|
|
294
|
+
* MediaStreamTrack dimensions and reapplies them via setParameters. This is a no-op
|
|
295
|
+
* if the track hasn't been published yet or if the track is in performance-optimized
|
|
296
|
+
* mode (which manages its own encodings).
|
|
297
|
+
*/
|
|
298
|
+
private async refreshSenderEncodings() {
|
|
299
|
+
if (!this.sender || !this.publishOptions || this.optimizeForPerformance) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const unlock = await this.senderLock.lock();
|
|
303
|
+
try {
|
|
304
|
+
let dims: Track.Dimensions;
|
|
305
|
+
try {
|
|
306
|
+
dims = await this.waitForDimensions();
|
|
307
|
+
} catch (e) {
|
|
308
|
+
this.log.warn('could not determine new track dimensions, skipping encoding recompute', {
|
|
309
|
+
...this.logContext,
|
|
310
|
+
error: e,
|
|
311
|
+
});
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (
|
|
316
|
+
this.lastEncodedDimensions &&
|
|
317
|
+
this.lastEncodedDimensions.width === dims.width &&
|
|
318
|
+
this.lastEncodedDimensions.height === dims.height
|
|
319
|
+
) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const isScreenShare = this.source === Track.Source.ScreenShare;
|
|
324
|
+
const newEncodings = computeVideoEncodings(isScreenShare, dims.width, dims.height, {
|
|
325
|
+
...this.publishOptions,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
await this.applyEncodingsToSender(this.sender, newEncodings);
|
|
329
|
+
this.encodings = newEncodings;
|
|
330
|
+
this.lastEncodedDimensions = dims;
|
|
331
|
+
|
|
332
|
+
for (const [codec, sc] of this.simulcastCodecs) {
|
|
333
|
+
if (!sc.sender || sc.sender.transport?.state === 'closed') {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (!isBackupVideoCodec(codec)) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const backupOpts: TrackPublishOptions = { ...this.publishOptions };
|
|
340
|
+
const backupEncodings = computeTrackBackupEncodings(this, codec, backupOpts);
|
|
341
|
+
if (!backupEncodings) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
await this.applyEncodingsToSender(sc.sender, backupEncodings);
|
|
345
|
+
sc.encodings = backupEncodings;
|
|
346
|
+
}
|
|
347
|
+
} catch (e) {
|
|
348
|
+
this.log.warn('failed to apply recomputed encodings', {
|
|
349
|
+
...this.logContext,
|
|
350
|
+
error: e,
|
|
351
|
+
});
|
|
352
|
+
} finally {
|
|
353
|
+
unlock();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private async applyEncodingsToSender(
|
|
358
|
+
sender: RTCRtpSender,
|
|
359
|
+
encodings: RTCRtpEncodingParameters[],
|
|
360
|
+
) {
|
|
361
|
+
const params = sender.getParameters();
|
|
362
|
+
if (!params.encodings || params.encodings.length !== encodings.length) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
params.encodings.forEach((existing, idx) => {
|
|
366
|
+
// preserve disabled layers (dynacast / Firefox workaround in
|
|
367
|
+
// setPublishingLayersForSender set scaleResolutionDownBy/maxBitrate to sentinel
|
|
368
|
+
// values for disabled layers — don't clobber those).
|
|
369
|
+
if (existing.active === false) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const next = encodings[idx];
|
|
373
|
+
if (next.scaleResolutionDownBy !== undefined) {
|
|
374
|
+
existing.scaleResolutionDownBy = next.scaleResolutionDownBy;
|
|
375
|
+
}
|
|
376
|
+
if (next.maxBitrate !== undefined) {
|
|
377
|
+
existing.maxBitrate = next.maxBitrate;
|
|
378
|
+
}
|
|
379
|
+
if (next.maxFramerate !== undefined) {
|
|
380
|
+
existing.maxFramerate = next.maxFramerate;
|
|
381
|
+
}
|
|
382
|
+
if (next.priority !== undefined) {
|
|
383
|
+
existing.priority = next.priority;
|
|
384
|
+
existing.networkPriority = next.priority;
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
this.log.debug('updating sender encodings after track restart', {
|
|
388
|
+
...this.logContext,
|
|
389
|
+
encodings: params.encodings,
|
|
390
|
+
});
|
|
391
|
+
await sender.setParameters(params);
|
|
268
392
|
}
|
|
269
393
|
|
|
270
394
|
async setProcessor(
|
|
@@ -384,8 +384,14 @@ class HTMLElementInfo implements ElementInfo {
|
|
|
384
384
|
|
|
385
385
|
private onEnterPiP = () => {
|
|
386
386
|
window.documentPictureInPicture?.window?.addEventListener('pagehide', this.onLeavePiP);
|
|
387
|
-
|
|
388
|
-
|
|
387
|
+
// Document PiP: the browser may fire 'enter' before the app has appended its subtree into
|
|
388
|
+
// documentPictureInPicture.window. Defer so pipWin.document.contains(video) is reliable.
|
|
389
|
+
queueMicrotask(() => {
|
|
390
|
+
requestAnimationFrame(() => {
|
|
391
|
+
this.isPiP = isElementInPiP(this.element);
|
|
392
|
+
this.handleVisibilityChanged?.();
|
|
393
|
+
});
|
|
394
|
+
});
|
|
389
395
|
};
|
|
390
396
|
|
|
391
397
|
private onLeavePiP = () => {
|