@livekit/rtc-node 0.13.28 → 0.13.30

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 (76) hide show
  1. package/dist/audio_stream-B4hGVcKz.d.cts +498 -0
  2. package/dist/audio_stream-DEG1JKge.d.ts +498 -0
  3. package/dist/audio_stream.cjs +21 -6
  4. package/dist/audio_stream.cjs.map +1 -1
  5. package/dist/audio_stream.d.cts +12 -22
  6. package/dist/audio_stream.d.ts +12 -22
  7. package/dist/audio_stream.d.ts.map +1 -1
  8. package/dist/audio_stream.js +19 -5
  9. package/dist/audio_stream.js.map +1 -1
  10. package/dist/data_streams/index.d.cts +1 -1
  11. package/dist/data_streams/index.d.ts +1 -1
  12. package/dist/data_streams/stream_reader.d.cts +1 -1
  13. package/dist/data_streams/stream_reader.d.ts +1 -1
  14. package/dist/data_streams/stream_writer.d.cts +1 -1
  15. package/dist/data_streams/stream_writer.d.ts +1 -1
  16. package/dist/data_streams/types.d.cts +1 -1
  17. package/dist/data_streams/types.d.ts +1 -1
  18. package/dist/ffi_client.cjs +11 -7
  19. package/dist/ffi_client.cjs.map +1 -1
  20. package/dist/ffi_client.d.cts +2 -0
  21. package/dist/ffi_client.d.ts +2 -0
  22. package/dist/ffi_client.d.ts.map +1 -1
  23. package/dist/ffi_client.js +11 -7
  24. package/dist/ffi_client.js.map +1 -1
  25. package/dist/frame_processor.cjs +6 -0
  26. package/dist/frame_processor.cjs.map +1 -1
  27. package/dist/frame_processor.d.cts +2 -0
  28. package/dist/frame_processor.d.ts +2 -0
  29. package/dist/frame_processor.d.ts.map +1 -1
  30. package/dist/frame_processor.js +6 -0
  31. package/dist/frame_processor.js.map +1 -1
  32. package/dist/index.d.cts +4 -7
  33. package/dist/index.d.ts +4 -7
  34. package/dist/participant.cjs +6 -0
  35. package/dist/participant.cjs.map +1 -1
  36. package/dist/participant.d.cts +14 -153
  37. package/dist/participant.d.ts +14 -153
  38. package/dist/participant.d.ts.map +1 -1
  39. package/dist/participant.js +6 -0
  40. package/dist/participant.js.map +1 -1
  41. package/dist/room.cjs +63 -0
  42. package/dist/room.cjs.map +1 -1
  43. package/dist/room.d.cts +12 -219
  44. package/dist/room.d.ts +12 -219
  45. package/dist/room.d.ts.map +1 -1
  46. package/dist/room.js +63 -0
  47. package/dist/room.js.map +1 -1
  48. package/dist/track.cjs +123 -0
  49. package/dist/track.cjs.map +1 -1
  50. package/dist/track.d.cts +15 -42
  51. package/dist/track.d.ts +15 -42
  52. package/dist/track.d.ts.map +1 -1
  53. package/dist/track.js +123 -0
  54. package/dist/track.js.map +1 -1
  55. package/dist/track_publication.d.cts +14 -47
  56. package/dist/track_publication.d.ts +14 -47
  57. package/dist/{stream_reader-CuG4b8Y-.d.cts → types-_PdPVish.d.cts} +40 -40
  58. package/dist/{stream_reader-CuG4b8Y-.d.ts → types-_PdPVish.d.ts} +40 -40
  59. package/dist/version.cjs +1 -1
  60. package/dist/version.cjs.map +1 -1
  61. package/dist/version.d.cts +1 -1
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/dist/version.js.map +1 -1
  65. package/dist/video_stream.d.cts +12 -2
  66. package/dist/video_stream.d.ts +12 -2
  67. package/package.json +4 -4
  68. package/src/audio_stream.ts +28 -4
  69. package/src/audio_stream_room_lifecycle.test.ts +753 -0
  70. package/src/bun_runtime.test.ts +27 -0
  71. package/src/ffi_client.ts +6 -0
  72. package/src/frame_processor.ts +4 -0
  73. package/src/participant.ts +17 -0
  74. package/src/room.ts +89 -1
  75. package/src/track.ts +141 -0
  76. package/src/version.ts +1 -1
