livekit-client 1.2.2 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/livekit-client.esm.mjs +1990 -905
  2. package/dist/livekit-client.esm.mjs.map +1 -1
  3. package/dist/livekit-client.umd.js +1 -1
  4. package/dist/livekit-client.umd.js.map +1 -1
  5. package/dist/src/index.d.ts +2 -1
  6. package/dist/src/index.d.ts.map +1 -1
  7. package/dist/src/options.d.ts +5 -0
  8. package/dist/src/options.d.ts.map +1 -1
  9. package/dist/src/proto/google/protobuf/timestamp.d.ts +1 -1
  10. package/dist/src/proto/google/protobuf/timestamp.d.ts.map +1 -1
  11. package/dist/src/proto/livekit_models.d.ts +34 -34
  12. package/dist/src/proto/livekit_models.d.ts.map +1 -1
  13. package/dist/src/proto/livekit_rtc.d.ts +124 -124
  14. package/dist/src/proto/livekit_rtc.d.ts.map +1 -1
  15. package/dist/src/room/DefaultReconnectPolicy.d.ts +8 -0
  16. package/dist/src/room/DefaultReconnectPolicy.d.ts.map +1 -0
  17. package/dist/src/room/DeviceManager.d.ts +1 -0
  18. package/dist/src/room/DeviceManager.d.ts.map +1 -1
  19. package/dist/src/room/PCTransport.d.ts +5 -1
  20. package/dist/src/room/PCTransport.d.ts.map +1 -1
  21. package/dist/src/room/RTCEngine.d.ts +7 -1
  22. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  23. package/dist/src/room/ReconnectPolicy.d.ts +23 -0
  24. package/dist/src/room/ReconnectPolicy.d.ts.map +1 -0
  25. package/dist/src/room/Room.d.ts +12 -1
  26. package/dist/src/room/Room.d.ts.map +1 -1
  27. package/dist/src/room/events.d.ts +2 -2
  28. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  29. package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
  30. package/dist/src/room/track/LocalAudioTrack.d.ts +0 -1
  31. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  32. package/dist/src/room/track/LocalVideoTrack.d.ts +1 -1
  33. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  34. package/dist/src/room/track/RemoteTrackPublication.d.ts.map +1 -1
  35. package/dist/src/room/track/RemoteVideoTrack.d.ts +0 -2
  36. package/dist/src/room/track/RemoteVideoTrack.d.ts.map +1 -1
  37. package/dist/src/room/track/create.d.ts.map +1 -1
  38. package/dist/src/room/utils.d.ts +1 -1
  39. package/dist/src/room/utils.d.ts.map +1 -1
  40. package/package.json +37 -33
  41. package/src/index.ts +2 -0
  42. package/src/options.ts +6 -0
  43. package/src/room/DefaultReconnectPolicy.ts +35 -0
  44. package/src/room/DeviceManager.ts +23 -1
  45. package/src/room/PCTransport.ts +91 -17
  46. package/src/room/RTCEngine.ts +105 -33
  47. package/src/room/ReconnectPolicy.ts +25 -0
  48. package/src/room/Room.ts +190 -167
  49. package/src/room/events.ts +2 -2
  50. package/src/room/participant/LocalParticipant.ts +38 -14
  51. package/src/room/participant/RemoteParticipant.ts +14 -0
  52. package/src/room/track/LocalAudioTrack.ts +0 -2
  53. package/src/room/track/LocalVideoTrack.ts +3 -8
  54. package/src/room/track/RemoteTrackPublication.ts +1 -1
  55. package/src/room/track/RemoteVideoTrack.ts +0 -3
  56. package/src/room/track/create.ts +16 -1
  57. package/src/room/utils.ts +7 -5
@@ -1,3 +1,6 @@
1
+ import log from '../logger';
2
+ import { isSafari } from './utils';
3
+
1
4
  const defaultId = 'default';
2
5
 
