livekit-client 2.22.4 → 2.23.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.
Files changed (116) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +69 -7
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +1133 -289
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js.map +1 -1
  8. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  9. package/dist/livekit-client.umd.js +1 -1
  10. package/dist/livekit-client.umd.js.map +1 -1
  11. package/dist/src/api/SignalClient.d.ts.map +1 -1
  12. package/dist/src/api/WebSocketStream.d.ts +1 -0
  13. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  14. package/dist/src/api/utils.d.ts +1 -1
  15. package/dist/src/api/utils.d.ts.map +1 -1
  16. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  17. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  18. package/dist/src/e2ee/worker/naluUtils.d.ts +12 -1
  19. package/dist/src/e2ee/worker/naluUtils.d.ts.map +1 -1
  20. package/dist/src/index.d.ts +2 -2
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/room/DefaultReconnectPolicy.d.ts.map +1 -1
  23. package/dist/src/room/PCTransport.d.ts +26 -4
  24. package/dist/src/room/PCTransport.d.ts.map +1 -1
  25. package/dist/src/room/RTCEngine.d.ts +8 -0
  26. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  27. package/dist/src/room/Room.d.ts +53 -0
  28. package/dist/src/room/Room.d.ts.map +1 -1
  29. package/dist/src/room/data-channel/DataChannelManager.d.ts +9 -8
  30. package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -1
  31. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
  32. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -1
  33. package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -1
  34. package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -1
  35. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
  36. package/dist/src/room/participant/LocalParticipant.d.ts +4 -0
  37. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  38. package/dist/src/room/participant/Participant.d.ts +1 -1
  39. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  40. package/dist/src/room/rpc/utils.d.ts.map +1 -1
  41. package/dist/src/room/track/LocalAudioTrack.d.ts +24 -1
  42. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  43. package/dist/src/room/track/LocalTrack.d.ts +18 -0
  44. package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
  45. package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
  46. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  47. package/dist/src/room/track/RemoteAudioTrack.d.ts +6 -0
  48. package/dist/src/room/track/RemoteAudioTrack.d.ts.map +1 -1
  49. package/dist/src/room/track/processor/types.d.ts +11 -0
  50. package/dist/src/room/track/processor/types.d.ts.map +1 -1
  51. package/dist/src/room/track/utils.d.ts.map +1 -1
  52. package/dist/src/room/utils.d.ts +28 -1
  53. package/dist/src/room/utils.d.ts.map +1 -1
  54. package/dist/ts4.2/api/WebSocketStream.d.ts +1 -0
  55. package/dist/ts4.2/api/utils.d.ts +1 -1
  56. package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +12 -1
  57. package/dist/ts4.2/index.d.ts +2 -2
  58. package/dist/ts4.2/room/PCTransport.d.ts +26 -4
  59. package/dist/ts4.2/room/RTCEngine.d.ts +8 -0
  60. package/dist/ts4.2/room/Room.d.ts +53 -0
  61. package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +9 -8
  62. package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
  63. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +4 -0
  64. package/dist/ts4.2/room/participant/Participant.d.ts +1 -1
  65. package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +24 -1
  66. package/dist/ts4.2/room/track/LocalTrack.d.ts +18 -0
  67. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
  68. package/dist/ts4.2/room/track/RemoteAudioTrack.d.ts +6 -0
  69. package/dist/ts4.2/room/track/processor/types.d.ts +11 -0
  70. package/dist/ts4.2/room/utils.d.ts +28 -1
  71. package/package.json +1 -1
  72. package/src/api/SignalClient.test.ts +234 -0
  73. package/src/api/SignalClient.ts +58 -23
  74. package/src/api/WebSocketStream.test.ts +11 -0
  75. package/src/api/WebSocketStream.ts +22 -1
  76. package/src/api/utils.ts +1 -1
  77. package/src/e2ee/E2eeManager.test.ts +43 -0
  78. package/src/e2ee/E2eeManager.ts +6 -2
  79. package/src/e2ee/worker/FrameCryptor.test.ts +212 -90
  80. package/src/e2ee/worker/FrameCryptor.ts +21 -4
  81. package/src/e2ee/worker/naluUtils.test.ts +123 -0
  82. package/src/e2ee/worker/naluUtils.ts +57 -2
  83. package/src/index.ts +2 -0
  84. package/src/room/DefaultReconnectPolicy.test.ts +58 -0
  85. package/src/room/DefaultReconnectPolicy.ts +14 -2
  86. package/src/room/PCTransport.test.ts +144 -2
  87. package/src/room/PCTransport.ts +151 -9
  88. package/src/room/RTCEngine.test.ts +234 -2
  89. package/src/room/RTCEngine.ts +102 -7
  90. package/src/room/Room.test.ts +116 -0
  91. package/src/room/Room.ts +286 -74
  92. package/src/room/data-channel/DataChannelManager.test.ts +24 -7
  93. package/src/room/data-channel/DataChannelManager.ts +43 -38
  94. package/src/room/data-channel/FlowControlledDataChannel.test.ts +60 -18
  95. package/src/room/data-channel/FlowControlledDataChannel.ts +40 -22
  96. package/src/room/data-channel/LossyDataChannel.test.ts +38 -0
  97. package/src/room/data-channel/LossyDataChannel.ts +10 -1
  98. package/src/room/data-channel/ReliableDataChannel.test.ts +72 -0
  99. package/src/room/data-channel/ReliableDataChannel.ts +16 -5
  100. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +31 -0
  101. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +11 -0
  102. package/src/room/participant/LocalParticipant.test.ts +136 -1
  103. package/src/room/participant/LocalParticipant.ts +66 -17
  104. package/src/room/participant/Participant.ts +6 -3
  105. package/src/room/participant/restartOnDefaultAudioDevice.test.ts +177 -0
  106. package/src/room/rpc/utils.test.ts +32 -0
  107. package/src/room/rpc/utils.ts +7 -0
  108. package/src/room/track/LocalAudioTrack.ts +101 -10
  109. package/src/room/track/LocalTrack.ts +70 -13
  110. package/src/room/track/LocalVideoTrack.ts +7 -0
  111. package/src/room/track/RemoteAudioTrack.ts +56 -11
  112. package/src/room/track/processor/types.ts +11 -0
  113. package/src/room/track/utils.test.ts +5 -0
  114. package/src/room/track/utils.ts +29 -5
  115. package/src/room/utils.test.ts +126 -0
  116. package/src/room/utils.ts +84 -21