@@ -0,0 +1,27 @@
1
+ // SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ // Guard: the "Bun" CI job runs vitest via `bun --bun run test` so test bodies
7
+ // execute on the bun runtime (with vitest still providing module mocking). The
8
+ // `--bun` flag is load-bearing — without it, bun honors vitest's
9
+ // `#!/usr/bin/env node` shebang and silently runs everything under node, making
10
+ // the bun job a meaningless duplicate of the node job.
11
+ //
12
+ // When EXPECT_BUN_RUNTIME=1 (set only by the bun CI step), assert that this test
13
+ // body is actually executing under bun. It runs through the exact same vitest
14
+ // path as the real suite, so it catches a regression where the runtime falls
15
+ // back to node. In every other run (the node CI job, local dev) the var is
16
+ // unset and this is skipped.
17
+ describe('bun runtime guard', () => {
18
+ const enforced = process.env.EXPECT_BUN_RUNTIME === '1';
19
+
20
+ it.skipIf(!enforced)('test bodies execute on the bun runtime', () => {
21
+ expect(
22
+ process.versions.bun,
23
+ 'EXPECT_BUN_RUNTIME=1 but tests are not running under bun — `bun --bun run test` ' +
24
+ 'fell back to node (check the --bun flag / invocation).',
25
+ ).toBeTruthy();
26
+ });
27
+ });
package/src/ffi_client.ts CHANGED
@@ -41,6 +41,8 @@ export class FfiClient extends (EventEmitter as new () => TypedEmitter<FfiClient
41
41
  return globalThis._ffiClientInstance;
42
42
  }
43
43
 
