@livekit/rtc-node 0.13.27 → 0.13.28

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.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "LiveKit RTC Node",
4
4
  "license": "Apache-2.0",
5
5
  "author": "LiveKit",
6
- "version": "0.13.27",
6
+ "version": "0.13.28",
7
7
  "main": "dist/index.js",
8
8
  "require": "dist/index.cjs",
9
9
  "types": "dist/index.d.ts",
@@ -33,7 +33,7 @@
33
33
  "@datastructures-js/deque": "1.0.8",
34
34
  "@livekit/mutex": "^1.0.0",
35
35
  "@livekit/typed-emitter": "^3.0.0",
36
- "@livekit/rtc-ffi-bindings": "0.12.52-patch.0",
36
+ "@livekit/rtc-ffi-bindings": "0.12.56",
37
37
  "pino": "^9.0.0",
38
38
  "pino-pretty": "^13.0.0"
39
39
  },
@@ -44,7 +44,7 @@
44
44
  "prettier": "^3.0.3",
45
45
  "tsup": "^8.3.5",
46
46
  "typescript": "5.8.2",
47
- "livekit-server-sdk": "2.15.2"
47
+ "livekit-server-sdk": "2.15.3"
48
48
  },
49
49
  "engines": {
50
50
  "node": ">= 18"
@@ -53,6 +53,7 @@
53
53
  "prebuild": "node -p \"'export const SDK_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts",
54
54
  "build": "pnpm prebuild && tsup --onSuccess \"tsc --declaration --emitDeclarationOnly\"",
55
55
  "lint": "eslint -f unix \"src/**/*.ts\" --ignore-pattern \"src/proto/*\"",
56
- "test": "vitest run src"
56
+ "test": "vitest run src",
57
+ "test:e2e": "node scripts/run-e2e.mjs"
57
58
  }
58
59
  }
package/src/index.ts CHANGED
@@ -15,7 +15,11 @@ export type { E2EEOptions, KeyProviderOptions } from './e2ee.js';
15
15
  export { dispose } from './ffi_client.js';
16
16
  export { LocalParticipant, Participant, RemoteParticipant } from './participant.js';
17
17
  export { EncryptionState, EncryptionType } from '@livekit/rtc-ffi-bindings';