3
6
  export default class DeviceManager {
@@ -12,13 +15,32 @@ export default class DeviceManager {
12
15
  return this.instance;
13
16
  }
14
17
 
18
+ static userMediaPromiseMap: Map<MediaDeviceKind, Promise<MediaStream>> = new Map();
19
+
15
20
  async getDevices(
16
21
  kind?: MediaDeviceKind,
17
22
  requestPermissions: boolean = true,
18
23
  ): Promise<MediaDeviceInfo[]> {
24
+ if (DeviceManager.userMediaPromiseMap?.size > 0) {
25
+ log.debug('awaiting getUserMedia promise');
26
+ try {
27
+ if (kind) {
28
+ await DeviceManager.userMediaPromiseMap.get(kind);
29
+ } else {
30
+ await Promise.all(DeviceManager.userMediaPromiseMap.values());
31
+ }
32
+ } catch (e: any) {
33
+ log.warn('error waiting for media permissons');
34
+ }
35
+ }
19
36
  let devices = await navigator.mediaDevices.enumerateDevices();
20
37
 
21
- if (requestPermissions) {
38
+ if (
39
+ requestPermissions &&
40
+ kind &&
41
+ // for safari we need to skip this check, as otherwise it will re-acquire user media and fail on iOS https://bugs.webkit.org/show_bug.cgi?id=179363
42
+ (!DeviceManager.userMediaPromiseMap.get(kind) || !isSafari())
43
+ ) {
22
44
  const isDummyDeviceOrEmpty =
23
45
  devices.length === 0 ||
24
46
  devices.some((device) => {
@@ -1,4 +1,5 @@
1
1
  import { debounce } from 'ts-debounce';
2
+ import { MediaDescription, parse, write } from 'sdp-transform';
2
3
  import log from '../logger';
3
4
 
4
5
  /** @internal */
@@ -88,31 +89,71 @@ export default class PCTransport {
88
89
  log.debug('starting to negotiate');
89
90
  const offer = await this.pc.createOffer(options);
90
91
 
91
- // mung sdp for codec bitrate setting that can't apply by sendEncoding
92
- this.trackBitrates.forEach((trackbr) => {
93
- let sdp = offer.sdp ?? '';
94
- const sidIndex = sdp.search(new RegExp(`msid.* ${trackbr.sid}`));
95
- if (sidIndex < 0) {
96
- return;
92
+ const sdpParsed = parse(offer.sdp ?? '');
93
+ sdpParsed.media.forEach((media) => {
94
+ if (media.type === 'audio') {
95
+ ensureAudioNack(media);
96
+ } else if (media.type === 'video') {
97
+ // mung sdp for codec bitrate setting that can't apply by sendEncoding
98
+ this.trackBitrates.some((trackbr): boolean => {
99
+ if (!media.msid || !media.msid.includes(trackbr.sid)) {
100
+ return false;
101
+ }
102
+
103
+ let codecPayload = 0;
104
+ media.rtp.some((rtp): boolean => {
105
+ if (rtp.codec.toUpperCase() === trackbr.codec.toUpperCase()) {
106
+ codecPayload = rtp.payload;
107
+ return true;
108
+ }
109
+ return false;
110
+ });
111
+
112
+ // add x-google-max-bitrate to fmtp line if not exist
113
+ if (codecPayload > 0) {
114
+ if (
115
+ !media.fmtp.some((fmtp): boolean => {
116
+ if (fmtp.payload === codecPayload) {
117
+ if (!fmtp.config.includes('x-google-max-bitrate')) {
118
+ fmtp.config += `;x-google-max-bitrate=${trackbr.maxbr}`;
119
+ }
120
+ return true;
121
+ }
122
+ return false;
123
+ })
124
+ ) {
125
+ media.fmtp.push({
126
+ payload: codecPayload,
127
+ config: `x-google-max-bitrate=${trackbr.maxbr}`,
128
+ });
129
+ }
130
+ }
131
+
132
+ return true;
133
+ });
97
134
  }
98
-
99
- const mlineStart = sdp.substring(0, sidIndex).lastIndexOf('m=');
100
- const mlineEnd = sdp.indexOf('m=', sidIndex);
101
- const mediaSection = sdp.substring(mlineStart, mlineEnd);
102
-
103
- const mungedMediaSection = mediaSection.replace(
104
- new RegExp(`a=rtpmap:(\\d+) ${trackbr.codec}/\\d+`, 'i'),
105
- '$'.concat(`&\r\na=fmtp:$1 x-google-max-bitrate=${trackbr.maxbr}`), // Unity replaces '$&' by some random values when building ( Using concat as a workaround )
106
- );
107
- sdp = sdp.substring(0, mlineStart) + mungedMediaSection + sdp.substring(mlineEnd);
108
- offer.sdp = sdp;
109
135
  });
136
+
137
+ offer.sdp = write(sdpParsed);
110
138
  this.trackBitrates = [];
111
139
 
112
140
  await this.pc.setLocalDescription(offer);
113
141
  this.onOffer(offer);
114
142
  }
115
143
 
144
+ async createAndSetAnswer(): Promise<RTCSessionDescriptionInit> {
145
+ const answer = await this.pc.createAnswer();
146
+ const sdpParsed = parse(answer.sdp ?? '');
147
+ sdpParsed.media.forEach((media) => {
148
+ if (media.type === 'audio') {
149
+ ensureAudioNack(media);
150
+ }
151
+ });
152
+ answer.sdp = write(sdpParsed);
153
+ await this.pc.setLocalDescription(answer);
154
+ return answer;
155
+ }
156
+
116
157
  setTrackCodecBitrate(sid: string, codec: string, maxbr: number) {
117
158
  this.trackBitrates.push({
118
159
  sid,
@@ -125,3 +166,36 @@ export default class PCTransport {
125
166
  this.pc.close();
126
167
  }
127
168
  }
169
+
170
+ function ensureAudioNack(
171
+ media: {
172
+ type: string;
173
+ port: number;
174
+ protocol: string;
175
+ payloads?: string | undefined;
176
+ } & MediaDescription,
177
+ ) {
178
+ // found opus codec to add nack fb
179
+ let opusPayload = 0;
180
+ media.rtp.some((rtp): boolean => {
181
+ if (rtp.codec === 'opus') {
182
+ opusPayload = rtp.payload;
183
+ return true;
184
+ }
185
+ return false;
186
+ });
187
+
188
+ // add nack rtcpfb if not exist
189
+ if (opusPayload > 0) {
190
+ if (!media.rtcpFb) {
191
+ media.rtcpFb = [];
192
+ }
193
+
194
+ if (!media.rtcpFb.some((fb) => fb.payload === opusPayload && fb.type === 'nack')) {
195
+ media.rtcpFb.push({
196
+ payload: opusPayload,
197
+ type: 'nack',
198
+ });
199
+ }
200
+ }
201
+ }
@@ -2,6 +2,7 @@ import { EventEmitter } from 'events';
2
2
  import type TypedEventEmitter from 'typed-emitter';
3
3
  import { SignalClient, SignalOptions } from '../api/SignalClient';
4
4
  import log from '../logger';
5
+ import { RoomOptions } from '../options';
5
6
  import {
6
7
  ClientConfigSetting,
7
8
  ClientConfiguration,
@@ -19,16 +20,16 @@ import {
19
20
  SignalTarget,
20
21
  TrackPublishedResponse,
21
22
  } from '../proto/livekit_rtc';
23
+ import DefaultReconnectPolicy from './DefaultReconnectPolicy';
22
24
  import { ConnectionError, TrackInvalidError, UnexpectedConnectionState } from './errors';
23
25
  import { EngineEvent } from './events';
24
26
  import PCTransport from './PCTransport';
27
+ import { ReconnectContext, ReconnectPolicy } from './ReconnectPolicy';
25
28
  import { isFireFox, isWeb, sleep } from './utils';
26
29
 
27
30
  const lossyDataChannel = '_lossy';
28
31
  const reliableDataChannel = '_reliable';
29
- const maxReconnectRetries = 10;
30
32
  const minReconnectWait = 2 * 1000;
31
- const maxReconnectDuration = 60 * 1000;
32
33
  export const maxICEConnectTimeout = 15 * 1000;
33
34
 
34
35
  enum PCState {
@@ -71,7 +72,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
71
72
 
72
73
  private _isClosed: boolean = true;
73
74
 
74
- private pendingTrackResolvers: { [key: string]: (info: TrackInfo) => void } = {};
75
+ private pendingTrackResolvers: {
76
+ [key: string]: { resolve: (info: TrackInfo) => void; reject: () => void };
77
+ } = {};
75
78
 
76
79
  // true if publisher connection has already been established.
77
80
  // this is helpful to know if we need to restart ICE on the publisher connection
@@ -96,9 +99,15 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
96
99
 
97
100
  private attemptingReconnect: boolean = false;
98
101
 
99
- constructor() {
102
+ private reconnectPolicy: ReconnectPolicy;
103
+
104
+ private reconnectTimeout?: ReturnType<typeof setTimeout>;
105
+
106
+ constructor(private options: RoomOptions) {
100
107
  super();
101
108
  this.client = new SignalClient();
109
+ this.client.signalLatency = this.options.expSignalLatency;
110
+ this.reconnectPolicy = this.options.reconnectPolicy ?? new DefaultReconnectPolicy();
102
111
  }
103
112
 
104
113
  async join(
@@ -157,12 +166,42 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
157
166
  if (this.pendingTrackResolvers[req.cid]) {
158
167
  throw new TrackInvalidError('a track with the same ID has already been published');
159
168
  }
160
- return new Promise<TrackInfo>((resolve) => {
161
- this.pendingTrackResolvers[req.cid] = resolve;
169
+ return new Promise<TrackInfo>((resolve, reject) => {
170
+ const publicationTimeout = setTimeout(() => {
171
+ delete this.pendingTrackResolvers[req.cid];
172
+ reject(
173
+ new ConnectionError('publication of local track timed out, no response from server'),
174
+ );
175
+ }, 10_000);
176
+ this.pendingTrackResolvers[req.cid] = {
177
+ resolve: (info: TrackInfo) => {
178
+ clearTimeout(publicationTimeout);
179
+ resolve(info);
180
+ },
181
+ reject: () => {
182
+ clearTimeout(publicationTimeout);
183
+ reject('Cancelled publication by calling unpublish');
184
+ },
185
+ };
162
186
  this.client.sendAddTrack(req);
163
187
  });
164
188
  }
165
189
 
190
+ removeTrack(sender: RTCRtpSender) {
191
+ if (sender.track && this.pendingTrackResolvers[sender.track.id]) {
192
+ const { reject } = this.pendingTrackResolvers[sender.track.id];
193
+ if (reject) {
194
+ reject();
195
+ }
196
+ delete this.pendingTrackResolvers[sender.track.id];
197
+ }
198
+ try {
199
+ this.publisher?.pc.removeTrack(sender);
200
+ } catch (e: unknown) {
201
+ log.warn('failed to remove track', { error: e, method: 'removeTrack' });
202
+ }
203
+ }
204
+
166
205
  updateMuteStatus(trackSid: string, muted: boolean) {
167
206
  this.client.sendMuteTrack(trackSid, muted);
168
207
  }
@@ -317,14 +356,13 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
317
356
  await this.subscriber.setRemoteDescription(sd);
318
357
 
319
358
  // answer the offer
320
- const answer = await this.subscriber.pc.createAnswer();
321
- await this.subscriber.pc.setLocalDescription(answer);
359
+ const answer = await this.subscriber.createAndSetAnswer();
322
360
  this.client.sendAnswer(answer);
323
361
  };
324
362
 
325
363
  this.client.onLocalTrackPublished = (res: TrackPublishedResponse) => {
326
364
  log.debug('received trackPublishedResponse', res);
327
- const resolve = this.pendingTrackResolvers[res.cid];
365
+ const { resolve } = this.pendingTrackResolvers[res.cid];
328
366
  if (!resolve) {
329
367
  log.error(`missing track resolver for ${res.cid}`);
330
368
  return;
@@ -437,18 +475,42 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
437
475
  // websocket reconnect behavior. if websocket is interrupted, and the PeerConnection
438
476
  // continues to work, we can reconnect to websocket to continue the session
439
477
  // after a number of retries, we'll close and give up permanently
440
- private handleDisconnect = (connection: string) => {
478
+ private handleDisconnect = (connection: string, signalEvents: boolean = false) => {
441
479
  if (this._isClosed) {
442
480
  return;
443
481
  }
482
+
444
483
  log.debug(`${connection} disconnected`);
445
484
  if (this.reconnectAttempts === 0) {
446
485
  // only reset start time on the first try
447
486
  this.reconnectStart = Date.now();
448
487
  }
449
488
 
450
- const delay = this.reconnectAttempts * this.reconnectAttempts * 300;
451
- setTimeout(async () => {
489
+ const disconnect = (duration: number) => {
490
+ log.info(
491
+ `could not recover connection after ${this.reconnectAttempts} attempts, ${duration}ms. giving up`,
492
+ );
493
+ this.emit(EngineEvent.Disconnected);
494
+ this.close();
495
+ };
496
+
497
+ const duration = Date.now() - this.reconnectStart;
498
+ const delay = this.getNextRetryDelay({
499
+ elapsedMs: duration,
500
+ retryCount: this.reconnectAttempts,
501
+ });
502
+
503
+ if (delay === null) {
504
+ disconnect(duration);
505
+ return;
506
+ }
507
+
508
+ log.debug(`reconnecting in ${delay}ms`);
509
+
510
+ if (this.reconnectTimeout) {
511
+ clearTimeout(this.reconnectTimeout);
512
+ }
513
+ this.reconnectTimeout = setTimeout(async () => {
452
514
  if (this._isClosed) {
453
515
  return;
454
516
  }
@@ -469,16 +531,20 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
469
531
  try {
470
532
  this.attemptingReconnect = true;
471
533
  if (this.fullReconnectOnNext) {
472
- await this.restartConnection();
534
+ await this.restartConnection(signalEvents);
473
535
  } else {
474
- await this.resumeConnection();
536
+ await this.resumeConnection(signalEvents);
475
537
  }
476
538
  this.reconnectAttempts = 0;
477
539
  this.fullReconnectOnNext = false;
540
+ if (this.reconnectTimeout) {
541
+ clearTimeout(this.reconnectTimeout);
542
+ }
478
543
  } catch (e) {
479
544
  this.reconnectAttempts += 1;
480
545
  let reconnectRequired = false;
481
546
  let recoverable = true;
547
+ let requireSignalEvents = false;
482
548
  if (e instanceof UnexpectedConnectionState) {
483
549
  log.debug('received unrecoverable error', { error: e });
484
550
  // unrecoverable
@@ -488,26 +554,17 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
488
554
  reconnectRequired = true;
489
555
  }
490
556
 
491
- // when we flip from resume to reconnect, we need to reset reconnectAttempts
492
- // this is needed to fire the right reconnecting events
557
+ // when we flip from resume to reconnect
558
+ // we need to fire the right reconnecting events
493
559
  if (reconnectRequired && !this.fullReconnectOnNext) {
494
560
  this.fullReconnectOnNext = true;
495
- this.reconnectAttempts = 0;
496
- }
497
-
498
- const duration = Date.now() - this.reconnectStart;
499
- if (this.reconnectAttempts >= maxReconnectRetries || duration > maxReconnectDuration) {
500
- recoverable = false;
561
+ requireSignalEvents = true;
501
562
  }
502
563
 
503
564
  if (recoverable) {
504
- this.handleDisconnect('reconnect');
565
+ this.handleDisconnect('reconnect', requireSignalEvents);
505
566
  } else {
506
- log.info(
507
- `could not recover connection after ${maxReconnectRetries} attempts, ${duration}ms. giving up`,
508
- );
509
- this.emit(EngineEvent.Disconnected);
510
- this.close();
567
+ disconnect(Date.now() - this.reconnectStart);
511
568
  }
512
569
  } finally {
513
570
  this.attemptingReconnect = false;
@@ -515,14 +572,25 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
515
572
  }, delay);
516
573
  };
517
574
 
518
- private async restartConnection() {
575
+ private getNextRetryDelay(context: ReconnectContext) {
576
+ try {
577
+ return this.reconnectPolicy.nextRetryDelayInMs(context);
578
+ } catch (e) {
579
+ log.warn('encountered error in reconnect policy', { error: e });
580
+ }
581
+
582
+ // error in user code with provided reconnect policy, stop reconnecting
583
+ return null;
584
+ }
585
+
586
+ private async restartConnection(emitRestarting: boolean = false) {
519
587
  if (!this.url || !this.token) {
520
588
  // permanent failure, don't attempt reconnection
521
589
  throw new UnexpectedConnectionState('could not reconnect, url or token not saved');
522
590
  }
523
591
 
524
592
  log.info(`reconnecting, attempt: ${this.reconnectAttempts}`);
525
- if (this.reconnectAttempts === 0) {
593
+ if (emitRestarting || this.reconnectAttempts === 0) {
526
594
  this.emit(EngineEvent.Restarting);
527
595
  }
528
596
 
@@ -550,7 +618,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
550
618
  this.emit(EngineEvent.Restarted, joinResponse);
551
619
  }
552
620
 
553
- private async resumeConnection(): Promise<void> {
621
+ private async resumeConnection(emitResuming: boolean = false): Promise<void> {
554
622
  if (!this.url || !this.token) {
555
623
  // permanent failure, don't attempt reconnection
556
624
  throw new UnexpectedConnectionState('could not reconnect, url or token not saved');
@@ -561,14 +629,18 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
561
629
  }
562
630
 
563
631
  log.info(`resuming signal connection, attempt ${this.reconnectAttempts}`);
564
- if (this.reconnectAttempts === 0) {
632
+ if (emitResuming || this.reconnectAttempts === 0) {
565
633
  this.emit(EngineEvent.Resuming);
566
634
  }
567
635
 
568
636
  try {
569
637
  await this.client.reconnect(this.url, this.token);
570
638
  } catch (e) {
571
- throw new SignalReconnectError();
639
+ let message = '';
640
+ if (e instanceof Error) {
641
+ message = e.message;
642
+ }
643
+ throw new SignalReconnectError(message);
572
644
  }
573
645
  this.emit(EngineEvent.SignalResumed);
574
646
 
@@ -0,0 +1,25 @@
1
+ /** Controls reconnecting of the client */
2
+ export interface ReconnectPolicy {
3
+ /** Called after disconnect was detected
4
+ *
5
+ * @returns {number | null} Amount of time in milliseconds to delay the next reconnect attempt, `null` signals to stop retrying.
6
+ */
7
+ nextRetryDelayInMs(context: ReconnectContext): number | null;
8
+ }
9
+
10
+ export interface ReconnectContext {
11
+ /**
12
+ * Number of failed reconnect attempts
13
+ */
14
+ readonly retryCount: number;
15
+
16
+ /**
17
+ * Elapsed amount of time in milliseconds since the disconnect.
18
+ */
19
+ readonly elapsedMs: number;
20
+
21
+ /**
22
+ * Reason for retrying
23
+ */
24
+ readonly retryReason?: Error;
25
+ }