livekit-client 2.15.8 → 2.15.9

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 (58) hide show
  1. package/dist/livekit-client.esm.mjs +577 -202
  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/api/SignalClient.d.ts +31 -2
  6. package/dist/src/api/SignalClient.d.ts.map +1 -1
  7. package/dist/src/api/WebSocketStream.d.ts +29 -0
  8. package/dist/src/api/WebSocketStream.d.ts.map +1 -0
  9. package/dist/src/api/utils.d.ts +2 -0
  10. package/dist/src/api/utils.d.ts.map +1 -1
  11. package/dist/src/connectionHelper/checks/turn.d.ts.map +1 -1
  12. package/dist/src/connectionHelper/checks/websocket.d.ts.map +1 -1
  13. package/dist/src/index.d.ts +2 -2
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/options.d.ts +6 -0
  16. package/dist/src/options.d.ts.map +1 -1
  17. package/dist/src/room/PCTransport.d.ts +1 -0
  18. package/dist/src/room/PCTransport.d.ts.map +1 -1
  19. package/dist/src/room/PCTransportManager.d.ts +6 -4
  20. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  21. package/dist/src/room/RTCEngine.d.ts +1 -1
  22. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  23. package/dist/src/room/Room.d.ts.map +1 -1
  24. package/dist/src/room/defaults.d.ts.map +1 -1
  25. package/dist/src/room/token-source/utils.d.ts +1 -1
  26. package/dist/src/room/token-source/utils.d.ts.map +1 -1
  27. package/dist/src/room/utils.d.ts +6 -0
  28. package/dist/src/room/utils.d.ts.map +1 -1
  29. package/dist/ts4.2/api/SignalClient.d.ts +31 -2
  30. package/dist/ts4.2/api/WebSocketStream.d.ts +29 -0
  31. package/dist/ts4.2/api/utils.d.ts +2 -0
  32. package/dist/ts4.2/index.d.ts +2 -2
  33. package/dist/ts4.2/options.d.ts +6 -0
  34. package/dist/ts4.2/room/PCTransport.d.ts +1 -0
  35. package/dist/ts4.2/room/PCTransportManager.d.ts +6 -4
  36. package/dist/ts4.2/room/RTCEngine.d.ts +1 -1
  37. package/dist/ts4.2/room/token-source/utils.d.ts +1 -1
  38. package/dist/ts4.2/room/utils.d.ts +6 -0
  39. package/package.json +1 -1
  40. package/src/api/SignalClient.test.ts +688 -0
  41. package/src/api/SignalClient.ts +308 -161
  42. package/src/api/WebSocketStream.test.ts +625 -0
  43. package/src/api/WebSocketStream.ts +118 -0
  44. package/src/api/utils.ts +10 -0
  45. package/src/connectionHelper/checks/turn.ts +1 -0
  46. package/src/connectionHelper/checks/webrtc.ts +1 -1
  47. package/src/connectionHelper/checks/websocket.ts +1 -0
  48. package/src/index.ts +2 -0
  49. package/src/options.ts +7 -0
  50. package/src/room/PCTransport.ts +7 -3
  51. package/src/room/PCTransportManager.ts +39 -35
  52. package/src/room/RTCEngine.ts +54 -16
  53. package/src/room/Room.ts +5 -2
  54. package/src/room/defaults.ts +1 -0
  55. package/src/room/token-source/TokenSource.ts +2 -2
  56. package/src/room/token-source/utils.test.ts +63 -0
  57. package/src/room/token-source/utils.ts +10 -5
  58. package/src/room/utils.ts +29 -0