18
- export { DisconnectReason, ParticipantKind } from '@livekit/rtc-ffi-bindings';
18
+ export {
19
+ DisconnectReason,
20
+ ParticipantKind,
21
+ ParticipantKindDetail,
22
+ } from '@livekit/rtc-ffi-bindings';
19
23
  export {
20
24
  ConnectionQuality,
21
25
  ConnectionState,
@@ -25,7 +29,12 @@ export {
25
29
  IceTransportType,
26
30
  TrackPublishOptions,
27
31
  } from '@livekit/rtc-ffi-bindings';
28
- export { StreamState, TrackKind, TrackSource } from '@livekit/rtc-ffi-bindings';
32
+ export {
33
+ SimulateScenarioKind,
34
+ StreamState,
35
+ TrackKind,
36
+ TrackSource,
37
+ } from '@livekit/rtc-ffi-bindings';
29
38
  export { VideoBufferType, VideoCodec, VideoRotation } from '@livekit/rtc-ffi-bindings';
30
39
  export { ConnectError, Room, RoomEvent, type RoomOptions, type RtcConfiguration } from './room.js';
31
40
  export { RpcError, type PerformRpcParams, type RpcInvocationData } from './rpc.js';
@@ -7,6 +7,7 @@ import {
7
7
  type OwnedParticipant,
8
8
  type ParticipantInfo,
9
9
  ParticipantKind,
10
+ type ParticipantKindDetail,
10
11
  } from '@livekit/rtc-ffi-bindings';
11
12
  import {
12
13
  ChatMessage as ChatMessageModel,
@@ -128,6 +129,10 @@ export abstract class Participant {
128
129
  return this.info.kind ?? ParticipantKind.STANDARD;
129
130
  }
130
131
 
132
+ get kindDetails(): ParticipantKindDetail[] {
133
+ return this.info.kindDetails ?? [];
134
+ }
135
+
131
136
  get disconnectReason(): DisconnectReason | undefined {
132
137
  if (this.info.disconnectReason === DisconnectReason.UNKNOWN_REASON) {
133
138
  return undefined;
package/src/room.ts CHANGED
@@ -5,7 +5,11 @@ import { Mutex } from '@livekit/mutex';
5
5
  import { EncryptionState, type EncryptionType } from '@livekit/rtc-ffi-bindings';
6
6
  import type { FfiEvent } from '@livekit/rtc-ffi-bindings';
7
7
  import { DisconnectReason, type OwnedParticipant } from '@livekit/rtc-ffi-bindings';
8
- import type { DataStream_Trailer, DisconnectCallback } from '@livekit/rtc-ffi-bindings';
8
+ import type {
9
+ DataStream_Trailer,
10
+ DisconnectCallback,
11
+ TrackPublicationInfo,
12
+ } from '@livekit/rtc-ffi-bindings';
9
13
  import {
10
14
  type ConnectCallback,
11
15
  ConnectRequest,
@@ -21,6 +25,9 @@ import {
21
25
  type IceServer,
22
26
  IceTransportType,
23
27
  type RoomInfo,
28
+ type SimulateScenarioCallback,
29
+ type SimulateScenarioKind,
30
+ type SimulateScenarioResponse,
24
31
  } from '@livekit/rtc-ffi-bindings';
25
32
  import { TrackKind } from '@livekit/rtc-ffi-bindings';
26
33
  import type { TypedEventEmitter as TypedEmitter } from '@livekit/typed-emitter';
@@ -325,6 +332,36 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
325
332
  this.removeAllListeners();
326
333
  }
327
334
 
335
+ /**
336
+ * Trigger a reconnection / chaos scenario for testing. Most useful in
337
+ * tests to deterministically force a Resume (signal-only reconnect that
338
+ * preserves the PeerConnection and existing publications) or a full
339
+ * reconnect (the SDK rebuilds the RtcSession and re-publishes existing
340
+ * local tracks; `RoomEvent.Reconnected` fires).
341
+ */
342
+ async simulateScenario(scenario: SimulateScenarioKind): Promise<void> {
343
+ if (!this.isConnected || !this.ffiHandle) {
344
+ throw new Error('simulateScenario requires a connected room');
345
+ }
346
+ const res = FfiClient.instance.request<SimulateScenarioResponse>({
347
+ message: {
348
+ case: 'simulateScenario',
349
+ value: {
350
+ roomHandle: this.ffiHandle.handle,
351
+ scenario,
352
+ },
353
+ },
354
+ });
355
+ const cb = await FfiClient.instance.waitFor<SimulateScenarioCallback>(
356
+ (ev: FfiEvent) =>
357
+ ev.message.case === 'simulateScenario' && ev.message.value.asyncId === res.asyncId,
358
+ { signal: this.disconnectController.signal },
359
+ );
360
+ if (cb.error) {
361
+ throw new Error(`simulateScenario failed: ${cb.error}`);
362
+ }
363
+ }
364
+
328
365
  private updateConnectionState(newState: ConnectionState) {
329
366
  if (this._connectionState === newState) {
330
367
  return;
@@ -481,6 +518,19 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
481
518
  const publication = this.localParticipant.trackPublications.get(ev.value.publicationSid!);
482
519
  this.localParticipant.trackPublications.delete(ev.value.publicationSid!);
483
520
  this.emit(RoomEvent.LocalTrackUnpublished, publication!, this.localParticipant!);
521
+ } else if ((ev.case as string) == 'localTrackRepublished') {
522
+ const value = (ev as any).value;
523
+ const previousSid: string = value.previousSid!;
524
+ const newInfo: TrackPublicationInfo = value.info!;
525
+ const publication = this.localParticipant.trackPublications.get(previousSid);
526
+ if (publication) {
527
+ publication.updateInfo(newInfo);
528
+ this.localParticipant.trackPublications.delete(previousSid);
529
+ this.localParticipant.trackPublications.set(publication.sid!, publication);
530
+ this.emit(RoomEvent.LocalTrackRepublished, publication, previousSid, this.localParticipant);
531
+ } else {
532
+ log.warn(`RoomEvent.LocalTrackRepublished: previous publication not found: ${previousSid}`);
533
+ }
484
534
  } else if (ev.case == 'localTrackSubscribed') {
485
535
  const publication = this.localParticipant.trackPublications.get(ev.value.trackSid!);
486
536
  if (publication) {
@@ -903,6 +953,18 @@ export type RoomCallbacks = {
903
953
  publication: LocalTrackPublication,
904
954
  participant: LocalParticipant,
905
955
  ) => void;
956
+ /**
957
+ * Fired when the SDK auto-republished a local track during a full
958
+ * reconnect. The publication object's identity is preserved (the same
959
+ * instance is updated in place with the new server-assigned SIDs);
960
+ * `previousSid` is provided for callers that key external state on the
961
+ * old SID and need to reconcile.
962
+ */
963
+ localTrackRepublished: (
964
+ publication: LocalTrackPublication,
965
+ previousSid: string,
966
+ participant: LocalParticipant,
967
+ ) => void;
906
968
  localTrackSubscribed: (track: LocalTrack) => void;
907
969
  trackPublished: (publication: RemoteTrackPublication, participant: RemoteParticipant) => void;
908
970
  trackUnpublished: (publication: RemoteTrackPublication, participant: RemoteParticipant) => void;
@@ -960,6 +1022,7 @@ export enum RoomEvent {
960
1022
  ParticipantDisconnected = 'participantDisconnected',
961
1023
  LocalTrackPublished = 'localTrackPublished',
962
1024
  LocalTrackUnpublished = 'localTrackUnpublished',
1025
+ LocalTrackRepublished = 'localTrackRepublished',
963
1026
  LocalTrackSubscribed = 'localTrackSubscribed',
964
1027
  TrackPublished = 'trackPublished',
965
1028
  TrackUnpublished = 'trackUnpublished',
@@ -15,6 +15,8 @@ import {
15
15
  Room,
16
16
  RoomEvent,
17
17
  RpcError,
18
+ SimulateScenarioKind,
19
+ TrackKind,
18
20
  TrackPublishOptions,
19
21
  TrackSource,
20
22
  dispose,
@@ -682,4 +684,159 @@ describeE2E('livekit-rtc e2e', () => {
682
684
  },
683
685
  testTimeoutMs,
684
686
  );
687
+
688
+ // -- Reconnect scenarios --
689
+ //
690
+ // Both tests verify the user-visible behavior: after the scenario fires,
691
+ // the subscriber continues to receive the publisher's tone. The full
692
+ // reconnect test additionally asserts there is exactly one audio
693
+ // publication on each side (regression: duplicate-publish bug).
694
+
695
+ const runReconnectScenario = async (scenario: SimulateScenarioKind) => {
696
+ const { rooms } = await connectTestRooms(2);
697
+ const [subRoom, pubRoom] = rooms;
698
+
699
+ const pubRateHz = 48_000;
700
+ const source = new AudioSource(pubRateHz, 1);
701
+ const track = LocalAudioTrack.createAudioTrack('reconnect_tone', source);
702
+ const opts = new TrackPublishOptions();
703
+ opts.source = TrackSource.SOURCE_MICROPHONE;
704
+ await pubRoom!.localParticipant!.publishTrack(track, opts);
705
+
706
+ let tonePhase = 0;
707
+ const samplesPer10ms = Math.floor(pubRateHz / 100);
708
+ const amplitude = 0.8 * 32767;
709
+ const sineHz = 60;
710
+ let toneRunning = true;
711
+ const toneTask = (async () => {
712
+ while (toneRunning) {
713
+ const frame = AudioFrame.create(pubRateHz, 1, samplesPer10ms);
714
+ for (let s = 0; s < samplesPer10ms; s++) {
715
+ frame.data[s] = Math.round(
716
+ amplitude * Math.sin((2 * Math.PI * sineHz * tonePhase) / pubRateHz),
717
+ );
718
+ tonePhase++;
719
+ }
720
+ await source.captureFrame(frame);
721
+ }
722
+ })();
723
+
724
+ // Subscriber-side: re-attach an AudioStream every time TrackSubscribed
725
+ // fires (a full reconnect may issue TrackUnsubscribed → TrackSubscribed
726
+ // with a fresh remote track).
727
+ const sub = {
728
+ lastFrameAt: 0,
729
+ collectFromMs: Number.POSITIVE_INFINITY,
730
+ collected: [] as Int16Array[],
731
+ readers: [] as ReturnType<AudioStream['getReader']>[],
732
+ };
733
+ const attach = (remoteTrack: unknown) => {
734
+ const stream = new AudioStream(remoteTrack as any, {
735
+ sampleRate: pubRateHz,
736
+ numChannels: 1,
737
+ });
738
+ const reader = stream.getReader();
739
+ sub.readers.push(reader);
740
+ (async () => {
741
+ try {
742
+ while (true) {
743
+ const { done, value } = await reader.read();
744
+ if (done) break;
745
+ sub.lastFrameAt = Date.now();
746
+ if (sub.lastFrameAt >= sub.collectFromMs) {
747
+ sub.collected.push(channelSamples(value, 0));
748
+ }
749
+ }
750
+ } catch {
751
+ // reader released
752
+ }
753
+ })();
754
+ };
755
+ subRoom!.on(RoomEvent.TrackSubscribed, (t) => attach(t));
756
+
757
+ try {
758
+ await waitFor(() => sub.lastFrameAt > 0 && Date.now() - sub.lastFrameAt < 500, {
759
+ timeoutMs: 10_000,
760
+ debugName: 'initial audio flow',
761
+ });
762
+
763
+ const simulateAt = Date.now();
764
+ await pubRoom!.simulateScenario(scenario);
765
+
766
+ // Wait for audio to actually flow again post-simulate: a frame
767
+ // received well after the simulate AND a fresh latest-frame timestamp.
768
+ await waitFor(
769
+ () => sub.lastFrameAt >= simulateAt + 500 && Date.now() - sub.lastFrameAt < 300,
770
+ { timeoutMs: 30_000, debugName: 'audio re-established after simulate' },
771
+ );
772
+ // Drain post-recovery buffer/jitter, then collect a 2s window of
773
+ // steady-state samples for tone detection.
774
+ await delay(1_500);
775
+ sub.collected.length = 0;
776
+ sub.collectFromMs = Date.now();
777
+ await waitFor(() => sub.collected.reduce((a, s) => a + s.length, 0) >= pubRateHz * 2, {
778
+ timeoutMs: 15_000,
779
+ debugName: 'post-simulate audio sampling',
780
+ });
781
+
782
+ const totalLen = sub.collected.reduce((a, s) => a + s.length, 0);
783
+ const concat = new Int16Array(totalLen);
784
+ let off = 0;
785
+ for (const s of sub.collected) {
786
+ concat.set(s, off);
787
+ off += s.length;
788
+ }
789
+ const detected = estimateFreqHz(concat, pubRateHz);
790
+ // Wider tolerance than the clean-path sine test: post-reconnect
791
+ // audio has brief discontinuities, and the autocorrelation is
792
+ // integer-lag (next neighbors to 60Hz are exactly 80Hz/40Hz), so
793
+ // ±20Hz lands right on the failure boundary under CI load.
794
+ expect(Math.abs(detected - sineHz)).toBeLessThan(25);
795
+
796
+ return { rooms, subRoom: subRoom!, pubRoom: pubRoom! };
797
+ } finally {
798
+ toneRunning = false;
799
+ await toneTask;
800
+ for (const r of sub.readers) {
801
+ try {
802
+ r.releaseLock();
803
+ } catch {
804
+ // ignore
805
+ }
806
+ }
807
+ await track.close();
808
+ }
809
+ };
810
+
811
+ itRaw(
812
+ 'resume keeps audio flowing on the subscriber side',
813
+ async () => {
814
+ const { rooms } = await runReconnectScenario(SimulateScenarioKind.SIMULATE_SIGNAL_RECONNECT);
815
+ await Promise.all(rooms.map((r) => r.disconnect()));
816
+ },
817
+ testTimeoutMs * 4,
818
+ );
819
+
820
+ itRaw(
821
+ 'full reconnect keeps audio flowing and ends with one publication on the subscriber',
822
+ async () => {
823
+ const { rooms, subRoom, pubRoom } = await runReconnectScenario(
824
+ SimulateScenarioKind.SIMULATE_FULL_RECONNECT,
825
+ );
826
+
827
+ try {
828
+ // Regression: subscriber must see exactly ONE audio publication after
829
+ // recovery — not duplicates from the auto-republish path.
830
+ const subscriberAudioPubs = Array.from(
831
+ subRoom.remoteParticipants
832
+ .get(pubRoom.localParticipant!.identity)!
833
+ .trackPublications.values(),
834
+ ).filter((p) => p.kind === TrackKind.KIND_AUDIO);
835
+ expect(subscriberAudioPubs.length).toBe(1);
836
+ } finally {
837
+ await Promise.all(rooms.map((r) => r.disconnect()));
838
+ }
839
+ },
840
+ testTimeoutMs * 4,
841
+ );
685
842
  });
@@ -66,6 +66,18 @@ export abstract class TrackPublication {
66
66
  get encryptionType(): EncryptionType | undefined {
67
67
  return this.info?.encryptionType;
68
68
  }
69
+
70
+ /**
71
+ * Update the publication's info in place. Used by the SDK when the
72
+ * server re-issues IDs / metadata for an existing publication (e.g.
73
+ * after a full reconnect). Application code holding a cached
74
+ * publication reference continues to read fresh values via the
75
+ * unchanged object identity.
76
+ * @internal
77
+ */
78
+ updateInfo(info: TrackPublicationInfo): void {
79
+ this.info = info;
80
+ }
69
81
  }
70
82
 
71
83
  export class LocalTrackPublication extends TrackPublication {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = "0.13.27";
1
+ export const SDK_VERSION = "0.13.28";