livekit-client 1.14.1 → 1.14.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (85) 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 +25 -44
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +555 -306
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.umd.js +1 -1
  8. package/dist/livekit-client.umd.js.map +1 -1
  9. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  10. package/dist/src/e2ee/utils.d.ts +0 -1
  11. package/dist/src/e2ee/utils.d.ts.map +1 -1
  12. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  13. package/dist/src/e2ee/worker/ParticipantKeyHandler.d.ts.map +1 -1
  14. package/dist/src/proto/livekit_models_pb.d.ts +87 -11
  15. package/dist/src/proto/livekit_models_pb.d.ts.map +1 -1
  16. package/dist/src/proto/livekit_rtc_pb.d.ts +0 -4
  17. package/dist/src/proto/livekit_rtc_pb.d.ts.map +1 -1
  18. package/dist/src/room/PCTransport.d.ts +20 -1
  19. package/dist/src/room/PCTransport.d.ts.map +1 -1
  20. package/dist/src/room/RTCEngine.d.ts +1 -1
  21. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  22. package/dist/src/room/Room.d.ts +1 -1
  23. package/dist/src/room/Room.d.ts.map +1 -1
  24. package/dist/src/room/defaults.d.ts +1 -0
  25. package/dist/src/room/defaults.d.ts.map +1 -1
  26. package/dist/src/room/events.d.ts +3 -1
  27. package/dist/src/room/events.d.ts.map +1 -1
  28. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  29. package/dist/src/room/participant/Participant.d.ts +1 -0
  30. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  31. package/dist/src/room/timers.d.ts +1 -1
  32. package/dist/src/room/timers.d.ts.map +1 -1
  33. package/dist/src/room/track/LocalAudioTrack.d.ts +1 -1
  34. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  35. package/dist/src/room/track/LocalTrack.d.ts +3 -3
  36. package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
  37. package/dist/src/room/track/LocalVideoTrack.d.ts +2 -1
  38. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  39. package/dist/src/room/track/Track.d.ts.map +1 -1
  40. package/dist/src/room/track/options.d.ts +0 -1
  41. package/dist/src/room/track/options.d.ts.map +1 -1
  42. package/dist/src/room/track/utils.d.ts +2 -1
  43. package/dist/src/room/track/utils.d.ts.map +1 -1
  44. package/dist/src/utils/cloneDeep.d.ts +2 -0
  45. package/dist/src/utils/cloneDeep.d.ts.map +1 -0
  46. package/dist/ts4.2/src/e2ee/utils.d.ts +0 -1
  47. package/dist/ts4.2/src/proto/livekit_models_pb.d.ts +87 -11
  48. package/dist/ts4.2/src/proto/livekit_rtc_pb.d.ts +0 -4
  49. package/dist/ts4.2/src/room/PCTransport.d.ts +20 -1
  50. package/dist/ts4.2/src/room/RTCEngine.d.ts +1 -1
  51. package/dist/ts4.2/src/room/Room.d.ts +1 -1
  52. package/dist/ts4.2/src/room/defaults.d.ts +1 -0
  53. package/dist/ts4.2/src/room/events.d.ts +3 -1
  54. package/dist/ts4.2/src/room/participant/Participant.d.ts +1 -0
  55. package/dist/ts4.2/src/room/timers.d.ts +1 -1
  56. package/dist/ts4.2/src/room/track/LocalAudioTrack.d.ts +1 -1
  57. package/dist/ts4.2/src/room/track/LocalTrack.d.ts +3 -3
  58. package/dist/ts4.2/src/room/track/LocalVideoTrack.d.ts +2 -1
  59. package/dist/ts4.2/src/room/track/options.d.ts +0 -1
  60. package/dist/ts4.2/src/room/track/utils.d.ts +1 -0
  61. package/dist/ts4.2/src/utils/cloneDeep.d.ts +2 -0
  62. package/package.json +15 -15
  63. package/src/connectionHelper/checks/webrtc.ts +1 -1
  64. package/src/e2ee/E2eeManager.ts +2 -1
  65. package/src/e2ee/utils.ts +0 -10
  66. package/src/e2ee/worker/FrameCryptor.ts +13 -14
  67. package/src/e2ee/worker/ParticipantKeyHandler.ts +4 -5
  68. package/src/e2ee/worker/e2ee.worker.ts +3 -1
  69. package/src/proto/livekit_models_pb.ts +140 -15
  70. package/src/proto/livekit_rtc_pb.ts +1 -7
  71. package/src/room/PCTransport.ts +122 -5
  72. package/src/room/RTCEngine.ts +56 -92
  73. package/src/room/Room.ts +14 -11
  74. package/src/room/defaults.ts +4 -2
  75. package/src/room/events.ts +5 -1
  76. package/src/room/participant/LocalParticipant.ts +47 -68
  77. package/src/room/participant/Participant.ts +1 -0
  78. package/src/room/track/LocalAudioTrack.ts +1 -1
  79. package/src/room/track/LocalTrack.ts +8 -5
  80. package/src/room/track/LocalVideoTrack.ts +2 -1
  81. package/src/room/track/Track.ts +6 -1
  82. package/src/room/track/options.ts +0 -7
  83. package/src/room/track/utils.ts +17 -8
  84. package/src/utils/cloneDeep.test.ts +54 -0
  85. package/src/utils/cloneDeep.ts +11 -0