@@ -0,0 +1,118 @@
1
+ // https://github.com/CarterLi/websocketstream-polyfill
2
+ import { sleep } from '../room/utils';
3
+
4
+ export interface WebSocketConnection<T extends ArrayBuffer | string = ArrayBuffer | string> {
5
+ readable: ReadableStream<T>;
6
+ writable: WritableStream<T>;
7
+ protocol: string;
8
+ extensions: string;
9
+ }
10
+
11
+ export interface WebSocketCloseInfo {
12
+ closeCode?: number;
13
+ reason?: string;
14
+ }
15
+
16
+ export interface WebSocketStreamOptions {
17
+ protocols?: string[];
18
+ signal?: AbortSignal;
19
+ }
20
+
21
+ /**
22
+ * [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) with [Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API)
23
+ *
24
+ * @see https://web.dev/websocketstream/
25
+ */
26
+ export class WebSocketStream<T extends ArrayBuffer | string = ArrayBuffer | string> {
27
+ readonly url: string;
28
+
29
+ readonly opened: Promise<WebSocketConnection<T>>;
30
+
31
+ readonly closed: Promise<WebSocketCloseInfo>;
32
+
33
+ readonly close: (closeInfo?: WebSocketCloseInfo) => void;
34
+
35
+ get readyState(): number {
36
+ return this.ws.readyState;
37
+ }
38
+
39
+ private ws: WebSocket;
40
+
41
+ constructor(url: string, options: WebSocketStreamOptions = {}) {
42
+ if (options.signal?.aborted) {
43
+ throw new DOMException('This operation was aborted', 'AbortError');
44
+ }
45
+
46
+ this.url = url;
47
+
48
+ const ws = new WebSocket(url, options.protocols ?? []);
49
+ ws.binaryType = 'arraybuffer';
50
+ this.ws = ws;
51
+
52
+ const closeWithInfo = ({ closeCode: code, reason }: WebSocketCloseInfo = {}) =>
53
+ ws.close(code, reason);
54
+
55
+ this.opened = new Promise((resolve, reject) => {
56
+ ws.onopen = () => {
57
+ resolve({
58
+ readable: new ReadableStream<T>({
59
+ start(controller) {
60
+ ws.onmessage = ({ data }) => controller.enqueue(data);
61
+ ws.onerror = (e) => controller.error(e);
62
+ },
63
+ cancel: closeWithInfo,
64
+ }),
65
+ writable: new WritableStream<T>({
66
+ write(chunk) {
67
+ ws.send(chunk);
68
+ },
69
+ abort() {
70
+ ws.close();
71
+ },
72
+ close: closeWithInfo,
73
+ }),
74
+ protocol: ws.protocol,
75
+ extensions: ws.extensions,
76
+ });
77
+ ws.removeEventListener('error', reject);
78
+ };
79
+ ws.addEventListener('error', reject);
80
+ });
81
+
82
+ this.closed = new Promise<WebSocketCloseInfo>((resolve, reject) => {
83
+ const rejectHandler = async () => {
84
+ const closePromise = new Promise<CloseEvent>((res) => {
85
+ if (ws.readyState === WebSocket.CLOSED) return;
86
+ else {
87
+ ws.addEventListener(
88
+ 'close',
89
+ (closeEv: CloseEvent) => {
90
+ res(closeEv);
91
+ },
92
+ { once: true },
93
+ );
94
+ }
95
+ });
96
+ const reason = await Promise.race([sleep(250), closePromise]);
97
+ if (!reason) {
98
+ reject(new Error('Encountered unspecified websocket error without a timely close event'));
99
+ } else {
100
+ // if we can infer the close reason from the close event then resolve the promise, we don't need to throw
101
+ resolve(reason);
102
+ }
103
+ };
104
+ ws.onclose = ({ code, reason }) => {
105
+ resolve({ closeCode: code, reason });
106
+ ws.removeEventListener('error', rejectHandler);
107
+ };
108
+
109
+ ws.addEventListener('error', rejectHandler);
110
+ });
111
+
112
+ if (options.signal) {
113
+ options.signal.onabort = () => ws.close();
114
+ }
115
+
116
+ this.close = closeWithInfo;
117
+ }
118
+ }
package/src/api/utils.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { SignalResponse } from '@livekit/protocol';
1
2
  import { toHttpUrl, toWebsocketUrl } from '../room/utils';
2
3
 
3
4
  export function createRtcUrl(url: string, searchParams: URLSearchParams) {
@@ -21,3 +22,12 @@ function appendUrlPath(urlObj: URL, path: string) {
21
22
  urlObj.pathname = `${ensureTrailingSlash(urlObj.pathname)}${path}`;
22
23
  return urlObj.toString();
23
24
  }
25
+
26
+ export function parseSignalResponse(value: ArrayBuffer | string) {
27
+ if (typeof value === 'string') {
28
+ return SignalResponse.fromJson(JSON.parse(value), { ignoreUnknownFields: true });
29
+ } else if (value instanceof ArrayBuffer) {
30
+ return SignalResponse.fromBinary(new Uint8Array(value));
31
+ }
32
+ throw new Error(`could not decode websocket message: ${typeof value}`);
33
+ }
@@ -13,6 +13,7 @@ export class TURNCheck extends Checker {
13
13
  maxRetries: 0,
14
14
  e2eeEnabled: false,
15
15
  websocketTimeout: 15_000,
16
+ singlePeerConnection: false,
16
17
  });
