livekit-client 2.22.0 → 2.22.1

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 (75) 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 +187 -1
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +1764 -121
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js +1 -1
  8. package/dist/livekit-client.fm.worker.js.map +1 -1
  9. package/dist/livekit-client.fm.worker.mjs +187 -1
  10. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/api/SignalClient.d.ts +25 -1
  14. package/dist/src/api/SignalClient.d.ts.map +1 -1
  15. package/dist/src/api/SignalClientStateMachine.d.ts +85 -0
  16. package/dist/src/api/SignalClientStateMachine.d.ts.map +1 -0
  17. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  18. package/dist/src/api/utils.d.ts.map +1 -1
  19. package/dist/src/index.d.ts +3 -2
  20. package/dist/src/index.d.ts.map +1 -1
  21. package/dist/src/logger.d.ts +2 -1
  22. package/dist/src/logger.d.ts.map +1 -1
  23. package/dist/src/options.d.ts +22 -0
  24. package/dist/src/options.d.ts.map +1 -1
  25. package/dist/src/room/PCTransport.d.ts +2 -1
  26. package/dist/src/room/PCTransport.d.ts.map +1 -1
  27. package/dist/src/room/RTCEngine.d.ts +7 -2
  28. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  29. package/dist/src/room/Room.d.ts +8 -1
  30. package/dist/src/room/Room.d.ts.map +1 -1
  31. package/dist/src/room/events.d.ts +1 -1
  32. package/dist/src/room/statsSummary.d.ts +13 -0
  33. package/dist/src/room/statsSummary.d.ts.map +1 -0
  34. package/dist/src/room/token-source/utils.d.ts.map +1 -1
  35. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  36. package/dist/src/room/track/Track.d.ts +10 -0
  37. package/dist/src/room/track/Track.d.ts.map +1 -1
  38. package/dist/src/utils/machineInspector.d.ts +54 -0
  39. package/dist/src/utils/machineInspector.d.ts.map +1 -0
  40. package/dist/ts4.2/api/SignalClient.d.ts +25 -1
  41. package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -0
  42. package/dist/ts4.2/index.d.ts +3 -2
  43. package/dist/ts4.2/logger.d.ts +2 -1
  44. package/dist/ts4.2/options.d.ts +22 -0
  45. package/dist/ts4.2/room/PCTransport.d.ts +2 -1
  46. package/dist/ts4.2/room/RTCEngine.d.ts +7 -2
  47. package/dist/ts4.2/room/Room.d.ts +8 -1
  48. package/dist/ts4.2/room/events.d.ts +1 -1
  49. package/dist/ts4.2/room/statsSummary.d.ts +13 -0
  50. package/dist/ts4.2/room/track/Track.d.ts +10 -0
  51. package/dist/ts4.2/utils/machineInspector.d.ts +54 -0
  52. package/package.json +7 -1
  53. package/src/api/SignalClient.test.ts +320 -8
  54. package/src/api/SignalClient.ts +260 -82
  55. package/src/api/SignalClientStateMachine.test.ts +472 -0
  56. package/src/api/SignalClientStateMachine.ts +180 -0
  57. package/src/api/WebSocketStream.ts +19 -3
  58. package/src/api/utils.test.ts +20 -1
  59. package/src/api/utils.ts +5 -0
  60. package/src/index.ts +5 -0
  61. package/src/logger.ts +1 -0
  62. package/src/options.ts +24 -0
  63. package/src/room/PCTransport.ts +2 -1
  64. package/src/room/RTCEngine.ts +16 -7
  65. package/src/room/Room.ts +70 -5
  66. package/src/room/events.ts +1 -1
  67. package/src/room/statsSummary.ts +187 -0
  68. package/src/room/token-source/test-tokens.ts +20 -0
  69. package/src/room/token-source/utils.test.ts +27 -0
  70. package/src/room/token-source/utils.ts +12 -5
  71. package/src/room/track/LocalAudioTrack.ts +9 -3
  72. package/src/room/track/Track.ts +27 -0
  73. package/src/room/utils.test.ts +24 -1
  74. package/src/room/utils.ts +1 -1
  75. package/src/utils/machineInspector.ts +90 -0
@@ -12,6 +12,7 @@ import {
12
12
  } from '@livekit/protocol';
13
13
  import { beforeEach, describe, expect, it, vi } from 'vitest';
14
14
  import { ConnectionError, ConnectionErrorReason } from '../room/errors';
15
+ import CriticalTimers from '../room/timers';
15
16
  import { SignalClient, SignalConnectionState } from './SignalClient';