@@ -18,6 +18,7 @@ import {
18
18
  TrackUnpublishedResponse,
19
19
  } from '../../proto/livekit_rtc_pb';
20
20
  import type RTCEngine from '../RTCEngine';
21
+ import { defaultVideoCodec } from '../defaults';
21
22
  import { DeviceUnsupportedError, TrackInvalidError, UnexpectedConnectionState } from '../errors';
22
23
  import { EngineEvent, ParticipantEvent, TrackEvent } from '../events';
23
24
  import LocalAudioTrack from '../track/LocalAudioTrack';
@@ -33,10 +34,11 @@ import type {
33
34
  TrackPublishOptions,
34
35
  VideoCaptureOptions,
35
36
  } from '../track/options';
36
- import { VideoPresets, isBackupCodec, isCodecEqual } from '../track/options';
37
+ import { VideoPresets, isBackupCodec } from '../track/options';
37
38
  import {
38
39
  constraintsForOptions,
39
40
  mergeDefaultOptions,
41
+ mimeTypeToVideoCodecString,
40
42
  screenCaptureToDisplayMediaStreamOptions,
41
43
  } from '../track/utils';
42
44
  import type { DataPublishOptions } from '../types';
@@ -408,6 +410,7 @@ export default class LocalParticipant extends Participant {
408
410
 
409
411
  if (constraints.audio) {
410
412
  this.microphoneError = undefined;
413
+ this.emit(ParticipantEvent.AudioStreamAcquired);
411
414
  }
412
415
  if (constraints.video) {
413
416
  this.cameraError = undefined;
@@ -460,6 +463,7 @@ export default class LocalParticipant extends Participant {
460
463
  screenVideo.source = Track.Source.ScreenShare;
461
464
  const localTracks: Array<LocalTrack> = [screenVideo];
462
465
  if (stream.getAudioTracks().length > 0) {
466
+ this.emit(ParticipantEvent.AudioStreamAcquired);
463
467
  const screenAudio = new LocalAudioTrack(
464
468
  stream.getAudioTracks()[0],
465
469
  undefined,
@@ -599,18 +603,7 @@ export default class LocalParticipant extends Participant {
599
603
  (publishedTrack) => track instanceof LocalTrack && publishedTrack.source === track.source,
600
604
  );
601
605
  if (existingTrackOfSource && track.source !== Track.Source.Unknown) {
602
- try {
603
- // throw an Error in order to capture the stack trace
604
- throw Error(`publishing a second track with the same source: ${track.source}`);
605
- } catch (e: unknown) {
606
- if (e instanceof Error) {
607
- log.warn(e.message, {
608
- oldTrack: existingTrackOfSource,
609
- newTrack: track,
610
- trace: e.stack,
611
- });
612
- }
613
- }
606
+ log.info(`publishing a second track with the same source: ${track.source}`);
614
607
  }
615
608
  if (opts.stopMicTrackOnMute && track instanceof LocalAudioTrack) {
616
609
  track.stopOnMute = true;
@@ -629,6 +622,10 @@ export default class LocalParticipant extends Participant {
629
622
  if (opts.videoCodec === 'vp9' && !supportsVP9()) {
630
623
  opts.videoCodec = undefined;
631
624
  }
625
+ if (opts.videoCodec === undefined) {
626
+ opts.videoCodec = defaultVideoCodec;
627
+ }
628
+ const videoCodec = opts.videoCodec;
632
629
 
633
630
  // handle track actions
634
631
  track.on(TrackEvent.Muted, this.onTrackMuted);
@@ -654,7 +651,6 @@ export default class LocalParticipant extends Participant {
654
651
 
655
652
  // compute encodings and layers for video
656
653
  let encodings: RTCRtpEncodingParameters[] | undefined;
657
- let simEncodings: RTCRtpEncodingParameters[] | undefined;
658
654
  if (track.kind === Track.Kind.Video) {
659
655
  let dims: Track.Dimensions = {
660
656
  width: 0,
@@ -679,53 +675,40 @@ export default class LocalParticipant extends Participant {
679
675
  req.height = dims.height;
680
676
  // for svc codecs, disable simulcast and use vp8 for backup codec
681
677
  if (track instanceof LocalVideoTrack) {
682
- if (isSVCCodec(opts.videoCodec)) {
678
+ if (isSVCCodec(videoCodec)) {
683
679
  // vp9 svc with screenshare has problem to encode, always use L1T3 here
684
- if (track.source === Track.Source.ScreenShare && opts.videoCodec === 'vp9') {
680
+ if (track.source === Track.Source.ScreenShare && videoCodec === 'vp9') {
685
681
  opts.scalabilityMode = 'L1T3';
686
682
  }
687
683
  // set scalabilityMode to 'L3T3_KEY' by default
688
684
  opts.scalabilityMode = opts.scalabilityMode ?? 'L3T3_KEY';
689
685
  }
690
686
 
687
+ req.simulcastCodecs = [
688
+ new SimulcastCodec({
689
+ codec: videoCodec,
690
+ cid: track.mediaStreamTrack.id,
691
+ }),
692
+ ];
693
+
691
694
  // set up backup
692
- if (opts.videoCodec && opts.backupCodec && opts.videoCodec !== opts.backupCodec.codec) {
695
+ if (opts.backupCodec && videoCodec !== opts.backupCodec.codec) {
693
696
  if (!this.roomOptions.dynacast) {
694
697
  this.roomOptions.dynacast = true;
695
698
  }
696
- const simOpts = { ...opts };
697
- simOpts.simulcast = true;
698
- simEncodings = computeTrackBackupEncodings(track, opts.backupCodec.codec, simOpts);
699
-
700
- req.simulcastCodecs = [
701
- new SimulcastCodec({
702
- codec: opts.videoCodec,
703
- cid: track.mediaStreamTrack.id,
704
- enableSimulcastLayers: true,
705
- }),
699
+ req.simulcastCodecs.push(
706
700
  new SimulcastCodec({
707
701
  codec: opts.backupCodec.codec,
708
702
  cid: '',
709
- enableSimulcastLayers: true,
710
- }),
711
- ];
712
- } else if (opts.videoCodec) {
713
- // pass codec info to sfu so it can prefer codec for the client which don't support
714
- // setCodecPreferences
715
- req.simulcastCodecs = [
716
- new SimulcastCodec({
717
- codec: opts.videoCodec,
718
- cid: track.mediaStreamTrack.id,
719
- enableSimulcastLayers: opts.simulcast ?? false,
720
703
  }),
721
- ];
704
+ );
722
705
  }
723
706
  }
724
707
 
725
708
  encodings = computeVideoEncodings(
726
709
  track.source === Track.Source.ScreenShare,
727
- dims.width,
728
- dims.height,
710
+ req.width,
711
+ req.height,
729
712
  opts,
730
713
  );
731
714
  req.layers = videoLayersFromEncodings(
@@ -749,30 +732,28 @@ export default class LocalParticipant extends Participant {
749
732
  }
750
733
 
751
734
  const ti = await this.engine.addTrack(req);
752
- let primaryCodecSupported = false;
753
- let backupCodecSupported = false;
754
- ti.codecs.forEach((c) => {
755
- if (isCodecEqual(c.mimeType, opts.videoCodec)) {
756
- primaryCodecSupported = true;
757
- } else if (opts.backupCodec && isCodecEqual(c.mimeType, opts.backupCodec.codec)) {
758
- backupCodecSupported = true;
735
+ // server might not support the codec the client has requested, in that case, fallback
736
+ // to a supported codec
737
+ let primaryCodecMime: string | undefined;
738
+ ti.codecs.forEach((codec) => {
739
+ if (primaryCodecMime === undefined) {
740
+ primaryCodecMime = codec.mimeType;
759
741
  }
760
742
  });
761
-
762
- if (req.simulcastCodecs.length > 0) {
763
- if (!primaryCodecSupported && !backupCodecSupported) {
764
- throw Error('cannot publish track, codec not supported by server');
765
- }
766
-
767
- if (!primaryCodecSupported && opts.backupCodec) {
768
- const backupCodec = opts.backupCodec;
769
- opts = { ...opts };
770
- log.debug(
771
- `primary codec ${opts.videoCodec} not supported, fallback to ${backupCodec.codec}`,
743
+ if (primaryCodecMime && track.kind === Track.Kind.Video) {
744
+ const updatedCodec = mimeTypeToVideoCodecString(primaryCodecMime);
745
+ if (updatedCodec !== videoCodec) {
746
+ log.debug('falling back to server selected codec', { codec: updatedCodec });
747
+ /* @ts-ignore */
748
+ opts.videoCodec = updatedCodec;
749
+
750
+ // recompute encodings since bitrates/etc could have changed
751
+ encodings = computeVideoEncodings(
752
+ track.source === Track.Source.ScreenShare,
753
+ req.width,
754
+ req.height,
755
+ opts,
772
756
  );
773
- opts.videoCodec = backupCodec.codec;
774
- opts.videoEncoding = backupCodec.encoding;
775
- encodings = simEncodings;
776
757
  }
777
758
  }
778
759
 
@@ -786,20 +767,19 @@ export default class LocalParticipant extends Participant {
786
767
  }
787
768
  log.debug(`publishing ${track.kind} with encodings`, { encodings, trackInfo: ti });
788
769
 
789
- // store RTPSender
790
770
  track.sender = await this.engine.createSender(track, opts, encodings);
791
771
 
792
772
  if (encodings) {
793
773
  if (isFireFox() && track.kind === Track.Kind.Audio) {
794
774
  /* Refer to RFC https://datatracker.ietf.org/doc/html/rfc7587#section-6.1,
795
- livekit-server uses maxaveragebitrate=510000in the answer sdp to permit client to
775
+ livekit-server uses maxaveragebitrate=510000 in the answer sdp to permit client to
796
776
  publish high quality audio track. But firefox always uses this value as the actual
797
777
  bitrates, causing the audio bitrates to rise to 510Kbps in any stereo case unexpectedly.
798
778
  So the client need to modify maxaverragebitrates in answer sdp to user provided value to
799
779
  fix the issue.
800
780
  */
801
781
  let trackTransceiver: RTCRtpTransceiver | undefined = undefined;
802
- for (const transceiver of this.engine.publisher.pc.getTransceivers()) {
782
+ for (const transceiver of this.engine.publisher.getTransceivers()) {
803
783
  if (transceiver.sender === track.sender) {
804
784
  trackTransceiver = transceiver;
805
785
  break;
@@ -889,7 +869,6 @@ export default class LocalParticipant extends Participant {
889
869
  {
890
870
  codec: opts.videoCodec,
891
871
  cid: simulcastTrack.mediaStreamTrack.id,
892
- enableSimulcastLayers: opts.simulcast,
893
872
  },
894
873
  ],
895
874
  });
@@ -947,11 +926,11 @@ export default class LocalParticipant extends Participant {
947
926
  track.sender = undefined;
948
927
  if (
949
928
  this.engine.publisher &&
950
- this.engine.publisher.pc.connectionState !== 'closed' &&
929
+ this.engine.publisher.getConnectionState() !== 'closed' &&
951
930
  trackSender
952
931
  ) {
953
932
  try {
954
- for (const transceiver of this.engine.publisher.pc.getTransceivers()) {
933
+ for (const transceiver of this.engine.publisher.getTransceivers()) {
955
934
  // if sender is not currently sending (after replaceTrack(null))
956
935
  // removeTrack would have no effect.
957
936
  // to ensure we end up successfully removing the track, manually set
@@ -309,6 +309,7 @@ export type ParticipantEventCallbacks = {
309
309
  status: TrackPublication.PermissionStatus,
310
310
  ) => void;
311
311
  mediaDevicesError: (error: Error) => void;
312
+ audioStreamAcquired: () => void;
312
313
  participantPermissionsChanged: (prevPermissions?: ParticipantPermission) => void;
313
314
  trackSubscriptionStatusChanged: (
314
315
  publication: RemoteTrackPublication,
@@ -138,7 +138,7 @@ export default class LocalAudioTrack extends LocalTrack {
138
138
  this.prevStats = stats;
139
139
  };
140
140
 
141
- async setProcessor(processor: TrackProcessor<typeof this.kind>) {
141
+ async setProcessor(processor: TrackProcessor<this['kind']>) {
142
142
  const unlock = await this.processorLock.lock();
143
143
  try {
144
144
  if (!this.audioContext) {
@@ -34,7 +34,7 @@ export default abstract class LocalTrack extends Track {
34
34
 
35
35
  protected processorElement?: HTMLMediaElement;
36
36
 
37
- protected processor?: TrackProcessor<typeof this.kind>;
37
+ protected processor?: TrackProcessor<this['kind']>;
38
38
 
39
39
  protected processorLock: Mutex;
40
40
 
@@ -163,6 +163,12 @@ export default abstract class LocalTrack extends Track {
163
163
  throw new Error('cannot get dimensions for audio tracks');
164
164
  }
165
165
 
166
+ if (getBrowser()?.os === 'iOS') {
167
+ // browsers report wrong initial resolution on iOS.
168
+ // when slightly delaying the call to .getSettings(), the correct resolution is being reported
169
+ await sleep(10);
170
+ }
171
+
166
172
  const started = Date.now();
167
173
  while (Date.now() - started < timeout) {
168
174
  const dims = this.dimensions;
@@ -396,10 +402,7 @@ export default abstract class LocalTrack extends Track {
396
402
  * @param showProcessedStreamLocally
397
403
  * @returns
398
404
  */
399
- async setProcessor(
400
- processor: TrackProcessor<typeof this.kind>,
401
- showProcessedStreamLocally = true,
402
- ) {
405
+ async setProcessor(processor: TrackProcessor<this['kind']>, showProcessedStreamLocally = true) {
403
406
  const unlock = await this.processorLock.lock();
404
407
  try {
405
408
  log.debug('setting up processor');
@@ -284,7 +284,8 @@ export default class LocalVideoTrack extends LocalTrack {
284
284
 
285
285
  /**
286
286
  * @internal
287
- * Sets codecs that should be publishing
287
+ * Sets codecs that should be publishing, returns new codecs that have not yet
288
+ * been published
288
289
  */
289
290
  async setPublishingCodecs(codecs: SubscribedCodec[]): Promise<VideoCodec[]> {
290
291
  log.debug('setting publishing codecs', {
@@ -293,7 +293,12 @@ export function attachToElement(track: MediaStreamTrack, element: HTMLMediaEleme
293
293
  mediaStream.addTrack(track);
294
294
  }
295
295
 
296
- element.autoplay = true;
296
+ if (!isSafari() || !(element instanceof HTMLVideoElement)) {
297
+ // when in low power mode (applies to both macOS and iOS), Safari will show a play/pause overlay
298
+ // when a video starts that has the `autoplay` attribute is set.
299
+ // we work around this by _not_ setting the autoplay attribute on safari and instead call `setTimeout(() => el.play(),0)` further down
300
+ element.autoplay = true;
301
+ }
297
302
  // In case there are no audio tracks present on the mediastream, we set the element as muted to ensure autoplay works
298
303
  element.muted = mediaStream.getAudioTracks().length === 0;
299
304
  if (element instanceof HTMLVideoElement) {
@@ -301,13 +301,6 @@ export function isBackupCodec(codec: string): codec is BackupVideoCodec {
301
301
  return !!backupCodecs.find((backup) => backup === codec);
302
302
  }
303
303
 
304
- export function isCodecEqual(c1: string | undefined, c2: string | undefined): boolean {
305
- return (
306
- c1?.toLowerCase().replace(/audio\/|video\//y, '') ===
307
- c2?.toLowerCase().replace(/audio\/|video\//y, '')
308
- );
309
- }
310
-
311
304
  /**
312
305
  * scalability modes for svc.
313
306
  */
@@ -1,10 +1,13 @@
1
+ import { cloneDeep } from '../../utils/cloneDeep';
1
2
  import { isSafari, sleep } from '../utils';
2
3
  import { Track } from './Track';
3
- import type {
4
- AudioCaptureOptions,
5
- CreateLocalTracksOptions,
6
- ScreenShareCaptureOptions,
7
- VideoCaptureOptions,
4
+ import {
5
+ type AudioCaptureOptions,
6
+ type CreateLocalTracksOptions,
7
+ type ScreenShareCaptureOptions,
8
+ type VideoCaptureOptions,
9
+ VideoCodec,
10
+ videoCodecs,
8
11
  } from './options';
9
12
  import type { AudioTrack } from './types';
10
13
 
@@ -13,9 +16,7 @@ export function mergeDefaultOptions(
13
16
  audioDefaults?: AudioCaptureOptions,
14
17
  videoDefaults?: VideoCaptureOptions,
15
18
  ): CreateLocalTracksOptions {
16
- const opts: CreateLocalTracksOptions = {
17
- ...options,
18
- };
19
+ const opts: CreateLocalTracksOptions = cloneDeep(options) ?? {};
19
20
  if (opts.audio === true) opts.audio = {};
20
21
  if (opts.video === true) opts.video = {};
21
22
 
@@ -181,3 +182,11 @@ export function screenCaptureToDisplayMediaStreamOptions(
181
182
  systemAudio: options.systemAudio,
182
183
  };
183
184
  }
185
+
186
+ export function mimeTypeToVideoCodecString(mimeType: string) {
187
+ const codec = mimeType.split('/')[1].toLowerCase() as VideoCodec;
188
+ if (!videoCodecs.includes(codec)) {
189
+ throw Error(`Video codec not supported: ${codec}`);
190
+ }
191
+ return codec;
192
+ }
@@ -0,0 +1,54 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { cloneDeep } from './cloneDeep';
3
+
4
+ describe('cloneDeep', () => {
5
+ beforeEach(() => {
6
+ global.structuredClone = vi.fn((val) => {
7
+ return JSON.parse(JSON.stringify(val));
8
+ });
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ it('should clone a simple object', () => {
16
+ const structuredCloneSpy = vi.spyOn(global, 'structuredClone');
17
+
18
+ const original = { name: 'John', age: 30 };
19
+ const cloned = cloneDeep(original);
20
+
21
+ expect(cloned).toEqual(original);
22
+ expect(cloned).not.toBe(original);
23
+ expect(structuredCloneSpy).toHaveBeenCalledTimes(1);
24
+ });
25
+
26
+ it('should clone an object with nested properties', () => {
27
+ const structuredCloneSpy = vi.spyOn(global, 'structuredClone');
28
+
29
+ const original = { name: 'John', age: 30, children: [{ name: 'Mark', age: 7 }] };
30
+ const cloned = cloneDeep(original);
31
+
32
+ expect(cloned).toEqual(original);
33
+ expect(cloned).not.toBe(original);
34
+ expect(cloned?.children).not.toBe(original.children);
35
+ expect(structuredCloneSpy).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ it('should use JSON namespace as a fallback', () => {
39
+ const structuredCloneSpy = vi.spyOn(global, 'structuredClone');
40
+ const serializeSpy = vi.spyOn(JSON, 'stringify');
41
+ const deserializeSpy = vi.spyOn(JSON, 'parse');
42
+
43
+ global.structuredClone = undefined as any;
44
+
45
+ const original = { name: 'John', age: 30 };
46
+ const cloned = cloneDeep(original);
47
+
48
+ expect(cloned).toEqual(original);
49
+ expect(cloned).not.toBe(original);
50
+ expect(structuredCloneSpy).not.toHaveBeenCalled();
51
+ expect(serializeSpy).toHaveBeenCalledTimes(1);
52
+ expect(deserializeSpy).toHaveBeenCalledTimes(1);
53
+ });
54
+ });
@@ -0,0 +1,11 @@
1
+ export function cloneDeep<T>(value: T) {
2
+ if (typeof value === 'undefined') {
3
+ return;
4
+ }
5
+
6
+ if (typeof structuredClone === 'function') {
7
+ return structuredClone(value);
8
+ } else {
9
+ return JSON.parse(JSON.stringify(value)) as T;
10
+ }
11
+ }