@@ -23,8 +23,9 @@ export interface DataChannelManagerOptions {
23
23
  * live for the engine's lifetime and have RTCDataChannel handles attached/detached as peer
24
24
  * connections come and go) plus the subscriber-side receive handles adopted by label.
25
25
  *
26
- * Handle turnover goes through {@link FlowControlledDataChannel.attach}/`detach`, which reject
27
- * parked headroom waiters as a built-in — there is no separate invalidation step to forget.
26
+ * Handle turnover goes through {@link FlowControlledDataChannel.attach}/`detach`, which close the
27
+ * released handle and reject parked headroom waiters as a built-in — there is no separate
28
+ * invalidation step to forget.
28
29
  */
29
30
  export declare class DataChannelManager {
30
31
  readonly reliable: ReliableDataChannel;
@@ -42,8 +43,8 @@ export declare class DataChannelManager {
42
43
  get hasPublisherChannels(): boolean;
43
44
  /**
44
45
  * Creates the three publisher data channels on the given transport, wires their handlers, and
45
- * attaches them to the wrappers — attaching rejects any waiters still parked on replaced
46
- * channel objects.
46
+ * attaches them to the wrappers — attaching closes any replaced channel objects and rejects the
47
+ * waiters still parked on them.
47
48
  */
48
49
  createPublisherChannels(pcManager: PCTransportManager): void;
49
50
  /**
@@ -52,10 +53,10 @@ export declare class DataChannelManager {
52
53
  */
53
54
  adoptSubscriberChannel(channel: RTCDataChannel): boolean;
54
55
  /**
55
- * Tears down all channels for a peer-connection cleanup: rejects parked waiters (detach — the
56
- * spec allows `pc.close()` to transition channels to 'closed' without firing events, so waiting
57
- * for browser close events is not an option), strips handlers, closes the handles, and resets
58
- * the reliable session state.
56
+ * Tears down all channels for a peer-connection cleanup: strips handlers, closes the handles,
57
+ * and resets the reliable session state. Detaching the publisher handles closes them and
58
+ * rejects parked waiters (the spec allows `pc.close()` to transition channels to 'closed'
59
+ * without firing events, so waiting for browser close events is not an option).
59
60
  */
60
61
  teardown(): void;
61
62
  }
@@ -25,7 +25,8 @@ export interface FlowControlledDataChannelOptions {
25
25
  * Waiters are parked on the channel object captured at wait entry. If that object stops being
26
26
  * current — replaced or torn down — its events may never fire again, so the owner must call
27
27
  * {@link invalidateWaiters}, which aborts parked waiters (releasing the gate); the next waiter
28
- * gets a fresh controller.
28
+ * gets a fresh controller. {@link attach} and {@link detach} do this, and close the released
29
+ * handle, as part of handle turnover.
29
30
  */
30
31
  export declare class FlowControlledDataChannel {
31
32
  readonly kind: DataChannelKind;
@@ -42,15 +43,23 @@ export declare class FlowControlledDataChannel {
42
43
  constructor(opts: FlowControlledDataChannelOptions);
43
44
  get channelHandle(): (RTCDataChannel | undefined);
44
45
  /**
45
- * Attaches the channel handle this wrapper controls. Replacing an existing handle rejects
46
- * parked waiters — their events would never fire again on the abandoned object — and installs a
47
- * fresh controller, so queued senders re-check against the new channel. Wrappers outlive their
48
- * handles: this is the one place handle turnover happens, which is what makes stranding a
49
- * waiter structurally impossible.
46
+ * Attaches the channel handle this wrapper controls. Replacing an existing handle closes it and
47
+ * rejects parked waiters — their events would never fire again on the abandoned object — and
48
+ * installs a fresh controller. Wrappers outlive their handles: this is the one place handle
49
+ * turnover happens, so a handle the wrapper lets go of is never left open, and stranding a
50
+ * waiter is structurally impossible.
50
51
  */
51
52
  attach(dc: RTCDataChannel): void;
52
- /** Detaches the handle on teardown, rejecting parked waiters. */
53
+ /** Detaches and closes the handle on teardown, rejecting parked waiters. */
53
54
  detach(reason?: string): void;
55
+ /**
56
+ * Closes a handle the wrapper no longer controls. `close()` moves it to `closing` synchronously,
57
+ * so senders still holding it (queued on the headroom lock) fail the open check once they get
58
+ * the lock. Parked waiters are aborted directly instead of relying on the `close` event, which
59
+ * fires asynchronously, if at all (the spec lets `pc.close()` skip it). Strip any handlers that
60
+ * must not observe the close before calling this.
61
+ */
62
+ private release;
54
63
  protected getChannel(): RTCDataChannel | undefined;
55
64
  /**
56
65
  * Whether the send buffer has room to accept more data (the send gate). Senders proceed while
@@ -70,16 +79,20 @@ export declare class FlowControlledDataChannel {
70
79
  */
71
80
  lockHeadroom(): Promise<() => void>;
72
81
  /**
73
- * Resolves once the caller may send on this channel: immediately while the send buffer is at or
74
- * below its high-water mark, otherwise once the buffer has drained to the low-water mark (the
82
+ * Resolves once the caller may send on `dc`: immediately while its send buffer is at or below
83
+ * the high-water mark, otherwise once the buffer has drained to the low-water mark (the
75
84
  * `bufferedamountlow` event). Callers are serialized through the headroom lock so that, when
76
85
  * the buffer drains, they refill it one at a time (up to the high-water mark) rather than all
77
86
  * sending at once and overflowing the SCTP send buffer (see livekit/client-sdk-js#1995). The
78
- * closed/buffer checks run inside the lock so queued callers proceed in FIFO order.
87
+ * checks run inside the lock so queued callers proceed in FIFO order.
88
+ *
89
+ * The caller passes the handle it resolved before queueing, and the wait is against that one
90
+ * handle throughout: if it was replaced meanwhile, it has been closed, so the wait rejects
91
+ * rather than letting the caller send on the replacement out of turn.
79
92
  */
80
- waitForHeadroomWithLock(): Promise<void>;
93
+ waitForHeadroomWithLock(dc: RTCDataChannel): Promise<void>;
81
94
  /** Core wait of {@link waitForHeadroomWithLock}. The caller must hold the headroom lock. */
82
- waitForHeadroomWithoutLock(): Promise<void>;
95
+ waitForHeadroomWithoutLock(dc: RTCDataChannel): Promise<void>;
83
96
  /** Rejects all parked headroom waiters; the next waiter gets a fresh controller. */
84
97
  invalidateWaiters(reason: string): void;
85
98
  /**
@@ -275,6 +275,10 @@ export default class LocalParticipant extends Participant {
275
275
  private handleSubscribedQualityUpdate;
276
276
  private handleLocalTrackUnpublished;
277
277
  private handleTrackEnded;
278
+ /**
279
+ * @internal
280
+ */
281
+ private restartOnDefaultAudioDevice;
278
282
  private getPublicationForTrack;
279
283
  private waitForPendingPublicationOfSource;
280
284
  /** Publishes a data track.
@@ -106,7 +106,7 @@ export default class Participant extends Participant_base {
106
106
  /**
107
107
  * @internal
108
108
  */
109
- setAudioContext(ctx: AudioContext | undefined): void;
109
+ setAudioContext(ctx: AudioContext | undefined): Promise<void>;
110
110
  protected addTrackPublication(publication: TrackPublication): void;
111
111
  }
112
112
  export type ParticipantEventCallbacks = {
@@ -9,6 +9,11 @@ export default class LocalAudioTrack extends LocalTrack<Track.Kind.Audio> {
9
9
  stopOnMute: boolean;
10
10
  private prevStats?;
11
11
  private isKrispNoiseFilterEnabled;
12
+ /**
13
+ * the processed track we currently have krisp feature listeners registered on. Tracked
14
+ * separately, as `processor.processedTrack` can be swapped out while we hold the listeners.
15
+ */
16
+ private observedProcessedTrack?;
12
17
  protected processor?: TrackProcessor<Track.Kind.Audio, AudioProcessorOptions> | undefined;
13
18
  get enhancedNoiseCancellation(): boolean;
14
19
  /**
@@ -27,12 +32,30 @@ export default class LocalAudioTrack extends LocalTrack<Track.Kind.Audio> {
27
32
  protected monitorSender: () => Promise<void>;
28
33
  private handleKrispNoiseFilterEnable;
29
34
  private handleKrispNoiseFilterDisable;
35
+ /**
36
+ * registers the krisp feature listeners on the given processed track, moving them over from
37
+ * a previously observed one. Passing `undefined` just removes the existing listeners.
38
+ */
39
+ private observeProcessedTrack;
40
+ /**
41
+ * Sets a processor on this track.
42
+ *
43
+ * The processor is destroyed when the track is stopped, and also when the audio context goes
44
+ * away, so a disconnect ends it on both counts and it has to be set again after reconnecting.
45
+ * See {@link TrackProcessor} for the full lifecycle.
46
+ */
30
47
  setProcessor(processor: TrackProcessor<Track.Kind.Audio, AudioProcessorOptions>): Promise<void>;
48
+ protected onMediaStreamTrackChanged(): Promise<void>;
49
+ protected internalStopProcessor(keepElement?: boolean): Promise<void>;
50
+ stop(): void;
31
51
  /**
32
52
  * @internal
33
53
  * @experimental
54
+ *
55
+ * A processor's nodes (`AudioWorkletNode`, ...) can't move between contexts, so the processor
56
+ * is rebuilt whenever the context changes and torn down when there is none left to run in.
34
57
  */
35
- setAudioContext(audioContext: AudioContext | undefined): void;
58
+ setAudioContext(audioContext: AudioContext | undefined): Promise<void>;
36
59
  getSenderStats(): Promise<AudioSenderStats | undefined>;
37
60
  checkForSilence(): Promise<boolean>;
38
61
  }
@@ -41,6 +41,7 @@ export default abstract class LocalTrack<TrackKind extends Track.Kind = Track.Ki
41
41
  private _isUpstreamPaused;
42
42
  get isUpstreamPaused(): boolean;
43
43
  get isUserProvided(): boolean;
44
+ get isStopped(): boolean;
44
45
  get mediaStreamTrack(): MediaStreamTrack;
45
46
  get isLocal(): boolean;
46
47
  /**
@@ -49,6 +50,12 @@ export default abstract class LocalTrack<TrackKind extends Track.Kind = Track.Ki
49
50
  */
50
51
  getSourceTrackSettings(): MediaTrackSettings;
51
52
  private setMediaStreamTrack;
53
+ /**
54
+ * Hook invoked after the underlying MediaStreamTrack has been swapped and the processor has
55
+ * been restarted around it, so `processor.processedTrack` already holds the new track.
56
+ * Unlike `onSenderTrackSwapped` this also fires on the restart and unmute paths.
57
+ */
58
+ protected onMediaStreamTrackChanged(): Promise<void>;
52
59
  waitForDimensions(timeout?: number): Promise<Track.Dimensions>;
53
60
  setDeviceId(deviceId: ConstrainDOMString): Promise<boolean>;
54
61
  abstract restartTrack(constraints?: unknown): Promise<void>;
@@ -95,16 +102,27 @@ export default abstract class LocalTrack<TrackKind extends Track.Kind = Track.Ki
95
102
  * Sets a processor on this track.
96
103
  * See https://github.com/livekit/track-processors-js for example usage
97
104
  *
105
+ * The processor lives on the track, so stopping the track destroys it. Unpublishing and
106
+ * disconnecting both stop the track by default, which means a processor has to be set again on
107
+ * the track published after a reconnect. See {@link TrackProcessor} for the full lifecycle,
108
+ * including what `stopLocalTrackOnUnpublish: false` changes.
109
+ *
98
110
  * @param processor
99
111
  * @param showProcessedStreamLocally
100
112
  * @returns
101
113
  */
102
114
  setProcessor(processor: TrackProcessor<TrackKind>, showProcessedStreamLocally?: boolean): Promise<void>;
115
+ /**
116
+ * Returns the processor currently set on this track, or `undefined` if there is none. A track
117
+ * that was stopped by an unpublish or a disconnect has none. See {@link TrackProcessor}.
118
+ */
103
119
  getProcessor(): TrackProcessor<TrackKind, any> | undefined;
104
120
  /**
105
121
  * Stops the track processor
106
122
  * See https://github.com/livekit/track-processors-js for example usage
107
123
  *
124
+ * Only needed to remove a processor from a track that stays published. Unpublishing or
125
+ * disconnecting already destroys it. See {@link TrackProcessor}.
108
126
  */
109
127
  stopProcessor(keepElement?: boolean): Promise<void>;
110
128
  /**
@@ -61,6 +61,13 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
61
61
  */
62
62
  private refreshSenderEncodings;
63
63
  private applyEncodingsToSender;
64
+ /**
65
+ * Sets a processor on this track, and wires its processed track into the simulcast codecs.
66
+ *
67
+ * The processor is destroyed when the track is stopped, which unpublishing and disconnecting
68
+ * both do by default, so it has to be set again after reconnecting. See {@link TrackProcessor}
69
+ * for the full lifecycle.
70
+ */
64
71
  setProcessor(processor: TrackProcessor<Track.Kind.Video>, showProcessedStreamLocally?: boolean): Promise<void>;
65
72
  setDegradationPreference(preference: RTCDegradationPreference): Promise<void>;
66
73
  /**
@@ -10,6 +10,12 @@ export default class RemoteAudioTrack extends RemoteTrack<Track.Kind.Audio> {
10
10
  private gainNode?;
11
11
  private sourceNode?;
12
12
  private webAudioPluginNodes;
13
+ /**
14
+ * the nodes of the graph currently wired up, in order, so teardown can undo exactly the edges
15
+ * `connectWebAudio` created. Reading `webAudioPluginNodes` instead would miss the old plugins
16
+ * once `setWebAudioPlugins` has replaced them.
17
+ */
18
+ private connectedNodes;
13
19
  private sinkId?;
14
20
  constructor(mediaTrack: MediaStreamTrack, sid: string, receiver: RTCRtpReceiver, audioContext?: AudioContext, audioOutput?: AudioOutputOptions, loggerOptions?: LoggerOptions);
15
21
  /**
@@ -19,6 +19,17 @@ export interface AudioProcessorOptions extends ProcessorOptions<Track.Kind.Audio
19
19
  */
20
20
  export interface VideoProcessorOptions extends ProcessorOptions<Track.Kind.Video> {
21
21
  }
22
+ /**
23
+ * A processor is bound to the track it was set on, not to the room, and it does not survive the
24
+ * track being stopped: unpublishing a track or disconnecting from a room destroys its processor.
25
+ * Both paths stop the track by default, so after reconnecting or republishing, call
26
+ * `setProcessor` again on the new track to put the processor back.
27
+ *
28
+ * The exception is `stopLocalTrackOnUnpublish: false`, which keeps the track alive across an
29
+ * unpublish or disconnect. A video processor then survives with it. An audio processor only
30
+ * survives if the audio context came from the application, because a context the SDK created is
31
+ * closed on disconnect and the processor goes with it.
32
+ */
22
33
  export interface TrackProcessor<T extends Track.Kind, U extends ProcessorOptions<T> = ProcessorOptions<T>> {
23
34
  name: string;
24
35
  init: (opts: U) => Promise<void>;
@@ -137,8 +137,19 @@ export interface ObservableMediaElement extends HTMLMediaElement {
137
137
  export declare function getClientInfo(capabilities?: ClientInfo_Capability[]): ClientInfo;
138
138
  export declare function getEmptyVideoStreamTrack(): MediaStreamTrack;
139
139
  export declare function createDummyVideoStreamTrack(width?: number, height?: number, enabled?: boolean, paintContent?: boolean): MediaStreamTrack;
140
+ /**
141
+ * Returns a silent audio track, cloned off a shared `AudioContext` created on first use. Hand the
142
+ * clone back to `releaseEmptyAudioStreamTrack` rather than stopping it, so the context can close.
143
+ * A clone that is only stopped stays counted as outstanding, which holds the shared context open
144
+ * for the lifetime of the page.
145
+ */
140
146
  export declare function getEmptyAudioStreamTrack(): MediaStreamTrack;
141
- export declare function getStereoAudioStreamTrack(): MediaStreamTrack;
147
+ /**
148
+ * Stops a track obtained from `getEmptyAudioStreamTrack` and closes the shared `AudioContext`
149
+ * backing it once every track it handed out has been released. Does nothing for a track this
150
+ * module never handed out, or one that has been released already.
151
+ */
152
+ export declare function releaseEmptyAudioStreamTrack(track: MediaStreamTrack): Promise<void>;
142
153
  /** An object that represents a serialized version of a `new Promise((resolve, reject) => {})`
143
154
  * constructor. Wait for a promise resolution with `await future.promise` and explicitly resolve or
144
155
  * reject the inner promise with `future.resolve(...)` or `future.reject(...)`.
@@ -190,6 +201,22 @@ export declare function isAudioCodec(maybeCodec: string): maybeCodec is AudioCod
190
201
  export declare function isVideoCodec(maybeCodec: string): maybeCodec is VideoCodec;
191
202
  export declare function unwrapConstraint(constraint: ConstrainDOMString): string;
192
203
  export declare function unwrapConstraint(constraint: ConstrainULong): number;
204
+ /**
205
+ * The getUserMedia rejections that no softer constraint can recover from, because the user or the
206
+ * document refused capture outright. Retrying one of these would put a second prompt in front of
207
+ * someone who already said no. The last two names are legacy spellings still reported by older
208
+ * engines. Matched by `name` rather than with `instanceof`, because these are not reliably distinct
209
+ * constructors across environments. happy-dom, which the tests run under, aliases them to `Event`.
210
+ */
211
+ export declare function isPermissionDeniedError(e: unknown): boolean;
212
+ /** Records that `e` is the rejection getUserMedia itself produced. */
213
+ export declare function markDeviceAcquisitionFailure(e: unknown): void;
214
+ /**
215
+ * Whether a track restart failed at getUserMedia rather than in the work that adopts the new track.
216
+ * Recorded on the side rather than by wrapping, because callers of `restartTrack` match on the
217
+ * rejection's own name.
218
+ */
219
+ export declare function isDeviceAcquisitionFailure(e: unknown): boolean;
193
220
  export declare function toWebsocketUrl(url: string): string;
194
221
  export declare function toHttpUrl(url: string): string;
195
222
  export declare function extractTranscriptionSegments(transcription: TranscriptionModel, firstReceivedTimesMap: Map<string, number>): TranscriptionSegment[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livekit-client",
3
- "version": "2.22.4",
3
+ "version": "2.23.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",
@@ -11,8 +11,10 @@ import {
11
11
  WrappedJoinRequest_Compression,
12
12
  } from '@livekit/protocol';
13
13
  import { beforeEach, describe, expect, it, vi } from 'vitest';
14
+ import type { Mutex } from '@livekit/mutex';
14
15
  import { ConnectionError, ConnectionErrorReason } from '../room/errors';
15
16
  import CriticalTimers from '../room/timers';
17
+ import { Future } from '../room/utils';
16
18
  import { SignalClient, SignalConnectionState } from './SignalClient';
17
19
  import type { WebSocketCloseInfo, WebSocketConnection } from './WebSocketStream';
18
20
  import { WebSocketStream } from './WebSocketStream';
@@ -147,6 +149,167 @@ describe('SignalClient.connect', () => {
147
149
  return JoinRequest.fromBinary(bytes);
148
150
  }
149
151
 
152
+ describe('Cancelled connection attempts', () => {
153
+ it.each(['before join', 'while waiting for the connection lock'])(
154
+ 'does not open a transport when aborted %s',
155
+ async (when) => {
156
+ const abortController = new AbortController();
157
+ const internals = signalClient as unknown as { connectionLock: Mutex };
158
+ const unlock = await internals.connectionLock.lock();
159
+ mockWebSocketStream({
160
+ connection: createMockConnection(
161
+ createMockReadableStream([createSignalResponse('join', createJoinResponse())]),
162
+ ),
163
+ });
164
+ if (when === 'before join') abortController.abort();
165
+ const joining = signalClient.join(
166
+ 'wss://test.livekit.io',
167
+ 'test-token',
168
+ defaultOptions,
169
+ abortController.signal,
170
+ );
171
+ abortController.abort();
172
+ unlock();
173
+
174
+ try {
175
+ await expect(joining).rejects.toMatchObject({ reason: ConnectionErrorReason.Cancelled });
176
+ expect(WebSocketStream).not.toHaveBeenCalled();
177
+ } finally {
178
+ await signalClient.close();
179
+ vi.mocked(WebSocketStream).mockReset();
180
+ }
181
+ },
182
+ );
183
+
184
+ it('does not replace a transport when aborted during its teardown', async () => {
185
+ const abortController = new AbortController();
186
+ const internals = signalClient as unknown as {
187
+ connectionLock: Mutex;
188
+ teardownTransport: (reason: string) => Promise<void>;
189
+ };
190
+ const teardownStarted = new Future<void, Error>();
191
+ const finishTeardown = new Future<void, Error>();
192
+ vi.spyOn(internals, 'teardownTransport').mockImplementationOnce(async () => {
193
+ teardownStarted.resolve?.();
194
+ await finishTeardown.promise;
195
+ });
196
+ signalClient.ws = {
197
+ close: () => {},
198
+ closed: Promise.resolve({ closeCode: 1000, reason: '' }),
199
+ } as WebSocketStream;
200
+ mockWebSocketStream({
201
+ connection: createMockConnection(
202
+ createMockReadableStream([createSignalResponse('join', createJoinResponse())]),
203
+ ),
204
+ });
205
+
206
+ const joining = signalClient.join(
207
+ 'wss://test.livekit.io',
208
+ 'test-token',
209
+ defaultOptions,
210
+ abortController.signal,
211
+ );
212
+ const cancelled = expect(joining).rejects.toMatchObject({
213
+ reason: ConnectionErrorReason.Cancelled,
214
+ });
215
+ await teardownStarted.promise;
216
+ abortController.abort();
217
+ finishTeardown.resolve?.();
218
+ await cancelled;
219
+ // The rejection can settle before the transport replacement resumes.
220
+ const unlock = await internals.connectionLock.lock();
221
+ unlock();
222
+
223
+ try {
224
+ expect(WebSocketStream).not.toHaveBeenCalled();
225
+ } finally {
226
+ await signalClient.close();
227
+ vi.mocked(WebSocketStream).mockReset();
228
+ }
229
+ });
230
+ it('does not validate the url when aborted while the websocket is opening', async () => {
231
+ const abortController = new AbortController();
232
+ const internals = signalClient as unknown as { connectionLock: Mutex };
233
+ const opened = new Future<WebSocketConnection, Error>();
234
+ opened.promise.catch(() => {});
235
+ mockWebSocketStream({ opened: opened.promise, readyState: 0 });
236
+ vi.mocked(global.fetch).mockClear();
237
+
238
+ const joining = signalClient.join(
239
+ 'wss://test.livekit.io',
240
+ 'test-token',
241
+ defaultOptions,
242
+ abortController.signal,
243
+ );
244
+ const cancelled = expect(joining).rejects.toMatchObject({
245
+ reason: ConnectionErrorReason.Cancelled,
246
+ });
247
+ await vi.waitFor(() => expect(WebSocketStream).toHaveBeenCalled());
248
+ abortController.abort();
249
+ opened.reject?.(new Error('closed by abort'));
250
+ await cancelled;
251
+ // let the attempt's continuation run to completion
252
+ const unlock = await internals.connectionLock.lock();
253
+ unlock();
254
+
255
+ try {
256
+ expect(global.fetch).not.toHaveBeenCalled();
257
+ } finally {
258
+ await signalClient.close();
259
+ vi.mocked(WebSocketStream).mockReset();
260
+ }
261
+ });
262
+
263
+ it('does not report a join that arrives after the attempt was aborted', async () => {
264
+ const abortController = new AbortController();
265
+ const internals = signalClient as unknown as { connectionLock: Mutex };
266
+ let enqueue: ((chunk: ArrayBuffer) => void) | undefined;
267
+ const readable = new ReadableStream<ArrayBuffer>({
268
+ start(controller) {
269
+ enqueue = (chunk) => controller.enqueue(chunk);
270
+ },
271
+ });
272
+ const writerReady = new Future<void, Error>();
273
+ const writable = new WritableStream();
274
+ const getWriter = writable.getWriter.bind(writable);
275
+ writable.getWriter = () => {
276
+ const writer = getWriter();
277
+ writerReady.resolve?.();
278
+ return writer;
279
+ };
280
+ mockWebSocketStream({
281
+ connection: { readable, writable, protocol: '', extensions: '' },
282
+ });
283
+ const onJoined = vi.fn();
284
+ signalClient.onJoined = onJoined;
285
+
286
+ const joining = signalClient.join(
287
+ 'wss://test.livekit.io',
288
+ 'test-token',
289
+ defaultOptions,
290
+ abortController.signal,
291
+ );
292
+ const cancelled = expect(joining).rejects.toMatchObject({
293
+ reason: ConnectionErrorReason.Cancelled,
294
+ });
295
+ await writerReady.promise;
296
+ abortController.abort();
297
+ enqueue?.(
298
+ createSignalResponse('join', createJoinResponse()).toBinary().buffer as ArrayBuffer,
299
+ );
300
+ await cancelled;
301
+ const unlock = await internals.connectionLock.lock();
302
+ unlock();
303
+
304
+ try {
305
+ expect(onJoined).not.toHaveBeenCalled();
306
+ } finally {
307
+ await signalClient.close();
308
+ vi.mocked(WebSocketStream).mockReset();
309
+ }
310
+ });
311
+ });
312
+
150
313
  describe('Happy Path - Initial Join', () => {
151
314
  it('should successfully connect and receive join response', async () => {
152
315
  const joinResponse = createJoinResponse();
@@ -162,6 +325,17 @@ describe('SignalClient.connect', () => {
162
325
  expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
163
326
  });
164
327
 
328
+ it('releases the connection lock when the url is malformed', async () => {
329
+ await expect(signalClient.join('not a url', 'test-token', defaultOptions)).rejects.toThrow();
330
+
331
+ const joinResponse = createJoinResponse();
332
+ const mockReadable = createMockReadableStream([createSignalResponse('join', joinResponse)]);
333
+ mockWebSocketStream({ connection: createMockConnection(mockReadable) });
334
+
335
+ const result = await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);
336
+ expect(result).toEqual(joinResponse);
337
+ });
338
+
165
339
  it('does not advertise packet trailer capability by default', async () => {
166
340
  const joinResponse = createJoinResponse();
167
341
  const signalResponse = createSignalResponse('join', joinResponse);
@@ -893,6 +1067,66 @@ describe('SignalClient.connect', () => {
893
1067
  });
894
1068
  });
895
1069
 
1070
+ describe('Failure Case - Validate Request Hangs', () => {
1071
+ // A socket that never opens on its own (a lossy network) and whose `opened` rejects once the
1072
+ // client closes it, like a real WebSocketStream aborted during the handshake.
1073
+ function mockHangingWebSocket() {
1074
+ vi.mocked(WebSocketStream).mockImplementationOnce(function () {
1075
+ let rejectOpened!: (e: unknown) => void;
1076
+ let resolveClosed!: (info: WebSocketCloseInfo) => void;
1077
+ return {
1078
+ url: 'wss://test.livekit.io',
1079
+ opened: new Promise((_, reject) => (rejectOpened = reject)),
1080
+ closed: new Promise((resolve) => (resolveClosed = resolve)),
1081
+ close: vi.fn(() => {
1082
+ rejectOpened(new Error('aborted during handshake'));
1083
+ resolveClosed({ closeCode: 1006, reason: '' });
1084
+ }),
1085
+ readyState: 0,
1086
+ } as any;
1087
+ });
1088
+ }
1089
+
1090
+ it('does not let a timed-out attempt block the next one', async () => {
1091
+ // A validate request on a dead pooled connection only settles once its abort signal fires.
1092
+ // Without a deadline it held the connection lock, so the next attempt never started. A
1093
+ // timed-out attempt now skips validation entirely, since its caller has already been rejected.
1094
+ vi.useFakeTimers();
1095
+ try {
1096
+ const hangingFetch = (_url: unknown, init?: RequestInit) =>
1097
+ new Promise<Response>((_, reject) => {
1098
+ init?.signal?.addEventListener('abort', () => reject(init.signal!.reason));
1099
+ });
1100
+ vi.mocked(fetch).mockImplementationOnce(hangingFetch).mockImplementationOnce(hangingFetch);
1101
+ mockHangingWebSocket();
1102
+ mockHangingWebSocket();
1103
+
1104
+ // building the join request finishes on real I/O, so wait for each socket before advancing
1105
+ const first = expect(
1106
+ signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions),
1107
+ ).rejects.toMatchObject({ reason: ConnectionErrorReason.Timeout });
1108
+ await vi.waitFor(() => expect(WebSocketStream).toHaveBeenCalledTimes(1));
1109
+ await vi.advanceTimersByTimeAsync(defaultOptions.websocketTimeout);
1110
+ await first;
1111
+
1112
+ const second = expect(
1113
+ signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions),
1114
+ ).rejects.toMatchObject({ reason: ConnectionErrorReason.Timeout });
1115
+ // past the validate deadline, which is what releases the lock the first attempt holds
1116
+ await vi.advanceTimersByTimeAsync(10_000);
1117
+ await vi.waitFor(() => expect(WebSocketStream).toHaveBeenCalledTimes(2));
1118
+ await vi.advanceTimersByTimeAsync(defaultOptions.websocketTimeout);
1119
+ await second;
1120
+
1121
+ expect(fetch).not.toHaveBeenCalled();
1122
+ } finally {
1123
+ vi.useRealTimers();
1124
+ // drop the unused hanging fetches so they don't leak into later tests
1125
+ vi.mocked(fetch).mockReset();
1126
+ }
1127
+ });
1128
+ });
1129
+
896
1130
  describe('Establishing over an existing session', () => {
897
1131
  async function joinSuccessfully() {
898
1132
  mockWebSocketStream({