16
17
  import type { WebSocketCloseInfo, WebSocketConnection } from './WebSocketStream';
17
18
  import { WebSocketStream } from './WebSocketStream';
@@ -275,7 +276,10 @@ describe('SignalClient.connect', () => {
275
276
  .catch((e) => e);
276
277
 
277
278
  expect(error).toBeInstanceOf(ConnectionError);
278
- expect(error.reason).toBe(ConnectionErrorReason.Cancelled);
279
+ // a stalled connect is a timeout, not a cancellation: the distinction is what lets Room try
280
+ // the next region and record the attempt against the backoff strategy
281
+ expect(error.reason).toBe(ConnectionErrorReason.Timeout);
282
+ expect(error.message).toContain('room connection has timed out');
279
283
  });
280
284
  });
281
285
 
@@ -550,6 +554,260 @@ describe('SignalClient.connect', () => {
550
554
  });
551
555
  });
552
556
 
557
+ describe('Resuming a client that never joined', () => {
558
+ it('is refused by the missing options rather than by the lifecycle', async () => {
559
+ // The engine can hold a freshly created client (Room.recreateEngine) whose machine is still
560
+ // `new`. Nothing there is resumable, and the options guard is what says so — it runs before the
561
+ // lifecycle input, so the caller gets a warning and undefined rather than a thrown refusal.
562
+ // If session identity ever became rehydratable, `new` would have to accept `reconnect` and this
563
+ // is the test that should change.
564
+ expect((signalClient as any).lifecycleState).toBe('new');
565
+
566
+ await expect(
567
+ signalClient.reconnect('wss://test.livekit.io', 'test-token', 'PA_session'),
568
+ ).resolves.toBeUndefined();
569
+
570
+ expect((signalClient as any).lifecycleState).toBe('new');
571
+ expect(WebSocketStream).not.toHaveBeenCalled();
572
+ });
573
+ });
574
+
575
+ describe('Transport closed while connected', () => {
576
+ /** Joins with a `closed` promise the test controls, and reports what onClose saw. */
577
+ async function joinWithControllableClose() {
578
+ let closeTransport: (info: WebSocketCloseInfo) => void = () => {};
579
+ const closed = new Promise<WebSocketCloseInfo>((resolve) => {
580
+ closeTransport = resolve;
581
+ });
582
+ mockWebSocketStream({
583
+ connection: createMockConnection(
584
+ createMockReadableStream([createSignalResponse('join', createJoinResponse())]),
585
+ ),
586
+ closed,
587
+ });
588
+
589
+ const closeReasons: Array<string> = [];
590
+ signalClient.onClose = (reason) => closeReasons.push(reason);
591
+ await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);
592
+ expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
593
+
594
+ return { closeTransport, closeReasons };
595
+ }
596
+
597
+ it('reports a clean server close, which used to be treated as nothing happening', async () => {
598
+ const { closeTransport, closeReasons } = await joinWithControllableClose();
599
+
600
+ // A server that drops signalling closes cleanly — a migration that never sends its
601
+ // Leave{action=RESUME} does exactly this. Swallowing it leaves Room and RTCEngine believing
602
+ // they are still connected until the connection reconcile forces a full reconnect.
603
+ closeTransport({ closeCode: 1000, reason: 'server dropped signalling' });
604
+ await vi.waitFor(() => expect(closeReasons).toEqual(['server dropped signalling']));
605
+
606
+ expect(signalClient.currentState).toBe(SignalConnectionState.DISCONNECTED);
607
+ });
608
+
609
+ it('ignores a close from a transport that has already been replaced', async () => {
610
+ const { closeTransport, closeReasons } = await joinWithControllableClose();
611
+
612
+ // a newer attempt takes over before the old socket reports its close
613
+ (signalClient as any).sendLifecycleInput({ type: 'reconnect' });
614
+ closeTransport({ closeCode: 1006, reason: 'late close from the old socket' });
615
+ await new Promise((resolve) => setTimeout(resolve, 0));
616
+
617
+ expect(closeReasons).toEqual([]);
618
+ expect(signalClient.currentState).toBe(SignalConnectionState.RECONNECTING);
619
+ });
620
+ });
621
+
622
+ describe('Held requests during a resume', () => {
623
+ /**
624
+ * Records the tracks whose mute requests reach the wire, in order. `mute` is session-scoped —
625
+ * it is absent from the pass-through list — so it is the class of request a resume holds.
626
+ * Filtered rather than counted, because keepalive traffic shares the transport.
627
+ */
628
+ function captureMutedTracks() {
629
+ const muted: Array<string> = [];
630
+ const writable = new WritableStream<ArrayBuffer | string>({
631
+ write(chunk) {
632
+ const request = SignalRequest.fromBinary(new Uint8Array(chunk as ArrayBuffer));
633
+ if (request.message?.case === 'mute') {
634
+ muted.push(request.message.value.sid);
635
+ }
636
+ },
637
+ });
638
+ return { muted, writable };
639
+ }
640
+
641
+ it('releases held requests before ones issued while the engine is still catching up', async () => {
642
+ mockWebSocketStream({
643
+ connection: createMockConnection(
644
+ createMockReadableStream([createSignalResponse('join', createJoinResponse())]),
645
+ ),
646
+ });
647
+ await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);
648
+
649
+ const { muted, writable } = captureMutedTracks();
650
+ mockWebSocketStream({
651
+ connection: {
652
+ ...createMockConnection(
653
+ createMockReadableStream([
654
+ createSignalResponse('reconnect', new ReconnectResponse({ iceServers: [] })),
655
+ ]),
656
+ ),
657
+ writable,
658
+ },
659
+ });
660
+
661
+ const resuming = signalClient.reconnect('wss://test.livekit.io', 'test-token', 'sid-123');
662
+ expect(signalClient.currentState).toBe(SignalConnectionState.RECONNECTING);
663
+
664
+ // issued while the resume is in flight, so it waits rather than racing it
665
+ await signalClient.sendMuteTrack('held-during-resume', true);
666
+ expect(muted).toEqual([]);
667
+
668
+ await resuming;
669
+ expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
670
+
671
+ // The transport is back, but the engine has not called setReconnected yet — a resume is only
672
+ // complete once the media path is back too, which in a real session is seconds later. A
673
+ // request issued in that window must not overtake the one that has been waiting.
674
+ await signalClient.sendMuteTrack('issued-while-catching-up', true);
675
+ expect(muted).toEqual([]);
676
+
677
+ signalClient.setReconnected();
678
+ await signalClient.sendMuteTrack('issued-after-reconnected', true);
679
+
680
+ expect(muted).toEqual([
681
+ 'held-during-resume',
682
+ 'issued-while-catching-up',
683
+ 'issued-after-reconnected',
684
+ ]);
685
+ });
686
+ });
687
+
688
+ describe('Failure Case - Closed After Upgrade', () => {
689
+ it('fails fast rather than waiting out the first-message timeout', async () => {
690
+ // The upgrade succeeds and the server then closes without sending a join response. Nothing
691
+ // else will reject here, so the close has to — otherwise the attempt hangs until the
692
+ // first-message timeout and reports a timeout instead of the close that caused it.
693
+ const neverYields = new ReadableStream<ArrayBuffer>({ start() {} });
694
+ mockWebSocketStream({
695
+ connection: createMockConnection(neverYields),
696
+ closed: Promise.resolve({ closeCode: 1011, reason: 'closed before join' }),
697
+ });
698
+
699
+ const err = await signalClient
700
+ .join('wss://test.livekit.io', 'test-token', defaultOptions)
701
+ .then(
702
+ () => undefined,
703
+ (e) => e,
704
+ );
705
+
706
+ expect(err).toMatchObject({ reason: ConnectionErrorReason.InternalError });
707
+ expect((err as Error).message).toContain('Websocket got closed during');
708
+ });
709
+ });
710
+
711
+ describe('Failure Case - Upgrade Rejected', () => {
712
+ it('surfaces the classified error, not the close that races it', async () => {
713
+ // A refused token fails the upgrade and closes the socket at once, but classifying the failure
714
+ // needs a round trip to the validate endpoint. The close must not pre-empt that answer, or a
715
+ // caller checking for a 401 sees a generic "websocket got closed" instead.
716
+ vi.mocked(fetch).mockImplementation(
717
+ () =>
718
+ new Promise((resolve) => {
719
+ setTimeout(
720
+ () => resolve({ status: 401, text: async () => 'permission denied' } as Response),
721
+ 20,
722
+ );
723
+ }),
724
+ );
725
+
726
+ vi.mocked(WebSocketStream).mockImplementation(function () {
727
+ return {
728
+ url: 'wss://test.livekit.io',
729
+ opened: Promise.reject(new Error('HTTP Authentication failed')),
730
+ closed: Promise.resolve({ closeCode: 1006, reason: '' }),
731
+ close: vi.fn(),
732
+ readyState: 3,
733
+ } as any;
734
+ });
735
+
736
+ await expect(
737
+ signalClient.join('wss://test.livekit.io', 'bad-token', defaultOptions),
738
+ ).rejects.toMatchObject({
739
+ reason: ConnectionErrorReason.NotAllowed,
740
+ status: 401,
741
+ });
742
+ });
743
+ });
744
+
745
+ describe('Establishing over an existing session', () => {
746
+ async function joinSuccessfully() {
747
+ mockWebSocketStream({
748
+ connection: createMockConnection(
749
+ createMockReadableStream([createSignalResponse('join', createJoinResponse())]),
750
+ ),
751
+ });
752
+ await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);
753
+ expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
754
+ }
755
+
756
+ it('refuses a join over a live session rather than opening a transport it would discard', async () => {
757
+ await joinSuccessfully();
758
+ const transportsOpened = vi.mocked(WebSocketStream).mock.calls.length;
759
+
760
+ await expect(
761
+ signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions),
762
+ ).rejects.toMatchObject({
763
+ message: expect.stringContaining("from 'connected'"),
764
+ reason: ConnectionErrorReason.InternalError,
765
+ });
766
+
767
+ // refusing is not a teardown: the live session is untouched and no transport was opened
768
+ expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
769
+ expect(vi.mocked(WebSocketStream).mock.calls.length).toBe(transportsOpened);
770
+ });
771
+
772
+ it('resumes after a close, which is how the engine recovers from an unexpected one', async () => {
773
+ await joinSuccessfully();
774
+ await signalClient.close();
775
+ expect((signalClient as any).lifecycleState).toBe('closed');
776
+
777
+ mockWebSocketStream({
778
+ connection: createMockConnection(
779
+ createMockReadableStream([
780
+ createSignalResponse('reconnect', new ReconnectResponse({ iceServers: [] })),
781
+ ]),
782
+ ),
783
+ });
784
+
785
+ await expect(
786
+ signalClient.reconnect('wss://test.livekit.io', 'test-token', 'PA_session'),
787
+ ).resolves.toBeDefined();
788
+ expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
789
+ });
790
+
791
+ it('waits for an in-flight close to settle instead of racing its teardown', async () => {
792
+ await joinSuccessfully();
793
+ mockWebSocketStream({
794
+ connection: createMockConnection(
795
+ createMockReadableStream([createSignalResponse('join', createJoinResponse())]),
796
+ ),
797
+ });
798
+
799
+ // close and join without awaiting the close in between. The public projection folds
800
+ // `disconnecting` into DISCONNECTED, so the precondition is read off the lifecycle itself.
801
+ const closing = signalClient.close();
802
+ expect((signalClient as any).lifecycleState).toBe('disconnecting');
803
+ const joining = signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);
804
+
805
+ await closing;
806
+ await expect(joining).resolves.toBeDefined();
807
+ expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
808
+ });
809
+ });
810
+
553
811
  describe('Edge Cases and State Management', () => {
554
812
  it('should set state to CONNECTING when joining', async () => {
555
813
  expect(signalClient.currentState).toBe(SignalConnectionState.DISCONNECTED);
@@ -626,14 +884,68 @@ describe('SignalClient.handleSignalConnected', () => {
626
884
  const mockReadable = new ReadableStream<ArrayBuffer>();
627
885
  const mockConnection = createMockConnection(mockReadable);
628
886
 
887
+ // handleSignalConnected only ever runs with an attempt in flight, so establish that first
888
+ (signalClient as any).sendLifecycleInput({ type: 'connect' });
889
+
629
890
  // Access the method through a type assertion for testing
630
891
  const handleMethod = (signalClient as any).handleSignalConnected;
631
892
  if (handleMethod) {
632
- handleMethod.call(signalClient, mockConnection);
893
+ handleMethod.call(signalClient, mockConnection, undefined, (signalClient as any).attemptId);
633
894
  expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
634
895
  }
635
896
  });
636
897
 
898
+ it('discards a connection whose attempt was abandoned while awaiting the first message', () => {
899
+ const mockReadable = new ReadableStream<ArrayBuffer>();
900
+ const mockConnection = createMockConnection(mockReadable);
901
+ const setIntervalSpy = vi.spyOn(CriticalTimers, 'setInterval');
902
+ const getReaderSpy = vi.spyOn(mockConnection.readable, 'getReader');
903
+
904
+ // a previous session supplied the keepalive config, so arming really would start a timer
905
+ (signalClient as any).pingIntervalDuration = 10;
906
+ (signalClient as any).pingTimeoutDuration = 30;
907
+
908
+ // an attempt is in flight, and its transport opens...
909
+ (signalClient as any).sendLifecycleInput({ type: 'connect' });
910
+ const abandonedAttemptId = (signalClient as any).attemptId;
911
+
912
+ // ...but the caller gives up before the server's first message arrives
913
+ (signalClient as any).sendLifecycleInput({
914
+ type: 'connectFailed',
915
+ error: new Error('aborted'),
916
+ });
917
+ expect(signalClient.currentState).toBe(SignalConnectionState.DISCONNECTED);
918
+
919
+ // the first message lands anyway: it must not arm a heartbeat or a reader for a dead session
920
+ (signalClient as any).handleSignalConnected(mockConnection, undefined, abandonedAttemptId);
921
+
922
+ expect(signalClient.currentState).toBe(SignalConnectionState.DISCONNECTED);
923
+ expect(setIntervalSpy).not.toHaveBeenCalled();
924
+ expect(getReaderSpy).not.toHaveBeenCalled();
925
+ expect((signalClient as any).pingInterval).toBeUndefined();
926
+ });
927
+
928
+ it('still arms the heartbeat and reader for the attempt that owns the session', () => {
929
+ const mockReadable = new ReadableStream<ArrayBuffer>();
930
+ const mockConnection = createMockConnection(mockReadable);
931
+ const setIntervalSpy = vi.spyOn(CriticalTimers, 'setInterval');
932
+ const getReaderSpy = vi.spyOn(mockConnection.readable, 'getReader');
933
+
934
+ (signalClient as any).pingIntervalDuration = 10;
935
+ (signalClient as any).pingTimeoutDuration = 30;
936
+
937
+ (signalClient as any).sendLifecycleInput({ type: 'connect' });
938
+ (signalClient as any).handleSignalConnected(
939
+ mockConnection,
940
+ undefined,
941
+ (signalClient as any).attemptId,
942
+ );
943
+
944
+ expect(signalClient.currentState).toBe(SignalConnectionState.CONNECTED);
945
+ expect(setIntervalSpy).toHaveBeenCalled();
946
+ expect(getReaderSpy).toHaveBeenCalled();
947
+ });
948
+
637
949
  it('should start reading loop without first message', async () => {
638
950
  const joinResponse = createJoinResponse();
639
951
  const signalResponse = createSignalResponse('join', joinResponse);
@@ -700,8 +1012,8 @@ describe('SignalClient.validateFirstMessage', () => {
700
1012
  mockWebSocketStream({ connection: initialMockConnection });
701
1013
  await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);
702
1014
 
703
- // Set state to RECONNECTING to match the validation logic
704
- (signalClient as any).state = SignalConnectionState.RECONNECTING;
1015
+ // Move the lifecycle machine to reconnecting to match the validation logic
1016
+ (signalClient as any).sendLifecycleInput({ type: 'reconnect' });
705
1017
 
706
1018
  const reconnectResponse = new ReconnectResponse({ iceServers: [] });
707
1019
  const signalResponse = createSignalResponse('reconnect', reconnectResponse);
@@ -724,8 +1036,8 @@ describe('SignalClient.validateFirstMessage', () => {
724
1036
  mockWebSocketStream({ connection: initialMockConnection });
725
1037
  await signalClient.join('wss://test.livekit.io', 'test-token', defaultOptions);
726
1038
 
727
- // Set state to reconnecting
728
- (signalClient as any).state = SignalConnectionState.RECONNECTING;
1039
+ // Move the lifecycle machine to reconnecting
1040
+ (signalClient as any).sendLifecycleInput({ type: 'reconnect' });
729
1041
 
730
1042
  const updateSignalResponse = createSignalResponse('update', { participants: [] });
731
1043
 
@@ -739,8 +1051,8 @@ describe('SignalClient.validateFirstMessage', () => {
739
1051
  });
740
1052
 
741
1053
  it('should reject leave request during connection attempt', () => {
742
- // Set state to CONNECTING to be in establishing connection state
743
- (signalClient as any).state = SignalConnectionState.CONNECTING;
1054
+ // Move the lifecycle machine to connecting to be in establishing connection state
1055
+ (signalClient as any).sendLifecycleInput({ type: 'connect' });
744
1056
 
745
1057
  const leaveRequest = new LeaveRequest({ reason: 1 });
746
1058
  const signalResponse = createSignalResponse('leave', leaveRequest);