livekit-client 2.20.2 → 2.22.0
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 +10 -10
- 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 +6 -1
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +2588 -1056
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.fm.worker.js +1 -1
- package/dist/livekit-client.fm.worker.js.map +1 -1
- package/dist/livekit-client.fm.worker.mjs +6 -1
- package/dist/livekit-client.fm.worker.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/room/PCTransport.d.ts +49 -2
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +48 -65
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +6 -0
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/data-channel/DataChannelManager.d.ts +62 -0
- package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -0
- package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
- package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -0
- package/dist/src/room/data-channel/LossyDataChannel.d.ts +40 -0
- package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -0
- package/dist/src/room/data-channel/ReliableDataChannel.d.ts +56 -0
- package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -0
- package/dist/src/room/data-channel/types.d.ts +12 -0
- package/dist/src/room/data-channel/types.d.ts.map +1 -0
- package/dist/src/room/data-stream/compression.d.ts +49 -0
- package/dist/src/room/data-stream/compression.d.ts.map +1 -0
- package/dist/src/room/data-stream/constants.d.ts +18 -0
- package/dist/src/room/data-stream/constants.d.ts.map +1 -0
- package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
- package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
- package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
- package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
- package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts.map +1 -1
- package/dist/src/room/data-stream/outgoing/header-utils.d.ts +18 -0
- package/dist/src/room/data-stream/outgoing/header-utils.d.ts.map +1 -0
- package/dist/src/room/errors.d.ts +3 -1
- package/dist/src/room/errors.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts +10 -2
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/RemoteParticipant.d.ts +11 -2
- package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
- package/dist/src/room/rpc/client/RpcClientManager.d.ts.map +1 -1
- package/dist/src/room/rpc/server/RpcServerManager.d.ts.map +1 -1
- package/dist/src/room/token-source/TokenSource.d.ts +19 -10
- package/dist/src/room/token-source/TokenSource.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/track/RemoteTrack.d.ts +3 -0
- package/dist/src/room/track/RemoteTrack.d.ts.map +1 -1
- package/dist/src/room/track/Track.d.ts.map +1 -1
- package/dist/src/room/types.d.ts +17 -0
- package/dist/src/room/types.d.ts.map +1 -1
- package/dist/src/room/utils.d.ts +28 -0
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/src/test/promiseState.d.ts +12 -0
- package/dist/src/test/promiseState.d.ts.map +1 -0
- package/dist/src/test/signalToken.d.ts.map +1 -1
- package/dist/src/utils/AsyncQueue.d.ts +3 -3
- package/dist/src/utils/AsyncQueue.d.ts.map +1 -1
- package/dist/src/version.d.ts +5 -1
- package/dist/src/version.d.ts.map +1 -1
- package/dist/ts4.2/index.d.ts +1 -1
- package/dist/ts4.2/room/PCTransport.d.ts +49 -2
- package/dist/ts4.2/room/RTCEngine.d.ts +48 -65
- package/dist/ts4.2/room/Room.d.ts +6 -0
- package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +62 -0
- package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
- package/dist/ts4.2/room/data-channel/LossyDataChannel.d.ts +41 -0
- package/dist/ts4.2/room/data-channel/ReliableDataChannel.d.ts +57 -0
- package/dist/ts4.2/room/data-channel/types.d.ts +12 -0
- package/dist/ts4.2/room/data-stream/compression.d.ts +49 -0
- package/dist/ts4.2/room/data-stream/constants.d.ts +18 -0
- package/dist/ts4.2/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
- package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
- package/dist/ts4.2/room/data-stream/outgoing/header-utils.d.ts +18 -0
- package/dist/ts4.2/room/errors.d.ts +3 -1
- package/dist/ts4.2/room/participant/LocalParticipant.d.ts +10 -2
- package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +11 -1
- package/dist/ts4.2/room/token-source/TokenSource.d.ts +17 -8
- package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
- package/dist/ts4.2/room/track/RemoteTrack.d.ts +3 -0
- package/dist/ts4.2/room/types.d.ts +17 -0
- package/dist/ts4.2/room/utils.d.ts +28 -0
- package/dist/ts4.2/test/promiseState.d.ts +12 -0
- package/dist/ts4.2/utils/AsyncQueue.d.ts +3 -3
- package/dist/ts4.2/version.d.ts +5 -1
- package/package.json +14 -13
- package/src/api/SignalClient.e2e.test.ts +16 -9
- package/src/e2ee/utils.ts +1 -1
- package/src/index.ts +2 -0
- package/src/room/PCTransport.test.ts +243 -1
- package/src/room/PCTransport.ts +181 -80
- package/src/room/RTCEngine.test.ts +397 -101
- package/src/room/RTCEngine.ts +244 -545
- package/src/room/Room.test.ts +138 -4
- package/src/room/Room.ts +92 -25
- package/src/room/data-channel/DataChannelManager.test.ts +152 -0
- package/src/room/data-channel/DataChannelManager.ts +237 -0
- package/src/room/data-channel/FlowControlledDataChannel.test.ts +154 -0
- package/src/room/data-channel/FlowControlledDataChannel.ts +216 -0
- package/src/room/data-channel/LossyDataChannel.test.ts +118 -0
- package/src/room/data-channel/LossyDataChannel.ts +125 -0
- package/src/room/data-channel/ReliableDataChannel.test.ts +158 -0
- package/src/room/data-channel/ReliableDataChannel.ts +153 -0
- package/src/room/data-channel/types.ts +30 -0
- package/src/room/data-stream/compression.ts +117 -0
- package/src/room/data-stream/constants.ts +18 -0
- package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +1601 -0
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +438 -86
- package/src/room/data-stream/incoming/StreamReader.ts +9 -3
- package/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts +1292 -0
- package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +489 -116
- package/src/room/data-stream/outgoing/header-utils.ts +87 -0
- package/src/room/errors.ts +7 -2
- package/src/room/participant/LocalParticipant.ts +13 -0
- package/src/room/participant/RemoteParticipant.ts +19 -5
- package/src/room/rpc/client/RpcClientManager.test.ts +20 -27
- package/src/room/rpc/client/RpcClientManager.ts +1 -4
- package/src/room/rpc/server/RpcServerManager.test.ts +24 -23
- package/src/room/rpc/server/RpcServerManager.ts +1 -3
- package/src/room/token-source/TokenSource.ts +25 -12
- package/src/room/track/LocalVideoTrack.test.ts +105 -2
- package/src/room/track/LocalVideoTrack.ts +36 -10
- package/src/room/track/RemoteTrack.test.ts +144 -0
- package/src/room/track/RemoteTrack.ts +39 -12
- package/src/room/track/Track.ts +2 -1
- package/src/room/types.ts +18 -0
- package/src/room/utils.test.ts +71 -2
- package/src/room/utils.ts +119 -0
- package/src/test/promiseState.ts +23 -0
- package/src/test/signalServerSetup.ts +2 -1
- package/src/test/signalToken.ts +17 -13
- package/src/type-polyfills/header-extensions.d.ts +13 -0
- package/src/utils/AsyncQueue.ts +3 -3
- package/src/version.ts +5 -1
|
@@ -406,15 +406,37 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
|
|
|
406
406
|
|
|
407
407
|
async setDegradationPreference(preference: RTCDegradationPreference) {
|
|
408
408
|
this.degradationPreference = preference;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
409
|
+
// applied one sender at a time on purpose, see applyDegradationPreference
|
|
410
|
+
await this.applyDegradationPreference(this.sender);
|
|
411
|
+
for (const sc of this.simulcastCodecs.values()) {
|
|
412
|
+
await this.applyDegradationPreference(sc.sender);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Degradation preference is a property of the sender, not of the track, so every sender
|
|
418
|
+
* publishing this track needs it applied separately. A backup codec publishes over its
|
|
419
|
+
* own sender, which would otherwise let the browser resolve a preference implicitly and
|
|
420
|
+
* diverge from the primary encoder.
|
|
421
|
+
*
|
|
422
|
+
* Callers apply this sequentially rather than concurrently: `setParameters` is only valid
|
|
423
|
+
* against the parameters most recently returned by `getParameters`, which is why this file
|
|
424
|
+
* serializes other sender parameter updates through `senderLock`.
|
|
425
|
+
*/
|
|
426
|
+
private async applyDegradationPreference(sender?: RTCRtpSender) {
|
|
427
|
+
if (!sender) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
this.log.debug(
|
|
432
|
+
`setting degradationPreference to ${this.degradationPreference}`,
|
|
433
|
+
this.logContext,
|
|
434
|
+
);
|
|
435
|
+
const params = sender.getParameters();
|
|
436
|
+
params.degradationPreference = this.degradationPreference;
|
|
437
|
+
await sender.setParameters(params);
|
|
438
|
+
} catch (e: any) {
|
|
439
|
+
this.log.warn(`failed to set degradationPreference`, { error: e, ...this.logContext });
|
|
418
440
|
}
|
|
419
441
|
}
|
|
420
442
|
|
|
@@ -436,13 +458,17 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
|
|
|
436
458
|
return simulcastCodecInfo;
|
|
437
459
|
}
|
|
438
460
|
|
|
439
|
-
setSimulcastTrackSender(codec: VideoCodec, sender: RTCRtpSender) {
|
|
461
|
+
async setSimulcastTrackSender(codec: VideoCodec, sender: RTCRtpSender) {
|
|
440
462
|
const simulcastCodecInfo = this.simulcastCodecs.get(codec);
|
|
441
463
|
if (!simulcastCodecInfo) {
|
|
442
464
|
return;
|
|
443
465
|
}
|
|
444
466
|
simulcastCodecInfo.sender = sender;
|
|
445
467
|
|
|
468
|
+
// the backup codec publishes over its own sender, so it needs the same degradation
|
|
469
|
+
// preference the primary sender resolved to.
|
|
470
|
+
await this.applyDegradationPreference(sender);
|
|
471
|
+
|
|
446
472
|
// browser will reenable disabled codec/layers after new codec has been published,
|
|
447
473
|
// so refresh subscribedCodecs after publish a new codec
|
|
448
474
|
setTimeout(() => {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import MockMediaStreamTrack from '../../test/MockMediaStreamTrack';
|
|
3
|
+
import { TrackEvent } from '../events';
|
|
4
|
+
import RemoteVideoTrack from './RemoteVideoTrack';
|
|
5
|
+
|
|
6
|
+
describe('RemoteTrack time sync loop', () => {
|
|
7
|
+
let track: RemoteVideoTrack;
|
|
8
|
+
let getSynchronizationSources: ReturnType<typeof vi.fn>;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
// fake timers back `requestAnimationFrame`, so cancellation behaves like it does in a browser
|
|
12
|
+
vi.useFakeTimers();
|
|
13
|
+
// `supportsSynchronizationSources()` gates the loop on this being implemented
|
|
14
|
+
vi.stubGlobal(
|
|
15
|
+
'RTCRtpReceiver',
|
|
16
|
+
class {
|
|
17
|
+
getSynchronizationSources() {}
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
let rtpTimestamp = 0;
|
|
22
|
+
getSynchronizationSources = vi.fn(() => [{ timestamp: 1, rtpTimestamp: ++rtpTimestamp }]);
|
|
23
|
+
const receiver = { getSynchronizationSources } as unknown as RTCRtpReceiver;
|
|
24
|
+
track = new RemoteVideoTrack(new MockMediaStreamTrack(), 'sid', receiver, {});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.unstubAllGlobals();
|
|
29
|
+
vi.useRealTimers();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('does not sample the receiver when nobody listens', () => {
|
|
33
|
+
track.startMonitor();
|
|
34
|
+
vi.advanceTimersToNextFrame();
|
|
35
|
+
|
|
36
|
+
expect(getSynchronizationSources).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('starts the loop when a listener subscribes', () => {
|
|
40
|
+
track.startMonitor();
|
|
41
|
+
const listener = vi.fn();
|
|
42
|
+
track.on(TrackEvent.TimeSyncUpdate, listener);
|
|
43
|
+
vi.advanceTimersToNextFrame();
|
|
44
|
+
|
|
45
|
+
expect(listener).toHaveBeenCalledWith({ timestamp: 1, rtpTimestamp: 1 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('starts the loop for listeners that subscribed before the monitor started', () => {
|
|
49
|
+
const listener = vi.fn();
|
|
50
|
+
track.on(TrackEvent.TimeSyncUpdate, listener);
|
|
51
|
+
track.startMonitor();
|
|
52
|
+
vi.advanceTimersToNextFrame();
|
|
53
|
+
|
|
54
|
+
expect(listener).toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does not start a second loop when a second listener subscribes', () => {
|
|
58
|
+
track.startMonitor();
|
|
59
|
+
track.on(TrackEvent.TimeSyncUpdate, vi.fn());
|
|
60
|
+
track.on(TrackEvent.TimeSyncUpdate, vi.fn());
|
|
61
|
+
|
|
62
|
+
vi.advanceTimersToNextFrame();
|
|
63
|
+
|
|
64
|
+
// a second chain would sample twice per frame
|
|
65
|
+
expect(getSynchronizationSources).toHaveBeenCalledTimes(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not start a second loop when the monitor is started again', () => {
|
|
69
|
+
track.on(TrackEvent.TimeSyncUpdate, vi.fn());
|
|
70
|
+
track.startMonitor();
|
|
71
|
+
track.startMonitor();
|
|
72
|
+
getSynchronizationSources.mockClear();
|
|
73
|
+
|
|
74
|
+
vi.advanceTimersToNextFrame();
|
|
75
|
+
|
|
76
|
+
expect(getSynchronizationSources).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('stops the loop once the last listener unsubscribes', () => {
|
|
80
|
+
const listener = vi.fn();
|
|
81
|
+
track.startMonitor();
|
|
82
|
+
track.on(TrackEvent.TimeSyncUpdate, listener);
|
|
83
|
+
vi.advanceTimersToNextFrame();
|
|
84
|
+
|
|
85
|
+
track.off(TrackEvent.TimeSyncUpdate, listener);
|
|
86
|
+
vi.advanceTimersToNextFrame();
|
|
87
|
+
getSynchronizationSources.mockClear();
|
|
88
|
+
vi.advanceTimersToNextFrame();
|
|
89
|
+
|
|
90
|
+
expect(getSynchronizationSources).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('restarts the loop when a listener subscribes again', () => {
|
|
94
|
+
const listener = vi.fn();
|
|
95
|
+
track.startMonitor();
|
|
96
|
+
track.on(TrackEvent.TimeSyncUpdate, listener);
|
|
97
|
+
track.off(TrackEvent.TimeSyncUpdate, listener);
|
|
98
|
+
vi.advanceTimersToNextFrame();
|
|
99
|
+
listener.mockClear();
|
|
100
|
+
|
|
101
|
+
track.on(TrackEvent.TimeSyncUpdate, listener);
|
|
102
|
+
vi.advanceTimersToNextFrame();
|
|
103
|
+
|
|
104
|
+
expect(listener).toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('cancels a running loop when the monitor is stopped', () => {
|
|
108
|
+
track.startMonitor();
|
|
109
|
+
// the listener stays subscribed, so only the cancellation can stop the loop
|
|
110
|
+
track.on(TrackEvent.TimeSyncUpdate, vi.fn());
|
|
111
|
+
vi.advanceTimersToNextFrame();
|
|
112
|
+
expect(getSynchronizationSources).toHaveBeenCalled();
|
|
113
|
+
|
|
114
|
+
track.stopMonitor();
|
|
115
|
+
getSynchronizationSources.mockClear();
|
|
116
|
+
vi.advanceTimersToNextFrame();
|
|
117
|
+
vi.advanceTimersToNextFrame();
|
|
118
|
+
|
|
119
|
+
expect(getSynchronizationSources).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('resumes the loop when the monitor is started again after a stop', () => {
|
|
123
|
+
track.startMonitor();
|
|
124
|
+
track.on(TrackEvent.TimeSyncUpdate, vi.fn());
|
|
125
|
+
vi.advanceTimersToNextFrame();
|
|
126
|
+
track.stopMonitor();
|
|
127
|
+
getSynchronizationSources.mockClear();
|
|
128
|
+
|
|
129
|
+
track.startMonitor();
|
|
130
|
+
vi.advanceTimersToNextFrame();
|
|
131
|
+
|
|
132
|
+
expect(getSynchronizationSources).toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('does not restart the loop after the monitor has been stopped', () => {
|
|
136
|
+
track.startMonitor();
|
|
137
|
+
track.stopMonitor();
|
|
138
|
+
|
|
139
|
+
track.on(TrackEvent.TimeSyncUpdate, vi.fn());
|
|
140
|
+
vi.advanceTimersToNextFrame();
|
|
141
|
+
|
|
142
|
+
expect(getSynchronizationSources).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EventEmitter } from 'events';
|
|
1
2
|
import { TrackEvent } from '../events';
|
|
2
3
|
import { monitorFrequency } from '../stats';
|
|
3
4
|
import type { LoggerOptions } from '../types';
|
|
@@ -123,20 +124,46 @@ export default abstract class RemoteTrack<
|
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/* @internal */
|
|
128
|
+
stopMonitor() {
|
|
129
|
+
super.stopMonitor();
|
|
130
|
+
(this as unknown as EventEmitter).off('newListener', this.onTimeSyncListenerAdded);
|
|
131
|
+
}
|
|
132
|
+
|
|
126
133
|
protected abstract monitorReceiver(): void;
|
|
127
134
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
private timeSyncLoop = () => {
|
|
136
|
+
if (this.listenerCount(TrackEvent.TimeSyncUpdate) === 0) {
|
|
137
|
+
// nobody is listening anymore, pause the loop until a new listener subscribes
|
|
138
|
+
this.timeSyncHandle = undefined;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.timeSyncHandle = requestAnimationFrame(this.timeSyncLoop);
|
|
142
|
+
const sources = this.receiver?.getSynchronizationSources()[0];
|
|
143
|
+
if (sources) {
|
|
144
|
+
const { timestamp, rtpTimestamp } = sources;
|
|
145
|
+
if (rtpTimestamp && this.rtpTimestamp !== rtpTimestamp) {
|
|
146
|
+
this.emit(TrackEvent.TimeSyncUpdate, { timestamp, rtpTimestamp });
|
|
147
|
+
this.rtpTimestamp = rtpTimestamp;
|
|
138
148
|
}
|
|
139
|
-
}
|
|
140
|
-
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
private onTimeSyncListenerAdded = (event: string) => {
|
|
153
|
+
if (event === TrackEvent.TimeSyncUpdate && this.timeSyncHandle === undefined) {
|
|
154
|
+
// `newListener` fires before the listener is registered, so schedule the
|
|
155
|
+
// next frame instead of entering the loop (which would see a count of 0)
|
|
156
|
+
this.timeSyncHandle = requestAnimationFrame(this.timeSyncLoop);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
registerTimeSyncUpdate() {
|
|
161
|
+
// `newListener` isn't part of the typed event map, hence the cast
|
|
162
|
+
const emitter = this as unknown as EventEmitter;
|
|
163
|
+
emitter.off('newListener', this.onTimeSyncListenerAdded);
|
|
164
|
+
emitter.on('newListener', this.onTimeSyncListenerAdded);
|
|
165
|
+
if (this.timeSyncHandle === undefined) {
|
|
166
|
+
this.timeSyncLoop();
|
|
167
|
+
}
|
|
141
168
|
}
|
|
142
169
|
}
|
package/src/room/track/Track.ts
CHANGED
|
@@ -276,8 +276,9 @@ export abstract class Track<
|
|
|
276
276
|
if (this.monitorInterval) {
|
|
277
277
|
clearInterval(this.monitorInterval);
|
|
278
278
|
}
|
|
279
|
-
if (this.timeSyncHandle) {
|
|
279
|
+
if (this.timeSyncHandle !== undefined) {
|
|
280
280
|
cancelAnimationFrame(this.timeSyncHandle);
|
|
281
|
+
this.timeSyncHandle = undefined;
|
|
281
282
|
}
|
|
282
283
|
}
|
|
283
284
|
|
package/src/room/types.ts
CHANGED
|
@@ -21,6 +21,21 @@ export interface SendTextOptions {
|
|
|
21
21
|
attachments?: Array<File>;
|
|
22
22
|
onProgress?: (progress: number) => void;
|
|
23
23
|
attributes?: Record<string, string>;
|
|
24
|
+
/** Whether to compress the payload (deflate-raw). Defaults to true. Compression is only applied
|
|
25
|
+
* when every recipient supports data streams v2 and the runtime can compress. */
|
|
26
|
+
compress?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SendBytesOptions {
|
|
30
|
+
topic?: string;
|
|
31
|
+
destinationIdentities?: Array<string>;
|
|
32
|
+
attributes?: Record<string, string>;
|
|
33
|
+
onProgress?: (progress: number) => void;
|
|
34
|
+
/** Whether to compress the payload (deflate-raw). Defaults to true. Compression is only applied
|
|
35
|
+
* when every recipient supports data streams v2 and the runtime can compress. */
|
|
36
|
+
compress?: boolean;
|
|
37
|
+
name?: string;
|
|
38
|
+
mimeType?: string;
|
|
24
39
|
}
|
|
25
40
|
|
|
26
41
|
export interface StreamTextOptions {
|
|
@@ -51,6 +66,9 @@ export type SendFileOptions = Pick<
|
|
|
51
66
|
> & {
|
|
52
67
|
onProgress?: (progress: number) => void;
|
|
53
68
|
encryptionType?: Encryption_Type.NONE;
|
|
69
|
+
/** Whether to compress the payload (deflate-raw). Defaults to true. Compression is only applied
|
|
70
|
+
* when every recipient supports data streams v2 and the runtime can compress. */
|
|
71
|
+
compress?: boolean;
|
|
54
72
|
};
|
|
55
73
|
|
|
56
74
|
export type DataPublishOptions = {
|
package/src/room/utils.test.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { ClientInfo_Capability } from '@livekit/protocol';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import {
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
ddExtensionURI,
|
|
5
|
+
extractMaxAgeFromRequestHeaders,
|
|
6
|
+
getClientInfo,
|
|
7
|
+
negotiateDependencyDescriptor,
|
|
8
|
+
splitUtf8,
|
|
9
|
+
toWebsocketUrl,
|
|
10
|
+
} from './utils';
|
|
4
11
|
|
|
5
12
|
describe('toWebsocketUrl', () => {
|
|
6
13
|
it('leaves wss urls alone', () => {
|
|
@@ -188,3 +195,65 @@ describe('extractMaxAgeFromRequestHeaders', () => {
|
|
|
188
195
|
expect(extractMaxAgeFromRequestHeaders(headers)).toBe(3600);
|
|
189
196
|
});
|
|
190
197
|
});
|
|
198
|
+
|
|
199
|
+
describe('negotiateDependencyDescriptor', () => {
|
|
200
|
+
/** A transceiver whose header extension control offers `extensions`, or none at all. */
|
|
201
|
+
const transceiverWith = (extensions?: RTCRtpHeaderExtensionCapability[], throws = false) => {
|
|
202
|
+
const set = vi.fn((updated: RTCRtpHeaderExtensionCapability[]) => {
|
|
203
|
+
if (throws) {
|
|
204
|
+
throw new Error('InvalidModificationError');
|
|
205
|
+
}
|
|
206
|
+
extensions = updated;
|
|
207
|
+
});
|
|
208
|
+
return {
|
|
209
|
+
transceiver: (extensions
|
|
210
|
+
? {
|
|
211
|
+
getHeaderExtensionsToNegotiate: () => extensions!,
|
|
212
|
+
setHeaderExtensionsToNegotiate: set,
|
|
213
|
+
}
|
|
214
|
+
: {}) as unknown as RTCRtpTransceiver,
|
|
215
|
+
set,
|
|
216
|
+
current: () => extensions,
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
it('turns the extension on for a transceiver that has it stopped', () => {
|
|
221
|
+
const { transceiver, set, current } = transceiverWith([
|
|
222
|
+
{ uri: 'urn:ietf:params:rtp-hdrext:sdes:mid', direction: 'sendrecv' },
|
|
223
|
+
{ uri: ddExtensionURI, direction: 'stopped' },
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
expect(negotiateDependencyDescriptor(transceiver)).toBe(true);
|
|
227
|
+
expect(set).toHaveBeenCalledOnce();
|
|
228
|
+
// sendrecv keeps it a plain a=extmap line, with no direction suffix for the server to parse
|
|
229
|
+
expect(current()?.find((ext) => ext.uri === ddExtensionURI)?.direction).toBe('sendrecv');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('leaves an extension the browser already negotiates alone', () => {
|
|
233
|
+
const { transceiver, set } = transceiverWith([{ uri: ddExtensionURI, direction: 'recvonly' }]);
|
|
234
|
+
|
|
235
|
+
expect(negotiateDependencyDescriptor(transceiver)).toBe(true);
|
|
236
|
+
expect(set).not.toHaveBeenCalled();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('reports no negotiation where the browser does not know the extension', () => {
|
|
240
|
+
const { transceiver, set } = transceiverWith([
|
|
241
|
+
{ uri: 'urn:ietf:params:rtp-hdrext:sdes:mid', direction: 'sendrecv' },
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
expect(negotiateDependencyDescriptor(transceiver)).toBe(false);
|
|
245
|
+
expect(set).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('reports no negotiation where the browser has no such control', () => {
|
|
249
|
+
const { transceiver } = transceiverWith();
|
|
250
|
+
|
|
251
|
+
expect(negotiateDependencyDescriptor(transceiver)).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('swallows a rejected direction rather than failing the connection', () => {
|
|
255
|
+
const { transceiver } = transceiverWith([{ uri: ddExtensionURI, direction: 'stopped' }], true);
|
|
256
|
+
|
|
257
|
+
expect(negotiateDependencyDescriptor(transceiver)).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
});
|
package/src/room/utils.ts
CHANGED
|
@@ -146,6 +146,45 @@ export function isSVCCodec(codec?: string): boolean {
|
|
|
146
146
|
return codec === 'av1' || codec === 'vp9';
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Opts `transceiver` into negotiating the AV1 dependency descriptor, reporting whether it will be.
|
|
151
|
+
*
|
|
152
|
+
* Chrome only offers the extension on transceivers that can send, so one we create to receive on
|
|
153
|
+
* never negotiates it — and Chrome 152 stopped decoding AV1 that arrives without it: frames get
|
|
154
|
+
* assembled, none ever decode, and the receiver asks for a keyframe forever. Asking through the
|
|
155
|
+
* transceiver rather than munging the extension into the SDP leaves the browser owning the
|
|
156
|
+
* extension id, which is what keeps that id consistent across the bundle and across
|
|
157
|
+
* renegotiations.
|
|
158
|
+
*
|
|
159
|
+
* A no-op where the browser offers no such control, or does not know the extension at all.
|
|
160
|
+
* @internal
|
|
161
|
+
*/
|
|
162
|
+
export function negotiateDependencyDescriptor(transceiver: RTCRtpTransceiver): boolean {
|
|
163
|
+
const extensions = transceiver.getHeaderExtensionsToNegotiate?.();
|
|
164
|
+
if (!extensions || !transceiver.setHeaderExtensionsToNegotiate) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
const dd = extensions.find((ext) => ext.uri === ddExtensionURI);
|
|
168
|
+
if (!dd) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
if (dd.direction !== 'stopped') {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
// sendrecv rather than recvonly, so that the extension is written as a plain `a=extmap` line —
|
|
175
|
+
// the form the server already emits where it is the one offering — rather than one carrying a
|
|
176
|
+
// `/recvonly` suffix that its parser may not expect
|
|
177
|
+
dd.direction = 'sendrecv';
|
|
178
|
+
try {
|
|
179
|
+
transceiver.setHeaderExtensionsToNegotiate(extensions);
|
|
180
|
+
return true;
|
|
181
|
+
} catch (e) {
|
|
182
|
+
// a rejected direction throws. Negotiating without the extension is what happened before this
|
|
183
|
+
// existed, so it is not worth failing the connection over
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
149
188
|
export function supportsSetSinkId(elm?: HTMLMediaElement): boolean {
|
|
150
189
|
if (!document || isSafariBased()) {
|
|
151
190
|
return false;
|
|
@@ -781,6 +820,73 @@ export function splitUtf8(s: string, n: number): NonSharedUint8Array[] {
|
|
|
781
820
|
return result;
|
|
782
821
|
}
|
|
783
822
|
|
|
823
|
+
/** Wraps a byte array in a `ReadableStream` that yields it as a single chunk and then closes. */
|
|
824
|
+
export function readableFromBytes(bytes: NonSharedUint8Array): ReadableStream<NonSharedUint8Array> {
|
|
825
|
+
return new ReadableStream<NonSharedUint8Array>({
|
|
826
|
+
start(controller) {
|
|
827
|
+
controller.enqueue(bytes);
|
|
828
|
+
controller.close();
|
|
829
|
+
},
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Re-chunks a byte stream into pieces of exactly `chunkSize` bytes (the final piece may be
|
|
835
|
+
* smaller), coalescing or splitting the source's pieces as needed. Memory use is bounded to roughly
|
|
836
|
+
* `chunkSize` plus one source read, so it never buffers the whole stream — used to pack
|
|
837
|
+
* `CompressionStream`/`file.stream()` output into MTU-sized data-stream chunks.
|
|
838
|
+
*/
|
|
839
|
+
export async function* readBytesInChunks(
|
|
840
|
+
source: ReadableStream<NonSharedUint8Array>,
|
|
841
|
+
chunkSize: number,
|
|
842
|
+
): AsyncGenerator<NonSharedUint8Array> {
|
|
843
|
+
const reader = source.getReader();
|
|
844
|
+
let buffer = new Uint8Array(0);
|
|
845
|
+
try {
|
|
846
|
+
while (true) {
|
|
847
|
+
const { done, value } = await reader.read();
|
|
848
|
+
if (done) {
|
|
849
|
+
break;
|
|
850
|
+
}
|
|
851
|
+
if (value.byteLength === 0) {
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
const merged = new Uint8Array(buffer.byteLength + value.byteLength);
|
|
855
|
+
merged.set(buffer);
|
|
856
|
+
merged.set(value, buffer.byteLength);
|
|
857
|
+
buffer = merged;
|
|
858
|
+
while (buffer.byteLength >= chunkSize) {
|
|
859
|
+
yield buffer.slice(0, chunkSize);
|
|
860
|
+
buffer = buffer.slice(chunkSize);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (buffer.byteLength > 0) {
|
|
864
|
+
yield buffer;
|
|
865
|
+
}
|
|
866
|
+
} finally {
|
|
867
|
+
reader.releaseLock();
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** Encodes a byte array as a base64 string (suitable for embedding binary data in a string field). */
|
|
872
|
+
export function encodeBase64(bytes: Uint8Array): string {
|
|
873
|
+
let binary = '';
|
|
874
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
875
|
+
binary += String.fromCharCode(bytes[i]!);
|
|
876
|
+
}
|
|
877
|
+
return btoa(binary);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** Decodes a base64 string (as produced by {@link encodeBase64}) back into a byte array. */
|
|
881
|
+
export function decodeBase64(base64: string): Uint8Array {
|
|
882
|
+
const binary = atob(base64);
|
|
883
|
+
const bytes = new Uint8Array(binary.length);
|
|
884
|
+
for (let i = 0; i < binary.length; i++) {
|
|
885
|
+
bytes[i] = binary.charCodeAt(i);
|
|
886
|
+
}
|
|
887
|
+
return bytes;
|
|
888
|
+
}
|
|
889
|
+
|
|
784
890
|
export function extractMaxAgeFromRequestHeaders(headers: Headers): number | undefined {
|
|
785
891
|
const cacheControl = headers.get('Cache-Control');
|
|
786
892
|
if (cacheControl) {
|
|
@@ -800,3 +906,16 @@ export function isPublisherOfferWithJoinSupported() {
|
|
|
800
906
|
// we have connectivity issue about publisher offer with join on firefox #1919
|
|
801
907
|
return isCompressionStreamSupported() && !isFireFox();
|
|
802
908
|
}
|
|
909
|
+
|
|
910
|
+
export function extractTrackSid(
|
|
911
|
+
mediaTrack: MediaStreamTrack,
|
|
912
|
+
stream: MediaStream,
|
|
913
|
+
): Track.SID | undefined {
|
|
914
|
+
const [, streamId] = unpackStreamId(stream.id);
|
|
915
|
+
if (streamId?.startsWith('TR')) {
|
|
916
|
+
return streamId;
|
|
917
|
+
}
|
|
918
|
+
if (mediaTrack.id.startsWith('TR')) {
|
|
919
|
+
return mediaTrack.id;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helper: resolves `true` if `promise` is still pending after `ms`
|
|
3
|
+
* (default: one macrotask), `false` if it has already settled — regardless of
|
|
4
|
+
* whether it resolved or rejected.
|
|
5
|
+
*
|
|
6
|
+
* Use to assert that something has *not* happened yet (e.g. an onClose callback
|
|
7
|
+
* that must stay silent), optionally within a timeout window. Preferable to
|
|
8
|
+
* `Promise.race([p, Promise.resolve(sentinel)])` when the "not yet" needs to
|
|
9
|
+
* hold for a duration rather than just the current microtask.
|
|
10
|
+
*/
|
|
11
|
+
export async function isPending(promise: Promise<unknown>, ms = 0): Promise<boolean> {
|
|
12
|
+
const pending = Symbol('pending');
|
|
13
|
+
// Swallow settlement (incl. rejection) so racing it can't raise unhandled
|
|
14
|
+
// rejections when the timer wins.
|
|
15
|
+
const settled = promise.then(
|
|
16
|
+
() => 'settled' as const,
|
|
17
|
+
() => 'settled' as const,
|
|
18
|
+
);
|
|
19
|
+
const timer = new Promise<typeof pending>((resolve) => {
|
|
20
|
+
setTimeout(() => resolve(pending), ms);
|
|
21
|
+
});
|
|
22
|
+
return (await Promise.race([settled, timer])) === pending;
|
|
23
|
+
}
|
|
@@ -2,6 +2,7 @@ import { type ChildProcess, execFileSync, spawn } from 'node:child_process';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { Socket, createServer } from 'node:net';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { sleep } from '../room/utils';
|
|
5
6
|
import { createToken } from './signalToken';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -47,7 +48,7 @@ async function waitReady(serverUrl: string): Promise<boolean> {
|
|
|
47
48
|
} catch {
|
|
48
49
|
// not up yet
|
|
49
50
|
}
|
|
50
|
-
await
|
|
51
|
+
await sleep(200);
|
|
51
52
|
}
|
|
52
53
|
return false;
|
|
53
54
|
}
|
package/src/test/signalToken.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AccessToken } from 'livekit-server-sdk';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Mint a LiveKit access token for the mock test-server (HS256, dev secret).
|
|
@@ -33,24 +33,28 @@ export async function createToken(opts: TokenOptions = {}): Promise<string> {
|
|
|
33
33
|
secret = 'secret',
|
|
34
34
|
ttlSeconds = 600,
|
|
35
35
|
} = opts;
|
|
36
|
-
const
|
|
37
|
-
const payload: Record<string, unknown> = {
|
|
38
|
-
video: { room, roomJoin: true, canPublish: true, canSubscribe: true, canPublishData: true },
|
|
39
|
-
};
|
|
36
|
+
const attributes: Record<string, string> = {};
|
|
40
37
|
if (signal) {
|
|
41
38
|
const control: Record<string, unknown> = { signal };
|
|
42
39
|
if (leaveAction !== undefined) {
|
|
43
40
|
control.leaveAction = leaveAction;
|
|
44
41
|
}
|
|
45
|
-
|
|
42
|
+
attributes['lk.mock'] = JSON.stringify(control);
|
|
46
43
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
44
|
+
const token = new AccessToken(apiKey, secret, {
|
|
45
|
+
ttl: ttlSeconds,
|
|
46
|
+
identity,
|
|
47
|
+
attributes,
|
|
48
|
+
});
|
|
49
|
+
token.addGrant({
|
|
50
|
+
room,
|
|
51
|
+
roomJoin: true,
|
|
52
|
+
canPublish: true,
|
|
53
|
+
canSubscribe: true,
|
|
54
|
+
canPublishData: true,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return token.toJwt();
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
/** A syntactically-valid token signed with the WRONG secret — for 401 tests. */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-transceiver control over which RTP header extensions get negotiated. Declared as optional
|
|
3
|
+
* because it is not in lib.dom yet and not every browser implements it.
|
|
4
|
+
* https://w3c.github.io/webrtc-extensions/#rtcrtptransceiver-interface-extensions
|
|
5
|
+
*/
|
|
6
|
+
interface RTCRtpTransceiver {
|
|
7
|
+
getHeaderExtensionsToNegotiate?(): RTCRtpHeaderExtensionCapability[];
|
|
8
|
+
setHeaderExtensionsToNegotiate?(extensions: RTCRtpHeaderExtensionCapability[]): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface RTCRtpHeaderExtensionCapability {
|
|
12
|
+
direction?: 'sendrecv' | 'sendonly' | 'recvonly' | 'stopped';
|
|
13
|
+
}
|
package/src/utils/AsyncQueue.ts
CHANGED
package/src/version.ts
CHANGED
|
@@ -8,7 +8,11 @@ export const CLIENT_PROTOCOL_DEFAULT = 0;
|
|
|
8
8
|
/** Replaces RPC v1 protocol with a v2 data streams based one to support unlimited request /
|
|
9
9
|
* response payload length. */
|
|
10
10
|
export const CLIENT_PROTOCOL_DATA_STREAM_RPC = 1;
|
|
11
|
+
/** "Data streams v2": the client knows how to receive a single-packet data stream (a stream whose
|
|
12
|
+
* entire payload is smuggled into the header packet, with no chunk/trailer packets). Senders only
|
|
13
|
+
* use the single-packet optimization when the recipient advertises at least this protocol. */
|
|
14
|
+
export const CLIENT_PROTOCOL_DATA_STREAM_V2 = 2;
|
|
11
15
|
|
|
12
16
|
/** The client protocol version indicates what level of support that the client has for
|
|
13
17
|
* client <-> client api interactions. */
|
|
14
|
-
export const clientProtocol =
|
|
18
|
+
export const clientProtocol = CLIENT_PROTOCOL_DATA_STREAM_V2;
|