livekit-client 2.21.0 → 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 +2 -1
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +480 -150
- 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 +2 -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/room/PCTransport.d.ts +49 -2
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +20 -1
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +2 -0
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/errors.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts +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/utils.d.ts +15 -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/ts4.2/room/PCTransport.d.ts +49 -2
- package/dist/ts4.2/room/RTCEngine.d.ts +20 -1
- package/dist/ts4.2/room/Room.d.ts +2 -0
- package/dist/ts4.2/room/participant/LocalParticipant.d.ts +1 -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/utils.d.ts +15 -0
- package/dist/ts4.2/test/promiseState.d.ts +12 -0
- package/dist/ts4.2/utils/AsyncQueue.d.ts +3 -3
- package/package.json +13 -12
- package/src/api/SignalClient.e2e.test.ts +16 -9
- package/src/e2ee/utils.ts +1 -1
- package/src/room/PCTransport.test.ts +243 -1
- package/src/room/PCTransport.ts +181 -80
- package/src/room/RTCEngine.test.ts +339 -2
- package/src/room/RTCEngine.ts +152 -11
- package/src/room/Room.test.ts +134 -3
- package/src/room/Room.ts +52 -17
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +1 -1
- package/src/room/data-stream/incoming/StreamReader.ts +1 -1
- package/src/room/errors.ts +1 -2
- 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/utils.test.ts +71 -2
- package/src/room/utils.ts +52 -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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AddTrackRequest, ConnectionQualityUpdate, JoinResponse, RegionSettings, StreamStateUpdate, SubscriptionPermissionUpdate, SubscriptionResponse } from '@livekit/protocol';
|
|
2
|
-
import { DataPacket, DataTrackSubscriberHandles, DisconnectReason, Encryption_Type, ParticipantInfo, PublishDataTrackResponse, RequestResponse, Room as RoomModel, RoomMovedResponse, ServerInfo, SpeakerInfo, SubscribedQualityUpdate, TrackInfo, TrackUnpublishedResponse, Transcription, UnpublishDataTrackResponse } from '@livekit/protocol';
|
|
2
|
+
import { DataPacket, DataTrackSubscriberHandles, DisconnectReason, Encryption_Type, ParticipantInfo, PublishDataTrackResponse, ReconnectReason, RequestResponse, Room as RoomModel, RoomMovedResponse, ServerInfo, SpeakerInfo, SubscribedQualityUpdate, TrackInfo, TrackUnpublishedResponse, Transcription, UnpublishDataTrackResponse } from '@livekit/protocol';
|
|
3
3
|
import type TypedEventEmitter from 'typed-emitter';
|
|
4
4
|
import type { SignalOptions } from '../api/SignalClient';
|
|
5
5
|
import { SignalClient } from '../api/SignalClient';
|
|
@@ -80,6 +80,10 @@ export default class RTCEngine extends RTCEngine_base {
|
|
|
80
80
|
private midToTrackId;
|
|
81
81
|
/** used to indicate whether the browser is currently waiting to reconnect */
|
|
82
82
|
private isWaitingForNetworkReconnect;
|
|
83
|
+
/** set while the local participant's connection quality is `LOST`; forces a full reconnect on timeout */
|
|
84
|
+
private lostQualityTimeout?;
|
|
85
|
+
/** timestamp (ms) the primary transport entered `CONNECTING`, used to bound how long we tolerate it */
|
|
86
|
+
private transportConnectingSince?;
|
|
83
87
|
constructor(options: InternalRoomOptions);
|
|
84
88
|
/** @internal */
|
|
85
89
|
get logContext(): {
|
|
@@ -137,6 +141,21 @@ export default class RTCEngine extends RTCEngine_base {
|
|
|
137
141
|
private createSimulcastTransceiverSender;
|
|
138
142
|
private createRTCRtpSender;
|
|
139
143
|
private handleDisconnect;
|
|
144
|
+
/**
|
|
145
|
+
* A sustained local `LOST` while connected and publishing means the server isn't receiving
|
|
146
|
+
* our media, so force a full reconnect; any non-`LOST` value cancels a pending trigger.
|
|
147
|
+
*/
|
|
148
|
+
private handleLocalConnectionQuality;
|
|
149
|
+
private scheduleLostQualityReconnect;
|
|
150
|
+
private clearLostQualityTimeout;
|
|
151
|
+
/** Whether the publisher currently has any sender with a live track. */
|
|
152
|
+
private hasActivePublisherSenders;
|
|
153
|
+
/**
|
|
154
|
+
* Forces a full reconnect while keeping the engine (and its saved credentials) alive. Used by
|
|
155
|
+
* Room's connection-reconcile safety net when the transport silently died but we looked connected.
|
|
156
|
+
* @internal
|
|
157
|
+
*/
|
|
158
|
+
reconnect(reason?: ReconnectReason): void;
|
|
140
159
|
private attemptReconnect;
|
|
141
160
|
private getNextRetryDelay;
|
|
142
161
|
private restartConnection;
|
|
@@ -78,6 +78,7 @@ declare class Room extends Room_base {
|
|
|
78
78
|
private log;
|
|
79
79
|
private bufferedEvents;
|
|
80
80
|
private isResuming;
|
|
81
|
+
private pendingTrackAddedCallbacks;
|
|
81
82
|
/**
|
|
82
83
|
* map to store first point in time when a particular transcription segment was received
|
|
83
84
|
*/
|
|
@@ -226,6 +227,7 @@ declare class Room extends Room_base {
|
|
|
226
227
|
private setupLocalParticipantEvents;
|
|
227
228
|
private recreateEngine;
|
|
228
229
|
private onTrackAdded;
|
|
230
|
+
private cancelPendingTrackAdded;
|
|
229
231
|
private handleLocalTrackSubscribed;
|
|
230
232
|
private emitLocalTrackSubscribed;
|
|
231
233
|
private handleRestarting;
|
|
@@ -222,7 +222,7 @@ export default class LocalParticipant extends Participant {
|
|
|
222
222
|
*
|
|
223
223
|
* @param options.topic Topic identifier used to route the stream to appropriate handlers.
|
|
224
224
|
*/
|
|
225
|
-
streamBytes(options?: StreamBytesOptions): Promise<import("
|
|
225
|
+
streamBytes(options?: StreamBytesOptions): Promise<import("../..").ByteStreamWriter>;
|
|
226
226
|
/**
|
|
227
227
|
* Initiate an RPC call to a remote participant
|
|
228
228
|
* @param params - Parameters for initiating the RPC call, see {@link PerformRpcParams}
|
|
@@ -34,13 +34,18 @@ declare class TokenSourceEndpoint extends TokenSourceCached {
|
|
|
34
34
|
private createRequestFromOptions;
|
|
35
35
|
protected update(options: TokenSourceFetchOptions): Promise<TokenSourceResponse>;
|
|
36
36
|
}
|
|
37
|
-
export type
|
|
37
|
+
export type DevelopmentTokenServerOptions = {
|
|
38
38
|
baseUrl?: string;
|
|
39
39
|
};
|
|
40
|
-
declare class
|
|
41
|
-
constructor(
|
|
40
|
+
declare class TokenSourceDevelopmentTokenServer extends TokenSourceEndpoint {
|
|
41
|
+
constructor(tokenServerId: string, options: DevelopmentTokenServerOptions);
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
/** @deprecated use {@link DevelopmentTokenServerOptions} instead */
|
|
44
|
+
export type SandboxTokenServerOptions = DevelopmentTokenServerOptions;
|
|
45
|
+
/** @deprecated Use {@link TokenSourceDevelopmentTokenServer} instead */
|
|
46
|
+
declare class TokenSourceSandboxTokenServer extends TokenSourceDevelopmentTokenServer {
|
|
47
|
+
}
|
|
48
|
+
export type { TokenSourceLiteral, TokenSourceCustom, TokenSourceEndpoint, TokenSourceSandboxTokenServer, TokenSourceDevelopmentTokenServer };
|
|
44
49
|
export { decodeTokenPayload, areTokenSourceFetchOptionsEqual };
|
|
45
50
|
export declare const TokenSource: {
|
|
46
51
|
/** TokenSource.literal contains a single, literal set of {@link TokenSourceResponseObject}
|
|
@@ -56,18 +61,22 @@ export declare const TokenSource: {
|
|
|
56
61
|
/**
|
|
57
62
|
* TokenSource.endpoint creates a token source that fetches credentials from a given URL using
|
|
58
63
|
* the standard endpoint format:
|
|
59
|
-
* @see https://
|
|
64
|
+
* @see https://docs.livekit.io/frontends/build/authentication/endpoint/
|
|
60
65
|
*/
|
|
61
66
|
endpoint(url: string, options?: EndpointOptions): TokenSourceEndpoint;
|
|
62
67
|
/**
|
|
63
|
-
*
|
|
68
|
+
* @deprecated Use {@link TokenSource.developmentTokenServer} instead
|
|
69
|
+
*/
|
|
70
|
+
sandboxTokenServer(sandboxId: string, options?: SandboxTokenServerOptions): TokenSourceSandboxTokenServer;
|
|
71
|
+
/**
|
|
72
|
+
* TokenSource.developmentTokenServer queries a development token server for credentials,
|
|
64
73
|
* which supports quick prototyping / getting started types of use cases.
|
|
65
74
|
*
|
|
66
75
|
* This token provider is INSECURE and should NOT be used in production.
|
|
67
76
|
*
|
|
68
77
|
* For more info:
|
|
69
|
-
* @see https://
|
|
78
|
+
* @see https://docs.livekit.io/frontends/build/authentication/sandbox-token-server/
|
|
70
79
|
*/
|
|
71
|
-
|
|
80
|
+
developmentTokenServer(tokenServerId: string, options?: DevelopmentTokenServerOptions): TokenSourceDevelopmentTokenServer;
|
|
72
81
|
};
|
|
73
82
|
//# sourceMappingURL=TokenSource.d.ts.map
|
|
@@ -56,8 +56,19 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
|
|
|
56
56
|
private applyEncodingsToSender;
|
|
57
57
|
setProcessor(processor: TrackProcessor<Track.Kind.Video>, showProcessedStreamLocally?: boolean): Promise<void>;
|
|
58
58
|
setDegradationPreference(preference: RTCDegradationPreference): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Degradation preference is a property of the sender, not of the track, so every sender
|
|
61
|
+
* publishing this track needs it applied separately. A backup codec publishes over its
|
|
62
|
+
* own sender, which would otherwise let the browser resolve a preference implicitly and
|
|
63
|
+
* diverge from the primary encoder.
|
|
64
|
+
*
|
|
65
|
+
* Callers apply this sequentially rather than concurrently: `setParameters` is only valid
|
|
66
|
+
* against the parameters most recently returned by `getParameters`, which is why this file
|
|
67
|
+
* serializes other sender parameter updates through `senderLock`.
|
|
68
|
+
*/
|
|
69
|
+
private applyDegradationPreference;
|
|
59
70
|
addSimulcastTrack(codec: VideoCodec, encodings?: RTCRtpEncodingParameters[]): SimulcastTrackInfo | undefined;
|
|
60
|
-
setSimulcastTrackSender(codec: VideoCodec, sender: RTCRtpSender): void
|
|
71
|
+
setSimulcastTrackSender(codec: VideoCodec, sender: RTCRtpSender): Promise<void>;
|
|
61
72
|
/**
|
|
62
73
|
* @internal
|
|
63
74
|
* Sets codecs that should be publishing, returns new codecs that have not yet
|
|
@@ -29,7 +29,10 @@ export default abstract class RemoteTrack<TrackKind extends Track.Kind = Track.K
|
|
|
29
29
|
*/
|
|
30
30
|
getPlayoutDelay(): number;
|
|
31
31
|
startMonitor(): void;
|
|
32
|
+
stopMonitor(): void;
|
|
32
33
|
protected abstract monitorReceiver(): void;
|
|
34
|
+
private timeSyncLoop;
|
|
35
|
+
private onTimeSyncListenerAdded;
|
|
33
36
|
registerTimeSyncUpdate(): void;
|
|
34
37
|
}
|
|
35
38
|
//# sourceMappingURL=RemoteTrack.d.ts.map
|
|
@@ -32,6 +32,20 @@ export declare function supportsAV1(): boolean;
|
|
|
32
32
|
export declare function supportsVP9(): boolean;
|
|
33
33
|
export declare function supportsH265(): boolean;
|
|
34
34
|
export declare function isSVCCodec(codec?: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Opts `transceiver` into negotiating the AV1 dependency descriptor, reporting whether it will be.
|
|
37
|
+
*
|
|
38
|
+
* Chrome only offers the extension on transceivers that can send, so one we create to receive on
|
|
39
|
+
* never negotiates it — and Chrome 152 stopped decoding AV1 that arrives without it: frames get
|
|
40
|
+
* assembled, none ever decode, and the receiver asks for a keyframe forever. Asking through the
|
|
41
|
+
* transceiver rather than munging the extension into the SDP leaves the browser owning the
|
|
42
|
+
* extension id, which is what keeps that id consistent across the bundle and across
|
|
43
|
+
* renegotiations.
|
|
44
|
+
*
|
|
45
|
+
* A no-op where the browser offers no such control, or does not know the extension at all.
|
|
46
|
+
* @internal
|
|
47
|
+
*/
|
|
48
|
+
export declare function negotiateDependencyDescriptor(transceiver: RTCRtpTransceiver): boolean;
|
|
35
49
|
export declare function supportsSetSinkId(elm?: HTMLMediaElement): boolean;
|
|
36
50
|
/**
|
|
37
51
|
* Checks whether or not setting an audio output via {@link Room#setActiveDevice}
|
|
@@ -162,4 +176,5 @@ export declare function decodeBase64(base64: string): Uint8Array;
|
|
|
162
176
|
export declare function extractMaxAgeFromRequestHeaders(headers: Headers): number | undefined;
|
|
163
177
|
export declare function isCompressionStreamSupported(): boolean;
|
|
164
178
|
export declare function isPublisherOfferWithJoinSupported(): boolean;
|
|
179
|
+
export declare function extractTrackSid(mediaTrack: MediaStreamTrack, stream: MediaStream): Track.SID | undefined;
|
|
165
180
|
//# sourceMappingURL=utils.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
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 declare function isPending(promise: Promise<unknown>, ms?: number): Promise<boolean>;
|
|
12
|
+
//# sourceMappingURL=promiseState.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livekit-client",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.22.0",
|
|
4
4
|
"description": "JavaScript/TypeScript client SDK for LiveKit",
|
|
5
5
|
"main": "./dist/livekit-client.umd.js",
|
|
6
6
|
"unpkg": "./dist/livekit-client.umd.js",
|
|
@@ -66,12 +66,12 @@
|
|
|
66
66
|
"@babel/core": "7.29.7",
|
|
67
67
|
"@babel/preset-env": "7.29.7",
|
|
68
68
|
"@bufbuild/protoc-gen-es": "^1.10.0",
|
|
69
|
-
"@changesets/cli": "2.31.
|
|
69
|
+
"@changesets/cli": "2.31.1",
|
|
70
70
|
"@eslint/js": "10.0.1",
|
|
71
|
-
"@livekit/changesets-changelog-github": "^0.0
|
|
71
|
+
"@livekit/changesets-changelog-github": "^0.2.0",
|
|
72
72
|
"@livekit/throws-transformer": "^0.1.3",
|
|
73
|
-
"@rollup/plugin-babel": "7.
|
|
74
|
-
"@rollup/plugin-commonjs": "29.0.
|
|
73
|
+
"@rollup/plugin-babel": "7.1.0",
|
|
74
|
+
"@rollup/plugin-commonjs": "29.0.3",
|
|
75
75
|
"@rollup/plugin-json": "6.1.0",
|
|
76
76
|
"@rollup/plugin-node-resolve": "16.0.3",
|
|
77
77
|
"@rollup/plugin-terser": "^1.0.0",
|
|
@@ -82,12 +82,12 @@
|
|
|
82
82
|
"@types/events": "^3.0.3",
|
|
83
83
|
"@types/sdp-transform": "2.15.0",
|
|
84
84
|
"@types/ua-parser-js": "0.7.39",
|
|
85
|
-
"@typescript-eslint/eslint-plugin": "8.
|
|
86
|
-
"@typescript-eslint/parser": "8.
|
|
85
|
+
"@typescript-eslint/eslint-plugin": "8.64.0",
|
|
86
|
+
"@typescript-eslint/parser": "8.64.0",
|
|
87
87
|
"@vitest/browser": "^4.1.10",
|
|
88
88
|
"@vitest/browser-playwright": "^4.1.10",
|
|
89
89
|
"downlevel-dts": "^0.11.0",
|
|
90
|
-
"eslint": "10.
|
|
90
|
+
"eslint": "10.7.0",
|
|
91
91
|
"eslint-config-airbnb-extended": "^2.3.2",
|
|
92
92
|
"eslint-config-prettier": "10.1.8",
|
|
93
93
|
"eslint-plugin-compat": "^6.0.2",
|
|
@@ -97,19 +97,20 @@
|
|
|
97
97
|
"glob": "^13.0.6",
|
|
98
98
|
"happy-dom": "^20.0.0",
|
|
99
99
|
"jsdom": "^26.1.0",
|
|
100
|
+
"livekit-server-sdk": "^2.17.0",
|
|
100
101
|
"playwright": "^1.61.1",
|
|
101
102
|
"prettier": "^3.4.2",
|
|
102
103
|
"publint": "^0.3.21",
|
|
103
|
-
"rollup": "4.
|
|
104
|
+
"rollup": "4.62.2",
|
|
104
105
|
"rollup-plugin-delete": "^2.1.0",
|
|
105
106
|
"rollup-plugin-typescript2": "0.37.0",
|
|
106
|
-
"size-limit": "^
|
|
107
|
+
"size-limit": "^12.0.0",
|
|
107
108
|
"tsx": "^4.21.0",
|
|
108
|
-
"typedoc": "0.28.
|
|
109
|
+
"typedoc": "0.28.20",
|
|
109
110
|
"typedoc-plugin-no-inherit": "1.6.1",
|
|
110
111
|
"typescript": "5.9.3",
|
|
111
112
|
"typescript-eslint": "^8.47.0",
|
|
112
|
-
"vite": "
|
|
113
|
+
"vite": "8.1.5",
|
|
113
114
|
"vitest": "^4.1.10"
|
|
114
115
|
},
|
|
115
116
|
"scripts": {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type LeaveRequest, LeaveRequest_Action } from '@livekit/protocol';
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, inject, it
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest';
|
|
3
3
|
import { ConnectionErrorReason } from '../room/errors';
|
|
4
|
+
import { sleep } from '../room/utils';
|
|
5
|
+
import { isPending } from '../test/promiseState';
|
|
4
6
|
import { createInvalidToken, createToken } from '../test/signalToken';
|
|
5
7
|
import { SignalClient, SignalConnectionState, type SignalOptions } from './SignalClient';
|
|
6
8
|
|
|
@@ -15,8 +17,6 @@ const defaultOpts = (): SignalOptions => ({
|
|
|
15
17
|
websocketTimeout: 5_000,
|
|
16
18
|
});
|
|
17
19
|
|
|
18
|
-
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
-
|
|
20
20
|
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
|
|
21
21
|
return Promise.race([
|
|
22
22
|
p,
|
|
@@ -70,12 +70,11 @@ describe.skipIf(!!unavailable)('SignalClient e2e', () => {
|
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
it('stays connected past the ping timeout while the server pongs', async () => {
|
|
73
|
-
const onClose = vi.fn();
|
|
74
73
|
await join('happy');
|
|
75
|
-
|
|
74
|
+
const closed = captureClose(client);
|
|
76
75
|
// Server ping timeout is 3s; a healthy pong loop must keep us alive.
|
|
77
76
|
await sleep(4_000);
|
|
78
|
-
expect(
|
|
77
|
+
expect(await isPending(closed)).toBe(true);
|
|
79
78
|
expect(client.currentState).toBe(SignalConnectionState.CONNECTED);
|
|
80
79
|
});
|
|
81
80
|
|
|
@@ -151,15 +150,19 @@ describe.skipIf(!!unavailable)('SignalClient e2e', () => {
|
|
|
151
150
|
(e) => e as Error,
|
|
152
151
|
);
|
|
153
152
|
expect(err).toBeInstanceOf(Error);
|
|
153
|
+
expect((err as { reason?: ConnectionErrorReason }).reason).toBe(
|
|
154
|
+
ConnectionErrorReason.LeaveRequest,
|
|
155
|
+
);
|
|
156
|
+
expect((err as Error).message).toContain('Received leave request');
|
|
154
157
|
});
|
|
155
158
|
|
|
156
159
|
it('closes gracefully via close() without firing onClose', async () => {
|
|
157
|
-
const onClose = vi.fn();
|
|
158
160
|
await join('happy');
|
|
159
|
-
|
|
161
|
+
const closed = captureClose(client);
|
|
160
162
|
await client.close();
|
|
161
163
|
expect(client.isDisconnected).toBe(true);
|
|
162
|
-
|
|
164
|
+
// Give any stray onClose a macrotask to land before asserting it stayed silent.
|
|
165
|
+
expect(await isPending(closed, 100)).toBe(true);
|
|
163
166
|
});
|
|
164
167
|
|
|
165
168
|
it('reconnects (resume) back to CONNECTED', async () => {
|
|
@@ -181,6 +184,10 @@ describe.skipIf(!!unavailable)('SignalClient e2e', () => {
|
|
|
181
184
|
(e) => e as Error,
|
|
182
185
|
);
|
|
183
186
|
expect(err).toBeInstanceOf(Error);
|
|
187
|
+
expect((err as { reason?: ConnectionErrorReason }).reason).toBe(
|
|
188
|
+
ConnectionErrorReason.LeaveRequest,
|
|
189
|
+
);
|
|
190
|
+
expect((err as Error).message).toContain('Received leave request');
|
|
184
191
|
});
|
|
185
192
|
|
|
186
193
|
// --- validate-endpoint classification -------------------------------
|
package/src/e2ee/utils.ts
CHANGED
|
@@ -139,7 +139,7 @@ export function needsRbspUnescaping(frameData: NonSharedUint8Array) {
|
|
|
139
139
|
export function parseRbsp(stream: NonSharedUint8Array): NonSharedUint8Array {
|
|
140
140
|
const dataOut: number[] = [];
|
|
141
141
|
var length = stream.length;
|
|
142
|
-
for (var i = 0; i < stream.length;
|
|
142
|
+
for (var i = 0; i < stream.length;) {
|
|
143
143
|
// Be careful about over/underflow here. byte_length_ - 3 can underflow, and
|
|
144
144
|
// i + 3 can overflow, but byte_length_ - i can't, because i < byte_length_
|
|
145
145
|
// above, and that expression will produce the number of bytes left in
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { type MediaDescription, parse } from 'sdp-transform';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
applyVideoStartBitrate,
|
|
5
|
+
conformBundledCodecFmtp,
|
|
6
|
+
ensureAudioNackAndStereo,
|
|
7
|
+
ensureVideoDDExtension,
|
|
8
|
+
extractStereoAndNackAudioFromOffer,
|
|
9
|
+
fmtpConfigHasParam,
|
|
10
|
+
placeholderMidsFromTransceivers,
|
|
11
|
+
} from './PCTransport';
|
|
12
|
+
import { ddExtensionURI } from './utils';
|
|
4
13
|
|
|
5
14
|
/** Parse the `key[=value]` pairs of an fmtp config into a comparable set. */
|
|
6
15
|
const paramSet = (config: string) => new Set(config.split(';').filter(Boolean));
|
|
@@ -52,6 +61,35 @@ a=recvonly
|
|
|
52
61
|
a=rtpmap:49 H265/90000
|
|
53
62
|
a=fmtp:49 level-id=180;profile-id=1;tier-flag=0;tx-mode=SRST`;
|
|
54
63
|
|
|
64
|
+
describe('video start bitrate', () => {
|
|
65
|
+
it('applies the bitrate only to the section whose msid track ID matches the cid', () => {
|
|
66
|
+
const { media } = parse(`v=0
|
|
67
|
+
o=- 0 0 IN IP4 127.0.0.1
|
|
68
|
+
s=-
|
|
69
|
+
t=0 0
|
|
70
|
+
a=group:BUNDLE 0 1
|
|
71
|
+
m=video 9 UDP/TLS/RTP/SAVPF 96
|
|
72
|
+
c=IN IP4 0.0.0.0
|
|
73
|
+
a=mid:0
|
|
74
|
+
a=sendonly
|
|
75
|
+
a=msid:PA_remote|camera other-track
|
|
76
|
+
a=rtpmap:96 VP8/90000
|
|
77
|
+
m=video 9 UDP/TLS/RTP/SAVPF 96
|
|
78
|
+
c=IN IP4 0.0.0.0
|
|
79
|
+
a=mid:1
|
|
80
|
+
a=sendonly
|
|
81
|
+
a=msid:PA_remote|camera camera-cid
|
|
82
|
+
a=rtpmap:96 VP8/90000`);
|
|
83
|
+
|
|
84
|
+
for (const section of media) {
|
|
85
|
+
applyVideoStartBitrate(section, 'camera-cid', 'VP8', 1_000);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
expect(fmtpOf(media, '0', 96)).toBeUndefined();
|
|
89
|
+
expect(paramSet(fmtpOf(media, '1', 96)!)).toContain('x-google-start-bitrate=900');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
55
93
|
describe('placeholderMidsFromTransceivers', () => {
|
|
56
94
|
const tr = (mid: string | null, track: unknown) =>
|
|
57
95
|
({ mid, sender: { track } }) as unknown as RTCRtpTransceiver;
|
|
@@ -148,3 +186,207 @@ a=fmtp:49 level-id=180;profile-id=1;tier-flag=0;tx-mode=SRST`);
|
|
|
148
186
|
expect(fmtpOf(media, '5', 49)).toBe(fmtpOf(media, '3', 49)); // placeholder conformed
|
|
149
187
|
});
|
|
150
188
|
});
|
|
189
|
+
|
|
190
|
+
describe('fmtpConfigHasParam', () => {
|
|
191
|
+
it('matches a whole `;`-delimited parameter, not a substring', () => {
|
|
192
|
+
// `stereo=1` is a substring of `sprop-stereo=1` — an exact-token match must
|
|
193
|
+
// not treat the latter as declaring the former.
|
|
194
|
+
expect(fmtpConfigHasParam('minptime=10;stereo=1', 'stereo=1')).toBe(true);
|
|
195
|
+
expect(fmtpConfigHasParam('minptime=10;sprop-stereo=1', 'stereo=1')).toBe(false);
|
|
196
|
+
expect(fmtpConfigHasParam('minptime=10;sprop-stereo=1', 'sprop-stereo=1')).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('tolerates surrounding whitespace between parameters', () => {
|
|
200
|
+
expect(fmtpConfigHasParam('minptime=10; stereo=1', 'stereo=1')).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
/** Build a parsed audio media section from its `a=` lines for the munge helpers. */
|
|
205
|
+
const audioSection = (rtpmap: string, fmtp: string, mid = '0') =>
|
|
206
|
+
parse(`v=0
|
|
207
|
+
o=- 0 0 IN IP4 127.0.0.1
|
|
208
|
+
s=-
|
|
209
|
+
t=0 0
|
|
210
|
+
m=audio 9 UDP/TLS/RTP/SAVPF 111
|
|
211
|
+
a=mid:${mid}
|
|
212
|
+
${rtpmap}
|
|
213
|
+
${fmtp}`).media[0];
|
|
214
|
+
|
|
215
|
+
describe('ensureAudioNackAndStereo', () => {
|
|
216
|
+
it('adds stereo=1 even when sprop-stereo=1 is already present', () => {
|
|
217
|
+
const media = audioSection(
|
|
218
|
+
'a=rtpmap:111 opus/48000/2',
|
|
219
|
+
'a=fmtp:111 minptime=10;sprop-stereo=1',
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
ensureAudioNackAndStereo(media as any, ['all'], []);
|
|
223
|
+
|
|
224
|
+
const config = media.fmtp.find((f) => f.payload === 111)!.config;
|
|
225
|
+
expect(paramSet(config)).toContain('stereo=1');
|
|
226
|
+
expect(paramSet(config)).toContain('sprop-stereo=1');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('does not add a duplicate stereo=1 when it is already present', () => {
|
|
230
|
+
const media = audioSection('a=rtpmap:111 opus/48000/2', 'a=fmtp:111 minptime=10;stereo=1');
|
|
231
|
+
|
|
232
|
+
ensureAudioNackAndStereo(media as any, ['all'], []);
|
|
233
|
+
|
|
234
|
+
const config = media.fmtp.find((f) => f.payload === 111)!.config;
|
|
235
|
+
expect(config.match(/(?:^|;)stereo=1(?:;|$)/g)).toHaveLength(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('matches the opus codec case-insensitively (RFC 4855)', () => {
|
|
239
|
+
const media = audioSection('a=rtpmap:111 OPUS/48000/2', 'a=fmtp:111 minptime=10');
|
|
240
|
+
|
|
241
|
+
ensureAudioNackAndStereo(media as any, ['all'], []);
|
|
242
|
+
|
|
243
|
+
expect(paramSet(media.fmtp.find((f) => f.payload === 111)!.config)).toContain('stereo=1');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('extractStereoAndNackAudioFromOffer', () => {
|
|
248
|
+
const offerWith = (rtpmap: string, fmtp: string) => ({
|
|
249
|
+
type: 'offer' as const,
|
|
250
|
+
sdp: `v=0
|
|
251
|
+
o=- 0 0 IN IP4 127.0.0.1
|
|
252
|
+
s=-
|
|
253
|
+
t=0 0
|
|
254
|
+
m=audio 9 UDP/TLS/RTP/SAVPF 111
|
|
255
|
+
a=mid:0
|
|
256
|
+
${rtpmap}
|
|
257
|
+
${fmtp}`,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('detects sprop-stereo=1 as a whole parameter', () => {
|
|
261
|
+
const { stereoMids } = extractStereoAndNackAudioFromOffer(
|
|
262
|
+
offerWith('a=rtpmap:111 opus/48000/2', 'a=fmtp:111 minptime=10;sprop-stereo=1'),
|
|
263
|
+
);
|
|
264
|
+
expect(stereoMids).toEqual(['0']);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('does not treat plain stereo=1 as sprop-stereo=1', () => {
|
|
268
|
+
const { stereoMids } = extractStereoAndNackAudioFromOffer(
|
|
269
|
+
offerWith('a=rtpmap:111 opus/48000/2', 'a=fmtp:111 minptime=10;stereo=1'),
|
|
270
|
+
);
|
|
271
|
+
expect(stereoMids).toEqual([]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('matches the opus codec case-insensitively (RFC 4855)', () => {
|
|
275
|
+
const { stereoMids } = extractStereoAndNackAudioFromOffer(
|
|
276
|
+
offerWith('a=rtpmap:111 OPUS/48000/2', 'a=fmtp:111 minptime=10;sprop-stereo=1'),
|
|
277
|
+
);
|
|
278
|
+
expect(stereoMids).toEqual(['0']);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// A single peer connection bundle as Chrome offers it: the section our camera sends on, the
|
|
283
|
+
// recvonly sections subscribed tracks arrive on, and one that lost AV1 in negotiation.
|
|
284
|
+
const SINGLE_PC_OFFER = `v=0
|
|
285
|
+
o=- 0 0 IN IP4 127.0.0.1
|
|
286
|
+
s=-
|
|
287
|
+
t=0 0
|
|
288
|
+
a=group:BUNDLE 0 1 2
|
|
289
|
+
m=video 9 UDP/TLS/RTP/SAVPF 96 45
|
|
290
|
+
c=IN IP4 0.0.0.0
|
|
291
|
+
a=mid:0
|
|
292
|
+
a=sendonly
|
|
293
|
+
a=msid:s cam
|
|
294
|
+
a=rtpmap:96 VP8/90000
|
|
295
|
+
a=rtpmap:45 AV1/90000
|
|
296
|
+
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
|
|
297
|
+
a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id
|
|
298
|
+
m=video 9 UDP/TLS/RTP/SAVPF 96 45
|
|
299
|
+
c=IN IP4 0.0.0.0
|
|
300
|
+
a=mid:1
|
|
301
|
+
a=recvonly
|
|
302
|
+
a=rtpmap:96 VP8/90000
|
|
303
|
+
a=rtpmap:45 AV1/90000
|
|
304
|
+
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
|
|
305
|
+
m=video 9 UDP/TLS/RTP/SAVPF 96
|
|
306
|
+
c=IN IP4 0.0.0.0
|
|
307
|
+
a=mid:2
|
|
308
|
+
a=recvonly
|
|
309
|
+
a=rtpmap:96 VP8/90000
|
|
310
|
+
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time`;
|
|
311
|
+
|
|
312
|
+
const sectionOf = (media: MediaDescription[], mid: string) =>
|
|
313
|
+
media.find((section) => `${section.mid}` === mid)!;
|
|
314
|
+
|
|
315
|
+
const ddOf = (media: MediaDescription[], mid: string) =>
|
|
316
|
+
sectionOf(media, mid).ext?.find((ext) => ext.uri === ddExtensionURI)?.value;
|
|
317
|
+
|
|
318
|
+
describe('ensureVideoDDExtension', () => {
|
|
319
|
+
it('assigns an id above every extension in the bundle', () => {
|
|
320
|
+
const sdp = parse(SINGLE_PC_OFFER);
|
|
321
|
+
// 11 is the highest in use, across all sections rather than just this one
|
|
322
|
+
expect(ensureVideoDDExtension(sectionOf(sdp.media, '1'), sdp, 0)).toBe(12);
|
|
323
|
+
expect(ddOf(sdp.media, '1')).toBe(12);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('reuses the id already chosen for the connection', () => {
|
|
327
|
+
const sdp = parse(SINGLE_PC_OFFER);
|
|
328
|
+
expect(ensureVideoDDExtension(sectionOf(sdp.media, '1'), sdp, 7)).toBe(7);
|
|
329
|
+
expect(ensureVideoDDExtension(sectionOf(sdp.media, '2'), sdp, 7)).toBe(7);
|
|
330
|
+
expect(ddOf(sdp.media, '1')).toBe(7);
|
|
331
|
+
expect(ddOf(sdp.media, '2')).toBe(7);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('leaves a section that already carries the extension alone, and reports its id', () => {
|
|
335
|
+
const sdp = parse(`${SINGLE_PC_OFFER}
|
|
336
|
+
a=extmap:3 ${ddExtensionURI}`);
|
|
337
|
+
expect(ensureVideoDDExtension(sectionOf(sdp.media, '2'), sdp, 0)).toBe(3);
|
|
338
|
+
expect(sectionOf(sdp.media, '2').ext).toHaveLength(2);
|
|
339
|
+
expect(ddOf(sdp.media, '2')).toBe(3);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('adopts the id the browser already advertises rather than inventing a second one', () => {
|
|
343
|
+
// Chrome maps the extension itself on the section it sends AV1 on, but not on recvonly
|
|
344
|
+
// ones. A bundle has to map the URI to one id, so the send section's id has to win over
|
|
345
|
+
// both a fresh id and the cached one.
|
|
346
|
+
const sdp = parse(SINGLE_PC_OFFER);
|
|
347
|
+
sectionOf(sdp.media, '0').ext!.push({ value: 13, uri: ddExtensionURI });
|
|
348
|
+
|
|
349
|
+
expect(ensureVideoDDExtension(sectionOf(sdp.media, '1'), sdp, 0)).toBe(13);
|
|
350
|
+
expect(ensureVideoDDExtension(sectionOf(sdp.media, '2'), sdp, 7)).toBe(13);
|
|
351
|
+
expect(ddOf(sdp.media, '1')).toBe(13);
|
|
352
|
+
expect(ddOf(sdp.media, '2')).toBe(13);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('steps over the id RFC 8285 reserves', () => {
|
|
356
|
+
const sdp = parse(SINGLE_PC_OFFER);
|
|
357
|
+
sectionOf(sdp.media, '0').ext!.push({ value: 14, uri: 'urn:3gpp:video-orientation' });
|
|
358
|
+
|
|
359
|
+
expect(ensureVideoDDExtension(sectionOf(sdp.media, '1'), sdp, 0)).toBe(16);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('abandons the cached id once something else stands for it', () => {
|
|
363
|
+
// The id was free when it was picked, then the first section we send on brought the fuller
|
|
364
|
+
// extension set along and claimed it. Reusing it anyway is what makes the browser reject
|
|
365
|
+
// the offer with "a BUNDLE group contains a codec collision for header extension id".
|
|
366
|
+
const sdp = parse(SINGLE_PC_OFFER);
|
|
367
|
+
sectionOf(sdp.media, '0').ext!.push({ value: 12, uri: 'urn:3gpp:video-orientation' });
|
|
368
|
+
|
|
369
|
+
expect(ensureVideoDDExtension(sectionOf(sdp.media, '1'), sdp, 12)).toBe(13);
|
|
370
|
+
expect(ddOf(sdp.media, '1')).toBe(13);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('leaves the offer alone when the mapped id is contested', () => {
|
|
374
|
+
// Nothing consistent for the whole bundle is available: the extension is mapped to 12 on one
|
|
375
|
+
// section and 12 means something else on another, and the browser's half of the map is not
|
|
376
|
+
// ours to renumber. Losing AV1 beats an offer that cannot be applied at all.
|
|
377
|
+
const sdp = parse(SINGLE_PC_OFFER);
|
|
378
|
+
sectionOf(sdp.media, '0').ext!.push({ value: 12, uri: 'urn:3gpp:video-orientation' });
|
|
379
|
+
sectionOf(sdp.media, '2').ext!.push({ value: 12, uri: ddExtensionURI });
|
|
380
|
+
|
|
381
|
+
expect(ensureVideoDDExtension(sectionOf(sdp.media, '1'), sdp, 0)).toBe(0);
|
|
382
|
+
expect(ddOf(sdp.media, '1')).toBeUndefined();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('adds the extension to a section that has none', () => {
|
|
386
|
+
const sdp = parse(SINGLE_PC_OFFER);
|
|
387
|
+
const section = sectionOf(sdp.media, '1');
|
|
388
|
+
delete section.ext;
|
|
389
|
+
expect(ensureVideoDDExtension(section, sdp, 0)).toBe(12);
|
|
390
|
+
expect(ddOf(sdp.media, '1')).toBe(12);
|
|
391
|
+
});
|
|
392
|
+
});
|