44
+ private _nextRequestAsyncId = BigInt(0);
45
+
44
46
  constructor() {
45
47
  super();
46
48
  this.setMaxListeners(0);
@@ -70,6 +72,10 @@ export class FfiClient extends (EventEmitter as new () => TypedEmitter<FfiClient
70
72
  return livekitRetrievePtr(data);
71
73
  }
72
74
 
75
+ getNextRequestAsyncId() {
76
+ return ++this._nextRequestAsyncId;
77
+ }
78
+
73
79
  async waitFor<T>(
74
80
  predicate: (ev: FfiEvent) => boolean,
75
81
  options?: { signal?: AbortSignal },
@@ -43,7 +43,11 @@ export abstract class FrameProcessor<Frame extends VideoFrame | AudioFrame> {
43
43
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
44
44
  onStreamInfoUpdated(_info: FrameProcessorStreamInfo): void {}
45
45
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
46
+ onStreamInfoCleared(): void {}
47
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
46
48
  onCredentialsUpdated(_credentials: FrameProcessorCredentials): void {}
49
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
50
+ onCredentialsCleared(): void {}
47
51
 
48
52
  abstract process(frame: Frame): Frame;
49
53
  abstract close(): void;
@@ -731,6 +731,14 @@ export class LocalParticipant extends Participant {
731
731
  case 'publication':
732
732
  const track_publication = new LocalTrackPublication(cb.message.value!);
733
733
  track_publication.track = track;
734
+ // Stamp the server-assigned publication
735
+ // SID onto the track so track.sid == publication.sid. Both
736
+ // Track.pushProcessorMetadataToStream and the localTrackRepublished
737
+ // handler look up the local publication by this SID; without it they
738
+ // depend on createAudioTrack's provisional SID happening to match.
739
+ if (track.info && track_publication.sid) {
740
+ track.info.sid = track_publication.sid;
741
+ }
734
742
  this.trackPublications.set(track_publication.sid!, track_publication);
735
743
 
736
744
  return track_publication;
@@ -767,6 +775,15 @@ export class LocalParticipant extends Participant {
767
775
 
768
776
  const pub = this.trackPublications.get(trackSid);
769
777
  if (pub) {
778
+ // Clear the processor's room context here too: this path races the
779
+ // localTrackUnpublished room event, and whichever loses finds the
780
+ // publication already gone and skips its own setRoom(null). Calling it
781
+ // from both paths guarantees the processor is cleared (and the
782
+ // tokenRefreshed listener detached); setRoom(null) is idempotent, so a
783
+ // double-clear when this path wins is safe.
784
+ if (pub.track) {
785
+ pub.track.setRoom(null);
786
+ }
770
787
  pub.track = undefined;
771
788
  }
772
789
  this.trackPublications.delete(trackSid);
package/src/room.ts CHANGED
@@ -3,7 +3,11 @@
3
3
  // SPDX-License-Identifier: Apache-2.0
4
4
  import { Mutex } from '@livekit/mutex';
5
5
  import { EncryptionState, type EncryptionType } from '@livekit/rtc-ffi-bindings';
6
- import type { FfiEvent } from '@livekit/rtc-ffi-bindings';
6
+ import type {
7
+ FfiEvent,
8
+ GetSessionStatsCallback,
9
+ GetSessionStatsResponse,
10
+ } from '@livekit/rtc-ffi-bindings';
7
11
  import { DisconnectReason, type OwnedParticipant } from '@livekit/rtc-ffi-bindings';
8
12
  import type {
9
13
  DataStream_Trailer,
@@ -24,6 +28,7 @@ import {
24
28
  RoomOptions as FfiRoomOptions,
25
29
  type IceServer,
26
30
  IceTransportType,
31
+ type ReadyForRoomEventResponse,
27
32
  type RoomInfo,
28
33
  type SimulateScenarioCallback,
29
34
  type SimulateScenarioKind,
@@ -191,6 +196,36 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
191
196
  return this.sidPromise;
192
197
  }
193
198
 
199
+ async getRtcStats() {
200
+ if (!this.isConnected || !this.ffiHandle) {
201
+ throw new Error('getRtcStats requires a connected room');
202
+ }
203
+ // subscribe before issuing the request
204
+ const requestId = FfiClient.instance.getNextRequestAsyncId();
205
+ const cbPromise = FfiClient.instance.waitFor<GetSessionStatsCallback>(
206
+ (ev: FfiEvent) =>
207
+ ev.message.case === 'getSessionStats' && ev.message.value.asyncId === requestId,
208
+ { signal: this.disconnectController.signal },
209
+ );
210
+ FfiClient.instance.request<GetSessionStatsResponse>({
211
+ message: {
212
+ case: 'getSessionStats',
213
+ value: {
214
+ roomHandle: this.ffiHandle.handle,
215
+ requestAsyncId: requestId,
216
+ },
217
+ },
218
+ });
219
+ const cb = await cbPromise;
220
+ if (cb.message.case === 'error') {
221
+ throw new Error(cb.message.value);
222
+ } else if (cb.message.case === 'result') {
223
+ return cb.message.value;
224
+ } else {
225
+ throw new Error('could not retrieve rtc stats');
226
+ }
227
+ }
228
+
194
229
  get numParticipants(): number {
195
230
  return this.info?.numParticipants ?? 0;
196
231
  }
@@ -292,6 +327,15 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
292
327
  }
293
328
  }
294
329
  this.updateConnectionState(ConnectionState.CONN_CONNECTED);
330
+ // Release the FFI room event gate after our listener and local state are ready.
331
+ FfiClient.instance.request<ReadyForRoomEventResponse>({
332
+ message: {
333
+ case: 'readyForRoomEvent',
334
+ value: {
335
+ roomHandle: this.ffiHandle.handle,
336
+ },
337
+ },
338
+ });
295
339
  break;
296
340
  case 'error':
297
341
  default:
@@ -400,6 +444,23 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
400
444
  }
401
445
  this.textStreamControllers.clear();
402
446
 
447
+ // Detach every track from this room so attached FrameProcessors receive
448
+ // onStreamInfoCleared/onCredentialsCleared and the tokenRefreshed listener
449
+ // is removed. Otherwise a server-initiated disconnect leaves processors with
450
+ // stale room context and the Room holding a strong ref to each Track's bound
451
+ // listener until GC. setRoom(null) is idempotent, so this is safe even if a
452
+ // track was already detached (e.g. via unsubscribe/unpublish).
453
+ if (this.localParticipant) {
454
+ for (const pub of this.localParticipant.trackPublications.values()) {
455
+ pub.track?.setRoom(null);
456
+ }
457
+ }
458
+ for (const participant of this.remoteParticipants.values()) {
459
+ for (const pub of participant.trackPublications.values()) {
460
+ pub.track?.setRoom(null);
461
+ }
462
+ }
463
+
403
464
  // Clear sidPromise before removing listeners so that a reconnect
404
465
  // doesn't return a stale, permanently-pending promise.
405
466
  this.sidPromise = undefined;
@@ -513,11 +574,27 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
513
574
  }
514
575
  } else if (ev.case == 'localTrackPublished') {
515
576
  const publication = this.localParticipant.trackPublications.get(ev.value.trackSid!);
577
+ if (publication?.track) {
578
+ publication.track.setRoom(this);
579
+ }
516
580
  this.emit(RoomEvent.LocalTrackPublished, publication!, this.localParticipant);
517
581
  } else if (ev.case == 'localTrackUnpublished') {
518
582
  const publication = this.localParticipant.trackPublications.get(ev.value.publicationSid!);
583
+ const track = publication?.track;
584
+ if (track) {
585
+ track.setRoom(null);
586
+ }
519
587
  this.localParticipant.trackPublications.delete(ev.value.publicationSid!);
588
+ // Emit while `publication.track` is still set, preserving the pre-existing
589
+ // payload for callbacks. The handler is synchronous, so nulling the track
590
+ // right after still completes before any other turn can observe it.
520
591
  this.emit(RoomEvent.LocalTrackUnpublished, publication!, this.localParticipant!);
592
+ // Mirror trackUnsubscribed: drop the publication's track reference. This
593
+ // also makes unpublishTrack's own setRoom(null) a no-op when it loses the
594
+ // race (its `pub.track` guard short-circuits), avoiding a redundant clear.
595
+ if (track && publication) {
596
+ publication.track = undefined;
597
+ }
521
598
  } else if ((ev.case as string) == 'localTrackRepublished') {
522
599
  const value = (ev as any).value;
523
600
  const previousSid: string = value.previousSid!;
@@ -527,6 +604,15 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
527
604
  publication.updateInfo(newInfo);
528
605
  this.localParticipant.trackPublications.delete(previousSid);
529
606
  this.localParticipant.trackPublications.set(publication.sid!, publication);
607
+ if (publication.track?.info) {
608
+ // Keep the local-track invariant (track.sid == publication.sid, set at
609
+ // publishTrack) intact across republish, then re-push metadata so any
610
+ // attached FrameProcessor learns the new publication SID / credentials.
611
+ // setRoom with the same room is a no-op for the tokenRefreshed listener
612
+ // but re-fans the metadata to every registered AudioStream.
613
+ publication.track.info.sid = publication.sid;
614
+ publication.track.setRoom(this);
615
+ }
530
616
  this.emit(RoomEvent.LocalTrackRepublished, publication, previousSid, this.localParticipant);
531
617
  } else {
532
618
  log.warn(`RoomEvent.LocalTrackRepublished: previous publication not found: ${previousSid}`);
@@ -576,6 +662,7 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
576
662
  } else if (trackInfo.kind == TrackKind.KIND_AUDIO) {
577
663
  publication.track = new RemoteAudioTrack(ownedTrack);
578
664
  }
665
+ publication.track?.setRoom(this);
579
666
 
580
667
  this.emit(RoomEvent.TrackSubscribed, publication.track!, publication, participant);
581
668
  } catch (e: unknown) {
@@ -588,6 +675,7 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
588
675
  ev.value.trackSid!,
589
676
  );