17
18
 
18
19
  let hasTLS = false;
@@ -38,7 +38,7 @@ export class WebRTCCheck extends Checker {
38
38
  }
39
39
  };
40
40
 
41
- if (this.room.engine.pcManager) {
41
+ if (this.room.engine.pcManager?.subscriber) {
42
42
  this.room.engine.pcManager.subscriber.onIceCandidateError = (ev) => {
43
43
  if (ev instanceof RTCPeerConnectionIceErrorEvent) {
44
44
  this.appendWarning(
@@ -18,6 +18,7 @@ export class WebSocketCheck extends Checker {
18
18
  maxRetries: 0,
19
19
  e2eeEnabled: false,
20
20
  websocketTimeout: 15_000,
21
+ singlePeerConnection: false,
21
22
  });
22
23
  this.appendMessage(`Connected to server, version ${joinRes.serverVersion}.`);
23
24
  if (joinRes.serverInfo?.edition === ServerInfo_Edition.Cloud && joinRes.serverInfo?.region) {
package/src/index.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  isVideoTrack,
51
51
  supportsAV1,
52
52
  supportsAdaptiveStream,
53
+ supportsAudioOutputSelection,
53
54
  supportsDynacast,
54
55
  supportsVP9,
55
56
  } from './room/utils';
@@ -121,6 +122,7 @@ export {
121
122
  setLogLevel,
122
123
  supportsAV1,
123
124
  supportsAdaptiveStream,
125
+ supportsAudioOutputSelection,
124
126
  supportsDynacast,
125
127
  supportsVP9,
126
128
  Mutex,
package/src/options.ts CHANGED
@@ -99,6 +99,13 @@ export interface InternalRoomOptions {
99
99
  encryption?: E2EEOptions;
100
100
 
101
101
  loggerName?: string;
102
+
103
+ /**
104
+ * @experimental
105
+ * only supported on LiveKit Cloud
106
+ * and LiveKit OSS >= 1.9.2
107
+ */
108
+ singlePeerConnection: boolean;
102
109
  }
103
110
 
104
111
  /**
@@ -167,7 +167,7 @@ export default class PCTransport extends EventEmitter {
167
167
  sdpParsed.media.forEach((media) => {
168
168
  const mid = getMidString(media.mid!);
169
169
  if (media.type === 'audio') {
170
- // mung sdp for opus bitrate settings
170
+ // munge sdp for opus bitrate settings
171
171
  this.trackBitrates.some((trackbr): boolean => {
172
172
  if (!trackbr.transceiver || mid != trackbr.transceiver.mid) {
173
173
  return false;
@@ -297,7 +297,7 @@ export default class PCTransport extends EventEmitter {
297
297
  sdpParsed.media.forEach((media) => {
298
298
  ensureIPAddrMatchVersion(media);
299
299
  if (media.type === 'audio') {
300
- ensureAudioNackAndStereo(media, [], []);
300
+ ensureAudioNackAndStereo(media, ['all'], []);
301
301
  } else if (media.type === 'video') {
302
302
  this.trackBitrates.some((trackbr): boolean => {
303
303
  if (!media.msid || !trackbr.cid || !media.msid.includes(trackbr.cid)) {
@@ -380,6 +380,10 @@ export default class PCTransport extends EventEmitter {
380
380
  return this.pc.addTransceiver(mediaStreamTrack, transceiverInit);
381
381
  }
382
382
 
383
+ addTransceiverOfKind(kind: 'audio' | 'video', transceiverInit: RTCRtpTransceiverInit) {
384
+ return this.pc.addTransceiver(kind, transceiverInit);
385
+ }
386
+
383
387
  addTrack(track: MediaStreamTrack) {
384
388
  if (!this._pc) {
385
389
  throw new UnexpectedConnectionState('PC closed, cannot add track');
@@ -623,7 +627,7 @@ function ensureAudioNackAndStereo(
623
627
  });
624
628
  }
625
629
 
626
- if (stereoMids.includes(mid)) {
630
+ if (stereoMids.includes(mid) || (stereoMids.length === 1 && stereoMids[0] === 'all')) {
627
631
  media.fmtp.some((fmtp): boolean => {
628
632
  if (fmtp.payload === opusPayload) {
629
633
  if (!fmtp.config.includes('stereo=1')) {
@@ -17,10 +17,11 @@ export enum PCTransportState {
17
17
  CLOSED,
18
18
  }
19
19
 
20
+ type PCMode = 'subscriber-primary' | 'publisher-primary' | 'publisher-only';
20
21
  export class PCTransportManager {
21
22
  public publisher: PCTransport;
22
23
 
23
- public subscriber: PCTransport;
24
+ public subscriber?: PCTransport;
24
25
 
25
26
  public peerConnectionTimeout: number = roomConnectOptionDefaults.peerConnectionTimeout;
26
27
 
@@ -39,7 +40,7 @@ export class PCTransportManager {
39
40
  public onStateChange?: (
40
41
  state: PCTransportState,
41
42
  pubState: RTCPeerConnectionState,
42
- subState: RTCPeerConnectionState,
43
+ subState?: RTCPeerConnectionState,
43
44
  ) => void;
44
45
 
45
46
  public onIceCandidate?: (ev: RTCIceCandidate, target: SignalTarget) => void;
@@ -64,38 +65,40 @@ export class PCTransportManager {
64
65
 
65
66
  private loggerOptions: LoggerOptions;
66
67
 
67
- constructor(
68
- rtcConfig: RTCConfiguration,
69
- subscriberPrimary: boolean,
70
- loggerOptions: LoggerOptions,
71
- ) {
68
+ constructor(rtcConfig: RTCConfiguration, mode: PCMode, loggerOptions: LoggerOptions) {
72
69
  this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCManager);
73
70
  this.loggerOptions = loggerOptions;
74
71
 
75
- this.isPublisherConnectionRequired = !subscriberPrimary;
76
- this.isSubscriberConnectionRequired = subscriberPrimary;
72
+ this.isPublisherConnectionRequired = mode !== 'subscriber-primary';
73
+ this.isSubscriberConnectionRequired = mode === 'subscriber-primary';
77
74
  this.publisher = new PCTransport(rtcConfig, loggerOptions);
78
- this.subscriber = new PCTransport(rtcConfig, loggerOptions);
75
+ if (mode !== 'publisher-only') {
76
+ this.subscriber = new PCTransport(rtcConfig, loggerOptions);
77
+ this.subscriber.onConnectionStateChange = this.updateState;
78
+ this.subscriber.onIceConnectionStateChange = this.updateState;
79
+ this.subscriber.onSignalingStatechange = this.updateState;
80
+ this.subscriber.onIceCandidate = (candidate) => {
81
+ this.onIceCandidate?.(candidate, SignalTarget.SUBSCRIBER);
82
+ };
83
+ // in subscriber primary mode, server side opens sub data channels.
84
+ this.subscriber.onDataChannel = (ev) => {
85
+ this.onDataChannel?.(ev);
86
+ };
87
+ this.subscriber.onTrack = (ev) => {
88
+ this.onTrack?.(ev);
89
+ };
90
+ }
79
91
 
80
92
  this.publisher.onConnectionStateChange = this.updateState;
81
- this.subscriber.onConnectionStateChange = this.updateState;
82
93
  this.publisher.onIceConnectionStateChange = this.updateState;
83
- this.subscriber.onIceConnectionStateChange = this.updateState;
84
94
  this.publisher.onSignalingStatechange = this.updateState;
85
- this.subscriber.onSignalingStatechange = this.updateState;
86
95
  this.publisher.onIceCandidate = (candidate) => {
87
96
  this.onIceCandidate?.(candidate, SignalTarget.PUBLISHER);
88
97
  };
89
- this.subscriber.onIceCandidate = (candidate) => {
90
- this.onIceCandidate?.(candidate, SignalTarget.SUBSCRIBER);
91
- };
92
- // in subscriber primary mode, server side opens sub data channels.
93
- this.subscriber.onDataChannel = (ev) => {
94
- this.onDataChannel?.(ev);
95
- };
96
- this.subscriber.onTrack = (ev) => {
98
+ this.publisher.onTrack = (ev) => {
97
99
  this.onTrack?.(ev);
98
100
  };
101
+
99
102
  this.publisher.onOffer = (offer, offerId) => {
100
103
  this.onPublisherOffer?.(offer, offerId);
101
104
  };
@@ -117,11 +120,6 @@ export class PCTransportManager {
117
120
  this.updateState();
118
121
  }
119
122
 
120
- requireSubscriber(require = true) {
121
- this.isSubscriberConnectionRequired = require;
122
- this.updateState();
123
- }
124
-
125
123
  createAndSendPublisherOffer(options?: RTCOfferOptions) {
126
124
  return this.publisher.createAndSendOffer(options);
127
125
  }
@@ -148,12 +146,14 @@ export class PCTransportManager {
148
146
  }
149
147
  }
150
148
  }
151
- await Promise.all([this.publisher.close(), this.subscriber.close()]);
149
+ await Promise.all([this.publisher.close(), this.subscriber?.close()]);
152
150
  this.updateState();
153
151
  }
154
152
 
155
153
  async triggerIceRestart() {
156
- this.subscriber.restartingIce = true;
154
+ if (this.subscriber) {
155
+ this.subscriber.restartingIce = true;
156
+ }
157
157
  // only restart publisher if it's needed
158
158
  if (this.needsPublisher) {
159
159
  await this.createAndSendPublisherOffer({ iceRestart: true });
@@ -164,7 +164,7 @@ export class PCTransportManager {
164
164
  if (target === SignalTarget.PUBLISHER) {
165
165
  await this.publisher.addIceCandidate(candidate);
166
166
  } else {
167
- await this.subscriber.addIceCandidate(candidate);
167
+ await this.subscriber?.addIceCandidate(candidate);
168
168
  }
169
169
  }
170
170
 
@@ -173,17 +173,17 @@ export class PCTransportManager {
173
173
  ...this.logContext,
174
174
  RTCSdpType: sd.type,
175
175
  sdp: sd.sdp,
176
- signalingState: this.subscriber.getSignallingState().toString(),
176
+ signalingState: this.subscriber?.getSignallingState().toString(),
177
177
  });
178
178
  const unlock = await this.remoteOfferLock.lock();
179
179
  try {
180
- const success = await this.subscriber.setRemoteDescription(sd, offerId);
180
+ const success = await this.subscriber?.setRemoteDescription(sd, offerId);
181
181
  if (!success) {
182
182
  return undefined;
183
183
  }
184
184
 
185
185
  // answer the offer
186
- const answer = await this.subscriber.createAndSetAnswer();
186
+ const answer = await this.subscriber?.createAndSetAnswer();
187
187
  return answer;
188
188
  } finally {
189
189
  unlock();
@@ -192,7 +192,7 @@ export class PCTransportManager {
192
192
 
193
193
  updateConfiguration(config: RTCConfiguration, iceRestart?: boolean) {
194
194
  this.publisher.setConfiguration(config);
195
- this.subscriber.setConfiguration(config);
195
+ this.subscriber?.setConfiguration(config);
196
196
  if (iceRestart) {
197
197
  this.triggerIceRestart();
198
198
  }
@@ -252,6 +252,10 @@ export class PCTransportManager {
252
252
  return this.publisher.addTransceiver(track, transceiverInit);
253
253
  }
254
254
 
255
+ addPublisherTransceiverOfKind(kind: 'audio' | 'video', transceiverInit: RTCRtpTransceiverInit) {
256
+ return this.publisher.addTransceiverOfKind(kind, transceiverInit);
257
+ }
258
+
255
259
  addPublisherTrack(track: MediaStreamTrack) {
256
260
  return this.publisher.addTrack(track);
257
261
  }
@@ -277,7 +281,7 @@ export class PCTransportManager {
277
281
  if (this.isPublisherConnectionRequired) {
278
282
  transports.push(this.publisher);
279
283
  }
280
- if (this.isSubscriberConnectionRequired) {
284
+ if (this.isSubscriberConnectionRequired && this.subscriber) {
281
285
  transports.push(this.subscriber);
282
286
  }
283
287
  return transports;
@@ -311,7 +315,7 @@ export class PCTransportManager {
311
315
  this.onStateChange?.(
312
316
  this.state,
313
317
  this.publisher.getConnectionState(),
314
- this.subscriber.getConnectionState(),
318
+ this.subscriber?.getConnectionState(),
315
319
  );
316
320
  }
317
321
  };
@@ -15,6 +15,7 @@ import {
15
15
  type JoinResponse,
16
16
  type LeaveRequest,
17
17
  LeaveRequest_Action,
18
+ MediaSectionsRequirement,
18
19
  ParticipantInfo,
19
20
  ReconnectReason,
20
21
  type ReconnectResponse,
@@ -425,7 +426,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
425
426
 
426
427
  this.pcManager = new PCTransportManager(
427
428
  rtcConfig,
428
- joinResponse.subscriberPrimary,
429
+ this.options.singlePeerConnection
430
+ ? 'publisher-only'
431
+ : joinResponse.subscriberPrimary
432
+ ? 'subscriber-primary'
433
+ : 'publisher-primary',
429
434
  this.loggerOptions,
430
435
  );
431
436
 
@@ -481,6 +486,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
481
486
  }
482
487
  };
483
488
  this.pcManager.onTrack = (ev: RTCTrackEvent) => {
489
+ // this fires after the underlying transceiver is stopped and potentially
490
+ // peer connection closed, so do not bubble up if there are no streams
491
+ if (ev.streams.length === 0) return;
484
492
  this.emit(EngineEvent.MediaTrackAdded, ev.track, ev.streams[0], ev.receiver);
485
493
  };
486
494
 
@@ -566,6 +574,18 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
566
574
  this.emit(EngineEvent.RoomMoved, res);
567
575
  };
568
576
 
577
+ this.client.onMediaSectionsRequirement = (requirement: MediaSectionsRequirement) => {
578
+ const transceiverInit: RTCRtpTransceiverInit = { direction: 'recvonly' };
579
+ for (let i: number = 0; i < requirement.numAudios; i++) {
580
+ this.pcManager?.addPublisherTransceiverOfKind('audio', transceiverInit);
581
+ }
582
+ for (let i: number = 0; i < requirement.numVideos; i++) {
583
+ this.pcManager?.addPublisherTransceiverOfKind('video', transceiverInit);
584
+ }
585
+
586
+ this.negotiate();
587
+ };
588
+
569
589
  this.client.onClose = () => {
570
590
  this.handleDisconnect('signal', ReconnectReason.RR_SIGNAL_DISCONNECTED);
571
591
  };
@@ -734,6 +754,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
734
754
  const decryptedPacket = EncryptedPacketPayload.fromBinary(decryptedData.payload);
735
755
  const newDp = new DataPacket({
736
756
  value: decryptedPacket.value,
757
+ participantIdentity: dp.participantIdentity,
758
+ participantSid: dp.participantSid,
737
759
  });
738
760
  if (newDp.value?.case === 'user') {
739
761
  // compatibility
@@ -1478,8 +1500,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1478
1500
  this.log.warn('sync state cannot be sent without peer connection setup', this.logContext);
1479
1501
  return;
1480
1502
  }
1481
- const previousAnswer = this.pcManager.subscriber.getLocalDescription();
1482
- const previousOffer = this.pcManager.subscriber.getRemoteDescription();
1503
+ const previousPublisherOffer = this.pcManager.publisher.getLocalDescription();
1504
+ const previousPublisherAnswer = this.pcManager.publisher.getRemoteDescription();
1505
+ const previousSubscriberOffer = this.pcManager.subscriber?.getRemoteDescription();
1506
+ const previousSubscriberAnswer = this.pcManager.subscriber?.getLocalDescription();
1483
1507
 
1484
1508
  /* 1. autosubscribe on, so subscribed tracks = all tracks - unsub tracks,
1485
1509
  in this case, we send unsub tracks, so server add all tracks to this
@@ -1501,18 +1525,32 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1501
1525
 
1502
1526
  this.client.sendSyncState(
1503
1527
  new SyncState({
1504
- answer: previousAnswer
1505
- ? toProtoSessionDescription({
1506
- sdp: previousAnswer.sdp,
1507
- type: previousAnswer.type,
1508
- })
1509
- : undefined,
1510
- offer: previousOffer
1511
- ? toProtoSessionDescription({
1512
- sdp: previousOffer.sdp,
1513
- type: previousOffer.type,
1514
- })
1515
- : undefined,
1528
+ answer: this.options.singlePeerConnection
1529
+ ? previousPublisherAnswer
1530
+ ? toProtoSessionDescription({
1531
+ sdp: previousPublisherAnswer.sdp,
1532
+ type: previousPublisherAnswer.type,
1533
+ })
1534
+ : undefined
1535
+ : previousSubscriberAnswer
1536
+ ? toProtoSessionDescription({
1537
+ sdp: previousSubscriberAnswer.sdp,
1538
+ type: previousSubscriberAnswer.type,
1539
+ })
1540
+ : undefined,
1541
+ offer: this.options.singlePeerConnection
1542
+ ? previousPublisherOffer
1543
+ ? toProtoSessionDescription({
1544
+ sdp: previousPublisherOffer.sdp,
1545
+ type: previousPublisherOffer.type,
1546
+ })
1547
+ : undefined
1548
+ : previousSubscriberOffer
1549
+ ? toProtoSessionDescription({
1550
+ sdp: previousSubscriberOffer.sdp,
1551
+ type: previousSubscriberOffer.type,
1552
+ })
1553
+ : undefined,
1516
1554
  subscription: new UpdateSubscription({
1517
1555
  trackSids,
1518
1556
  subscribe: !autoSubscribe,
@@ -1609,7 +1647,7 @@ export type EngineEventCallbacks = {
1609
1647
  activeSpeakersUpdate: (speakers: Array<SpeakerInfo>) => void;
1610
1648
  dataPacketReceived: (packet: DataPacket, encryptionType: Encryption_Type) => void;
1611
1649
  transcriptionReceived: (transcription: Transcription) => void;
1612
- transportsCreated: (publisher: PCTransport, subscriber: PCTransport) => void;
1650
+ transportsCreated: (publisher: PCTransport, subscriber?: PCTransport) => void;
1613
1651
  /** @internal */
1614
1652
  trackSenderAdded: (track: Track, sender: RTCRtpSender) => void;
1615
1653
  rtpVideoMapUpdate: (rtpMap: Map<number, VideoCodec>) => void;
package/src/room/Room.ts CHANGED
@@ -370,6 +370,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
370
370
  if (e2eeOptions) {
371
371
  if ('e2eeManager' in e2eeOptions) {
372
372
  this.e2eeManager = e2eeOptions.e2eeManager;
373
+ this.e2eeManager.isDataChannelEncryptionEnabled = dcEncryptionEnabled;
373
374
  } else {
374
375
  this.e2eeManager = new E2EEManager(e2eeOptions, dcEncryptionEnabled);
375
376
  }
@@ -761,6 +762,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
761
762
  maxRetries: connectOptions.maxRetries,
762
763
  e2eeEnabled: !!this.e2eeManager,
763
764
  websocketTimeout: connectOptions.websocketTimeout,
765
+ singlePeerConnection: roomOptions.singlePeerConnection,
764
766
  },
765
767
  abortController.signal,
766
768
  );
@@ -939,8 +941,9 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
939
941
  this.isResuming
940
942
  ) {
941
943
  // try aborting pending connection attempt
942
- this.log.warn('abort connection attempt', this.logContext);
943
- this.abortController?.abort();
944
+ const msg = 'Abort connection attempt due to user initiated disconnect';
945
+ this.log.warn(msg, this.logContext);
946
+ this.abortController?.abort(msg);
944
947
  // in case the abort controller didn't manage to cancel the connection attempt, reject the connect promise explicitly
945
948
  this.connectFuture?.reject?.(
946
949
  new ConnectionError('Client initiated disconnect', ConnectionErrorReason.Cancelled),
@@ -42,6 +42,7 @@ export const roomOptionDefaults: InternalRoomOptions = {
42
42
  reconnectPolicy: new DefaultReconnectPolicy(),
43
43
  disconnectOnPageLeave: true,
44
44
  webAudioMix: false,
45
+ singlePeerConnection: false,
45
46
  } as const;
46
47
 
47
48
  export const roomConnectOptionDefaults: InternalRoomConnectOptions = {
@@ -11,7 +11,7 @@ import {
11
11
  TokenSourceFixed,
12
12
  type TokenSourceResponseObject,
13
13
  } from './types';
14
- import { decodeTokenPayload, isResponseExpired } from './utils';
14
+ import { decodeTokenPayload, isResponseTokenValid } from './utils';
15
15
 
16
16
  /** A TokenSourceCached is a TokenSource which caches the last {@link TokenSourceResponseObject} value and returns it
17
17
  * until a) it expires or b) the {@link TokenSourceFetchOptions} provided to .fetch(...) change. */
@@ -56,7 +56,7 @@ abstract class TokenSourceCached extends TokenSourceConfigurable {
56
56
  if (!this.cachedResponse) {
57
57
  return false;
58
58
  }
59
- if (isResponseExpired(this.cachedResponse)) {
59
+ if (!isResponseTokenValid(this.cachedResponse)) {
60
60
  return false;
61
61
  }
62
62
  if (this.isSameAsCachedFetchOptions(fetchOptions)) {
@@ -0,0 +1,63 @@
1
+ import { TokenSourceResponse } from '@livekit/protocol';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { decodeTokenPayload, isResponseTokenValid } from './utils';
4
+
5
+ // Test JWTs created for test purposes only.
6
+ // None of these actually auth against anything.
7
+ const TOKENS = {
8
+ // Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
9
+ // Exp date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
10
+ // A dummy roomConfig value is also set, with room_config.name = "test room name", and room_config.agents = [{"agentName": "test agent name","metadata":"test agent metadata"}]
11
+ VALID:
12
+ 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjEwLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MCwicm9vbUNvbmZpZyI6eyJuYW1lIjoidGVzdCByb29tIG5hbWUiLCJlbXB0eVRpbWVvdXQiOjAsImRlcGFydHVyZVRpbWVvdXQiOjAsIm1heFBhcnRpY2lwYW50cyI6MCwibWluUGxheW91dERlbGF5IjowLCJtYXhQbGF5b3V0RGVsYXkiOjAsInN5bmNTdHJlYW1zIjpmYWxzZSwiYWdlbnRzIjpbeyJhZ2VudE5hbWUiOiJ0ZXN0IGFnZW50IG5hbWUiLCJtZXRhZGF0YSI6InRlc3QgYWdlbnQgbWV0YWRhdGEifV0sIm1ldGFkYXRhIjoiIn19.EDetpHG8cSubaApzgWJaQrpCiSy9KDBlfCfVdIydbQ-_CHiNnXOK_f_mCJbTf9A-duT1jmvPOkLrkkWFT60XPQ',
13
+
14
+ // Nbf date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
15
+ // Exp date set at 9876543211 seconds (Fri Dec 22 2282 20:13:31 GMT+0000)
16
+ NBF_IN_FUTURE:
17
+ 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjExLCJuYmYiOjk4NzY1NDMyMTAsImlhdCI6MTIzNDU2Nzg5MH0.DcMmdKrD76eJg7IUBZqoTRDvBaXtCcwtuE5h7IwVXhG_6nvgxN_ix30_AmLgnYhvhkN-x9dTRPoHg-CME72AbQ',
18
+
19
+ // Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
20
+ // Exp date set at 1234567891 seconds (Fri Feb 13 2009 23:31:31 GMT+0000)
21
+ EXP_IN_PAST:
22
+ 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoxMjM0NTY3ODkxLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MH0.OYP1NITayotBYt0mioInLJmaIM0bHyyR-yG6iwKyQDzhoGha15qbsc7dOJlzz4za1iW5EzCgjc2_xGxqaSu5XA',
23
+ };
24
+
25
+ describe('isResponseTokenValid', () => {
26
+ it('should find a valid jwt not expired', () => {
27
+ const isValid = isResponseTokenValid(
28
+ TokenSourceResponse.fromJson({
29
+ serverUrl: 'ws://localhost:7800',
30
+ participantToken: TOKENS.VALID,
31
+ }),
32
+ );
33
+ expect(isValid).toBe(true);
34
+ });
35
+ it('should find a long ago expired jwt as expired', () => {
36
+ const isValid = isResponseTokenValid(
37
+ TokenSourceResponse.fromJson({
38
+ serverUrl: 'ws://localhost:7800',
39
+ participantToken: TOKENS.EXP_IN_PAST,
40
+ }),
41
+ );
42
+ expect(isValid).toBe(false);
43
+ });
44
+ it('should find a jwt that has not become active yet as expired', () => {
45
+ const isValid = isResponseTokenValid(
46
+ TokenSourceResponse.fromJson({
47
+ serverUrl: 'ws://localhost:7800',
48
+ participantToken: TOKENS.NBF_IN_FUTURE,
49
+ }),
50
+ );
51
+ expect(isValid).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe('decodeTokenPayload', () => {
56
+ it('should extract roomconfig metadata from a token', () => {
57
+ const payload = decodeTokenPayload(TOKENS.VALID);
58
+ expect(payload.roomConfig?.name).toBe('test room name');
59
+ expect(payload.roomConfig?.agents).toHaveLength(1);
60
+ expect(payload.roomConfig?.agents![0].agentName).toBe('test agent name');
61
+ expect(payload.roomConfig?.agents![0].metadata).toBe('test agent metadata');
62
+ });
63
+ });