590
677
  const track = publication.track!;
678
+ track.setRoom(null);
591
679
  publication.track = undefined;
592
680
  publication.subscribed = false;
593
681
  this.emit(RoomEvent.TrackUnsubscribed, track, publication, participant);
package/src/track.ts CHANGED
@@ -11,7 +11,9 @@ import type {
11
11
  } from '@livekit/rtc-ffi-bindings';
12
12
  import { CreateAudioTrackRequest, CreateVideoTrackRequest } from '@livekit/rtc-ffi-bindings';
13
13
  import type { AudioSource } from './audio_source.js';
14
+ import type { AudioStreamSource } from './audio_stream.js';
14
15
  import { FfiClient, FfiHandle } from './ffi_client.js';
16
+ import type { Room } from './room.js';
15
17
  import type { VideoSource } from './video_source.js';
16
18
 
17
19
  export abstract class Track {
@@ -21,9 +23,148 @@ export abstract class Track {
21
23
  /** @internal */
22
24
  ffi_handle: FfiHandle;
23
25
 
26
+ private roomRef: WeakRef<Room> | null = null;
27
+ private audioStreams: Set<WeakRef<AudioStreamSource>> = new Set();
28
+ private streamFinalizationRegistry: FinalizationRegistry<WeakRef<AudioStreamSource>>;
29
+ private onRoomTokenRefreshed = () => {
30
+ const room = this.resolveRoom();
31
+ if (!room || !room.token || !room.serverUrl) {
32
+ return;
33
+ }
34
+ for (const stream of this.iterateStreams()) {
35
+ const processor = stream.processor;
36
+ if (!processor) {
37
+ continue;
38
+ }
39
+ processor.onCredentialsUpdated({ token: room.token, url: room.serverUrl });
40
+ }
41
+ };
42
+
24
43
  constructor(owned: OwnedTrack) {
25
44
  this.info = owned.info;
26
45
  this.ffi_handle = new FfiHandle(owned.handle!.id!);
46
+ this.streamFinalizationRegistry = new FinalizationRegistry<WeakRef<AudioStreamSource>>(
47
+ (ref) => {
48
+ this.audioStreams.delete(ref);
49
+ },
50
+ );
51
+ }
52
+
53
+ /** @internal */
54
+ resolveRoom(): Room | null {
55
+ return this.roomRef?.deref() ?? null;
56
+ }
57
+
58
+ /** @internal */
59
+ setRoom(room: Room | null): void {
60
+ const oldRoom = this.resolveRoom();
61
+ if (!oldRoom && !room) {
62
+ // Already roomless — nothing to detach and nothing to re-clear. Without
63
+ // this guard a second setRoom(null) (e.g. the unpublishTrack /
64
+ // localTrackUnpublished race calling it from both paths) would re-fire
65
+ // onStreamInfoCleared / onCredentialsCleared on every registered processor.
66
+ return;
67
+ }
68
+ if (oldRoom && oldRoom !== room) {
69
+ oldRoom.off('tokenRefreshed', this.onRoomTokenRefreshed);
70
+ }
71
+ if (room) {
72
+ room.off('tokenRefreshed', this.onRoomTokenRefreshed);
73
+ room.on('tokenRefreshed', this.onRoomTokenRefreshed);
74
+ }
75
+ this.roomRef = room ? new WeakRef(room) : null;
76
+ for (const stream of this.iterateStreams()) {
77
+ this.pushProcessorMetadataToStream(stream, room);
78
+ }
79
+ }
80
+
81
+ /** @internal */
82
+ registerAudioStream(stream: AudioStreamSource): void {
83
+ const ref = new WeakRef(stream);
84
+ this.audioStreams.add(ref);
85
+ this.streamFinalizationRegistry.register(stream, ref);
86
+ const room = this.resolveRoom();
87
+ if (room) {
88
+ this.pushProcessorMetadataToStream(stream, room);
89
+ }
90
+ }
91
+
92
+ /** @internal */
93
+ unregisterAudioStream(stream: AudioStreamSource): void {
94
+ for (const ref of this.audioStreams) {
95
+ if (ref.deref() === stream) {
96
+ this.audioStreams.delete(ref);
97
+ return;
98
+ }
99
+ }
100
+ }
101
+
102
+ private *iterateStreams(): Generator<AudioStreamSource> {
103
+ const dead: Array<WeakRef<AudioStreamSource>> = [];
104
+ for (const ref of this.audioStreams) {
105
+ const stream = ref.deref();
106
+ if (stream) {
107
+ yield stream;
108
+ } else {
109
+ dead.push(ref);
110
+ }
111
+ }
112
+ for (const ref of dead) {
113
+ this.audioStreams.delete(ref);
114
+ }
115
+ }
116
+
117
+ private pushProcessorMetadataToStream(stream: AudioStreamSource, room: Room | null): void {
118
+ const processor = stream.processor;
119
+ if (!processor) {
120
+ return;
121
+ }
122
+
123
+ if (!room) {
124
+ // Guard with optional-call: plugins built against an older @livekit/rtc-node
125
+ // inherit a FrameProcessor base class that doesn't define these methods,
126
+ // so they could be undefined on the prototype chain.
127
+ processor.onStreamInfoCleared?.();
128
+ processor.onCredentialsCleared?.();
129
+ return;
130
+ }
131
+
132
+ let identity = '';
133
+ let publicationSid = '';
134
+ const trackSid = this.sid;
135
+ if (trackSid) {
136
+ let found = false;
137
+ for (const participant of room.remoteParticipants.values()) {
138
+ const publication = participant.trackPublications.get(trackSid);
139
+ if (publication) {
140
+ identity = participant.identity;
141
+ publicationSid = publication.sid ?? '';
142
+ found = true;
143
+ break;
144
+ }
145
+ }
146
+ if (!found) {
147
+ const local = room.localParticipant;
148
+ if (local) {
149
+ for (const publication of local.trackPublications.values()) {
150
+ if (publication.sid === trackSid) {
151
+ identity = local.identity;
152
+ publicationSid = publication.sid ?? '';
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ processor.onStreamInfoUpdated({
161
+ roomName: room.name ?? '',
162
+ participantIdentity: identity,
163
+ publicationSid,
164
+ });
165
+ if (room.token && room.serverUrl) {
166
+ processor.onCredentialsUpdated({ token: room.token, url: room.serverUrl });
167
+ }
27
168
  }
28
169
 
29
170
  get sid(): string | undefined {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = "0.13.28";
1
+ export const SDK_VERSION = "0.13.30";