@livekit/rtc-node 0.13.32 → 0.13.34

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 (141) hide show
  1. package/dist/async_queue.d.ts +2 -3
  2. package/dist/audio_filter.d.ts +2 -3
  3. package/dist/audio_frame.d.ts +5 -6
  4. package/dist/audio_mixer.d.ts +3 -6
  5. package/dist/audio_resampler.d.ts +3 -6
  6. package/dist/audio_source.d.ts +5 -6
  7. package/dist/{audio_stream-4M8XlO4-.d.cts → audio_stream-BNVaONuH.d.cts} +36 -10
  8. package/dist/audio_stream.d.cts +2 -2
  9. package/dist/audio_stream.d.ts +61 -18
  10. package/dist/data_streams/index.d.cts +1 -1
  11. package/dist/data_streams/index.d.ts +4 -3
  12. package/dist/data_streams/stream_reader.cjs +97 -58
  13. package/dist/data_streams/stream_reader.cjs.map +1 -1
  14. package/dist/data_streams/stream_reader.d.cts +1 -1
  15. package/dist/data_streams/stream_reader.d.ts +65 -2
  16. package/dist/data_streams/stream_reader.d.ts.map +1 -1
  17. package/dist/data_streams/stream_writer.d.cts +1 -1
  18. package/dist/data_streams/stream_writer.d.ts +5 -7
  19. package/dist/data_streams/types.cjs.map +1 -1
  20. package/dist/data_streams/types.d.cts +1 -1
  21. package/dist/data_streams/types.d.ts +47 -2
  22. package/dist/data_streams/types.d.ts.map +1 -1
  23. package/dist/e2ee.cjs +5 -1
  24. package/dist/e2ee.cjs.map +1 -1
  25. package/dist/e2ee.d.cts +3 -1
  26. package/dist/e2ee.d.ts +11 -11
  27. package/dist/e2ee.d.ts.map +1 -1
  28. package/dist/ffi_client.d.ts +8 -10
  29. package/dist/frame_processor.d.ts +8 -11
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.d.cts +2 -2
  32. package/dist/index.d.ts +22 -17
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -107
  35. package/dist/log.d.ts +5 -7
  36. package/dist/participant.cjs +179 -192
  37. package/dist/participant.cjs.map +1 -1
  38. package/dist/participant.d.cts +2 -2
  39. package/dist/participant.d.ts +169 -18
  40. package/dist/participant.d.ts.map +1 -1
  41. package/dist/room.cjs +166 -120
  42. package/dist/room.cjs.map +1 -1
  43. package/dist/room.d.cts +2 -2
  44. package/dist/room.d.ts +228 -18
  45. package/dist/room.d.ts.map +1 -1
  46. package/dist/rpc.d.ts +7 -9
  47. package/dist/tests/e2e_common.cjs +157 -0
  48. package/dist/tests/e2e_common.cjs.map +1 -0
  49. package/dist/tests/e2e_common.d.cts +56 -0
  50. package/dist/tests/e2e_common.d.ts +37 -0
  51. package/dist/tests/e2e_common.d.ts.map +1 -0
  52. package/dist/track.d.cts +2 -2
  53. package/dist/track.d.ts +67 -18
  54. package/dist/track_publication.d.cts +2 -2
  55. package/dist/track_publication.d.ts +45 -18
  56. package/dist/transcription.d.ts +3 -4
  57. package/dist/{types-_PdPVish.d.ts → types-f7wTlFjs.d.cts} +34 -11
  58. package/dist/types.d.ts +2 -3
  59. package/dist/utils.cjs +27 -2
  60. package/dist/utils.cjs.map +1 -1
  61. package/dist/utils.d.cts +8 -1
  62. package/dist/utils.d.ts +10 -5
  63. package/dist/utils.d.ts.map +1 -1
  64. package/dist/version.cjs +1 -1
  65. package/dist/version.cjs.map +1 -1
  66. package/dist/version.d.cts +1 -1
  67. package/dist/version.d.ts +2 -3
  68. package/dist/video_frame.d.ts +4 -5
  69. package/dist/video_source.d.ts +6 -8
  70. package/dist/video_stream.d.cts +2 -2
  71. package/dist/video_stream.d.ts +5 -22
  72. package/package.json +6 -7
  73. package/src/audio_stream_room_lifecycle.test.ts +4 -1
  74. package/src/data_streams/stream_reader.ts +132 -82
  75. package/src/data_streams/types.ts +6 -0
  76. package/src/e2ee.ts +7 -0
  77. package/src/index.ts +8 -1
  78. package/src/participant.ts +252 -223
  79. package/src/room.ts +233 -134
  80. package/src/tests/e2e.test.ts +50 -139
  81. package/src/tests/e2e_common.ts +163 -0
  82. package/src/tests/e2e_data_streams.test.ts +639 -0
  83. package/src/utils.ts +30 -0
  84. package/src/version.ts +1 -1
  85. package/dist/async_queue.js +0 -56
  86. package/dist/async_queue.js.map +0 -1
  87. package/dist/audio_filter.js +0 -24
  88. package/dist/audio_filter.js.map +0 -1
  89. package/dist/audio_frame.js +0 -78
  90. package/dist/audio_frame.js.map +0 -1
  91. package/dist/audio_mixer.js +0 -269
  92. package/dist/audio_mixer.js.map +0 -1
  93. package/dist/audio_resampler.js +0 -154
  94. package/dist/audio_resampler.js.map +0 -1
  95. package/dist/audio_source.js +0 -134
  96. package/dist/audio_source.js.map +0 -1
  97. package/dist/audio_stream-CTyN1Ldy.d.ts +0 -523
  98. package/dist/audio_stream.js +0 -122
  99. package/dist/audio_stream.js.map +0 -1
  100. package/dist/data_streams/index.js +0 -3
  101. package/dist/data_streams/index.js.map +0 -1
  102. package/dist/data_streams/stream_reader.js +0 -124
  103. package/dist/data_streams/stream_reader.js.map +0 -1
  104. package/dist/data_streams/stream_writer.js +0 -26
  105. package/dist/data_streams/stream_writer.js.map +0 -1
  106. package/dist/data_streams/types.js +0 -1
  107. package/dist/data_streams/types.js.map +0 -1
  108. package/dist/e2ee.js +0 -251
  109. package/dist/e2ee.js.map +0 -1
  110. package/dist/ffi_client.js +0 -89
  111. package/dist/ffi_client.js.map +0 -1
  112. package/dist/frame_processor.js +0 -29
  113. package/dist/frame_processor.js.map +0 -1
  114. package/dist/index.js.map +0 -1
  115. package/dist/log.js +0 -9
  116. package/dist/log.js.map +0 -1
  117. package/dist/participant.js +0 -715
  118. package/dist/participant.js.map +0 -1
  119. package/dist/room.js +0 -900
  120. package/dist/room.js.map +0 -1
  121. package/dist/rpc.js +0 -65
  122. package/dist/rpc.js.map +0 -1
  123. package/dist/track.js +0 -234
  124. package/dist/track.js.map +0 -1
  125. package/dist/track_publication.js +0 -100
  126. package/dist/track_publication.js.map +0 -1
  127. package/dist/transcription.js +0 -1
  128. package/dist/transcription.js.map +0 -1
  129. package/dist/types-_PdPVish.d.cts +0 -87
  130. package/dist/types.js +0 -1
  131. package/dist/types.js.map +0 -1
  132. package/dist/utils.js +0 -35
  133. package/dist/utils.js.map +0 -1
  134. package/dist/version.js +0 -5
  135. package/dist/version.js.map +0 -1
  136. package/dist/video_frame.js +0 -207
  137. package/dist/video_frame.js.map +0 -1
  138. package/dist/video_source.js +0 -53
  139. package/dist/video_source.js.map +0 -1
  140. package/dist/video_stream.js +0 -70
  141. package/dist/video_stream.js.map +0 -1
package/src/room.ts CHANGED
@@ -9,12 +9,10 @@ import type {
9
9
  GetSessionStatsResponse,
10
10
  } from '@livekit/rtc-ffi-bindings';
11
11
  import { DisconnectReason, type OwnedParticipant } from '@livekit/rtc-ffi-bindings';
12
- import type {
13
- DataStream_Trailer,
14
- DisconnectCallback,
15
- TrackPublicationInfo,
16
- } from '@livekit/rtc-ffi-bindings';
12
+ import { type DisconnectCallback, type TrackPublicationInfo } from '@livekit/rtc-ffi-bindings';
17
13
  import {
14
+ ByteStreamReaderReadIncrementalRequest,
15
+ type ByteStreamReaderReadIncrementalResponse,
18
16
  type ConnectCallback,
19
17
  ConnectRequest,
20
18
  type ConnectResponse,
@@ -22,29 +20,27 @@ import {
22
20
  ConnectionState,
23
21
  ContinualGatheringPolicy,
24
22
  type DataPacketKind,
25
- type DataStream_Chunk,
26
- type DataStream_Header,
23
+ DataStream_Chunk,
27
24
  type DisconnectResponse,
25
+ RoomDataStreamOptions as FfiRoomDataStreamOptions,
28
26
  RoomOptions as FfiRoomOptions,
29
27
  type IceServer,
30
28
  IceTransportType,
29
+ type OwnedByteStreamReader,
30
+ type OwnedTextStreamReader,
31
31
  type ReadyForRoomEventResponse,
32
32
  type RoomInfo,
33
33
  type SimulateScenarioCallback,
34
34
  type SimulateScenarioKind,
35
35
  type SimulateScenarioResponse,
36
+ TextStreamReaderReadIncrementalRequest,
37
+ type TextStreamReaderReadIncrementalResponse,
36
38
  } from '@livekit/rtc-ffi-bindings';
37
39
  import { TrackKind } from '@livekit/rtc-ffi-bindings';
38
40
  import type { TypedEventEmitter as TypedEmitter } from '@livekit/typed-emitter';
39
41
  import EventEmitter from 'events';
40
42
  import { ByteStreamReader, TextStreamReader } from './data_streams/stream_reader.js';
41
- import type {
42
- ByteStreamHandler,
43
- ByteStreamInfo,
44
- StreamController,
45
- TextStreamHandler,
46
- TextStreamInfo,
47
- } from './data_streams/types.js';
43
+ import { type ByteStreamHandler, type TextStreamHandler } from './data_streams/types.js';
48
44
  import type { E2EEOptions } from './e2ee.js';
49
45
  import { E2EEManager, defaultE2EEOptions } from './e2ee.js';
50
46
  import { FfiClient, FfiClientEvent, FfiHandle } from './ffi_client.js';
@@ -56,7 +52,12 @@ import { RemoteAudioTrack, RemoteVideoTrack } from './track.js';
56
52
  import type { LocalTrackPublication, TrackPublication } from './track_publication.js';
57
53
  import { RemoteTrackPublication } from './track_publication.js';
58
54
  import type { ChatMessage } from './types.js';
59
- import { bigIntToNumber } from './utils.js';
55
+ import {
56
+ bigIntToNumber,
57
+ byteStreamInfoFromProto,
58
+ numberToBigInt,
59
+ textStreamInfoFromProto,
60
+ } from './utils.js';
60
61
 
61
62
  export interface RtcConfiguration {
62
63
  iceTransportType: IceTransportType;
@@ -70,6 +71,15 @@ export const defaultRtcConfiguration: RtcConfiguration = {
70
71
  iceServers: [],
71
72
  };
72
73
 
74
+ export interface RoomDataStreamOptions {
75
+ /**
76
+ * Maximum decompressed payload size in bytes accepted for a single incoming
77
+ * data stream. Incoming streams exceeding this limit terminate with an error
78
+ * on the receiving side. Unset falls back to the SDK default of 5gb.
79
+ */
80
+ maxPayloadByteLength?: number;
81
+ }
82
+
73
83
  export interface RoomOptions {
74
84
  autoSubscribe: boolean;
75
85
  dynacast: boolean;
@@ -79,6 +89,8 @@ export interface RoomOptions {
79
89
  e2ee?: E2EEOptions;
80
90
  encryption?: E2EEOptions;
81
91
  rtcConfig?: RtcConfiguration;
92
+ /** Options controlling data stream behavior for this room. */
93
+ dataStream?: RoomDataStreamOptions;
82
94
  }
83
95
 
84
96
  export const defaultRoomOptions = new FfiRoomOptions({
@@ -101,8 +113,15 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
101
113
  */
102
114
  private ffiEventLock = new Mutex();
103
115
 
104
- private byteStreamControllers = new Map<string, StreamController<DataStream_Chunk>>();
105
- private textStreamControllers = new Map<string, StreamController<DataStream_Chunk>>();
116
+ // Active incoming stream readers keyed by their FFI reader handle id. Each entry
117
+ // owns the FfiClient listener feeding reader events into the ReadableStream.
118
+ private streamReaders = new Map<
119
+ bigint,
120
+ {
121
+ controller: ReadableStreamDefaultController<DataStream_Chunk>;
122
+ listener: (ev: FfiEvent) => void;
123
+ }
124
+ >();
106
125
  private byteStreamHandlers = new Map<string, ByteStreamHandler>();
107
126
  private textStreamHandlers = new Map<string, TextStreamHandler>();
108
127
 
@@ -272,16 +291,39 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
272
291
  * @throws ConnectError - if connection fails
273
292
  */
274
293
  async connect(url: string, token: string, opts?: RoomOptions) {
275
- const options = { ...defaultRoomOptions, ...opts };
294
+ // dataStream is a plain user-facing object; convert it to its proto
295
+ // message separately instead of spreading it into the FFI options.
296
+ const { dataStream, ...restOpts } = opts ?? {};
297
+ const options = { ...defaultRoomOptions, ...restOpts };
276
298
  const e2eeEnabled = options.encryption || options.e2ee;
277
- const e2eeOptions = options.encryption
278
- ? { ...defaultE2EEOptions, ...options.encryption }
279
- : { ...defaultE2EEOptions, ...options.e2ee };
299
+ const userE2EEOptions = options.encryption ?? options.e2ee;
300
+ const e2eeOptions: E2EEOptions = {
301
+ ...defaultE2EEOptions,
302
+ ...userE2EEOptions,
303
+ keyProviderOptions: {
304
+ ...defaultE2EEOptions.keyProviderOptions,
305
+ ...userE2EEOptions?.keyProviderOptions,
306
+ },
307
+ };
308
+
309
+ const mergedOptions = { ...options };
310
+
311
+ if (options.encryption) {
312
+ mergedOptions.encryption = e2eeOptions;
313
+ } else if (options.e2ee) {
314
+ mergedOptions.e2ee = e2eeOptions;
315
+ }
316
+
317
+ if (dataStream) {
318
+ mergedOptions.dataStream = new FfiRoomDataStreamOptions({
319
+ maxPayloadByteLength: numberToBigInt(dataStream.maxPayloadByteLength),
320
+ });
321
+ }
280
322
 
281
323
  const req = new ConnectRequest({
282
324
  url: url,
283
325
  token: token,
284
- options,
326
+ options: mergedOptions,
285
327
  });
286
328
 
287
329
  FfiClient.instance.on(FfiClientEvent.FfiEvent, this.onFfiEvent);
@@ -421,28 +463,20 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
421
463
  if (this.hasCleanedUp) return;
422
464
  this.hasCleanedUp = true;
423
465
 
424
- // Error all in-progress stream controllers to prevent FD leaks.
425
- // Streams that were receiving data but never got a trailer (e.g. the sender
426
- // disconnected mid-transfer) would otherwise keep their ReadableStream open
427
- // indefinitely, leaking the underlying controller and any buffered chunks.
466
+ // Error all in-progress stream readers to prevent FD leaks.
467
+ // Streams that were receiving data but never reached end-of-stream (e.g. the
468
+ // sender disconnected mid-transfer) would otherwise keep their ReadableStream
469
+ // open indefinitely, leaking the underlying controller and any buffered chunks.
428
470
  // Using error() instead of close() signals an abnormal termination to consumers.
429
- for (const [, streamController] of this.byteStreamControllers) {
430
- try {
431
- streamController.controller.error(new Error('Disconnected while receiving'));
432
- } catch {
433
- // controller may already be closed or errored
434
- }
435
- }
436
- this.byteStreamControllers.clear();
437
-
438
- for (const [, streamController] of this.textStreamControllers) {
471
+ for (const [, { controller, listener }] of this.streamReaders) {
472
+ FfiClient.instance.off(FfiClientEvent.FfiEvent, listener);
439
473
  try {
440
- streamController.controller.error(new Error('Disconnected while receiving'));
474
+ controller.error(new Error('Disconnected while receiving'));
441
475
  } catch {
442
476
  // controller may already be closed or errored
443
477
  }
444
478
  }
445
- this.textStreamControllers.clear();
479
+ this.streamReaders.clear();
446
480
 
447
481
  // Detach every track from this room so attached FrameProcessors receive
448
482
  // onStreamInfoCleared/onCredentialsCleared and the tokenRefreshed listener
@@ -451,6 +485,11 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
451
485
  // listener until GC. setRoom(null) is idempotent, so this is safe even if a
452
486
  // track was already detached (e.g. via unsubscribe/unpublish).
453
487
  if (this.localParticipant) {
488
+ // Data stream writers that were never closed (abandoned by the caller, or
489
+ // left errored by a failed write) keep their native writer alive until
490
+ // their handle is dropped.
491
+ this.localParticipant.disposeOpenStreamWriters();
492
+
454
493
  for (const pub of this.localParticipant.trackPublications.values()) {
455
494
  pub.track?.setRoom(null);
456
495
  }
@@ -840,12 +879,10 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
840
879
  this.emit(RoomEvent.Reconnected);
841
880
  } else if (ev.case == 'roomSidChanged') {
842
881
  this.emit(RoomEvent.RoomSidChanged, ev.value.sid!);
843
- } else if (ev.case === 'streamHeaderReceived' && ev.value.header) {
844
- this.handleStreamHeader(ev.value.header, ev.value.participantIdentity!);
845
- } else if (ev.case === 'streamChunkReceived' && ev.value.chunk) {
846
- this.handleStreamChunk(ev.value.chunk);
847
- } else if (ev.case === 'streamTrailerReceived' && ev.value.trailer) {
848
- this.handleStreamTrailer(ev.value.trailer);
882
+ } else if (ev.case === 'byteStreamOpened' && ev.value.reader) {
883
+ this.handleByteStreamOpened(ev.value.reader, ev.value.participantIdentity!);
884
+ } else if (ev.case === 'textStreamOpened' && ev.value.reader) {
885
+ this.handleTextStreamOpened(ev.value.reader, ev.value.participantIdentity!);
849
886
  } else if (ev.case === 'roomUpdated') {
850
887
  this.info = ev.value;
851
888
  this.emit(RoomEvent.RoomUpdated);
@@ -930,104 +967,166 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
930
967
  return participant;
931
968
  }
932
969
 
933
- private handleStreamHeader(streamHeader: DataStream_Header, participantIdentity: string) {
934
- if (streamHeader.contentHeader.case === 'byteHeader') {
935
- const streamHandlerCallback = this.byteStreamHandlers.get(streamHeader.topic ?? '');
970
+ private handleByteStreamOpened(ownedReader: OwnedByteStreamReader, participantIdentity: string) {
971
+ const info = byteStreamInfoFromProto(ownedReader.info!);
972
+ const readerHandle = ownedReader.handle!.id!;
936
973
 
937
- if (!streamHandlerCallback) {
938
- log.debug(
939
- 'ignoring incoming byte stream due to no handler for topic: %s',
940
- streamHeader.topic ?? 'undefined',
941
- );
942
- return;
943
- }
944
- let streamController: ReadableStreamDefaultController<DataStream_Chunk>;
945
- const stream = new ReadableStream({
946
- start: (controller) => {
947
- streamController = controller;
948
- this.byteStreamControllers.set(streamHeader.streamId!, {
949
- header: streamHeader,
950
- controller: streamController,
951
- startTime: Date.now(),
952
- });
953
- },
954
- });
955
- const info: ByteStreamInfo = {
956
- streamId: streamHeader.streamId!,
957
- name: streamHeader.contentHeader.value.name ?? 'unknown',
958
- mimeType: streamHeader.mimeType!,
959
- totalSize: streamHeader.totalLength ? Number(streamHeader.totalLength) : undefined,
960
- topic: streamHeader.topic!,
961
- timestamp: bigIntToNumber(streamHeader.timestamp!),
962
- attributes: streamHeader.attributes,
963
- };
964
- streamHandlerCallback(
965
- new ByteStreamReader(info, stream, bigIntToNumber(streamHeader.totalLength)),
966
- { identity: participantIdentity },
967
- );
968
- } else if (streamHeader.contentHeader.case === 'textHeader') {
969
- const streamHandlerCallback = this.textStreamHandlers.get(streamHeader.topic ?? '');
970
-
971
- if (!streamHandlerCallback) {
972
- log.debug(
973
- 'ignoring incoming text stream due to no handler for topic: %s',
974
- streamHeader.topic ?? 'undefined',
975
- );
974
+ const streamHandlerCallback = this.byteStreamHandlers.get(info.topic);
975
+ if (!streamHandlerCallback) {
976
+ log.debug('ignoring incoming byte stream due to no handler for topic: %s', info.topic);
977
+ // The native reader was never consumed — dispose it to free the handle
978
+ // and any chunks it has buffered.
979
+ new FfiHandle(readerHandle).dispose();
980
+ return;
981
+ }
982
+
983
+ // Assigned by start(); unsubscribe() drops it once the stream is done with,
984
+ // whether that's end-of-stream or the consumer walking away early.
985
+ let listener: ((ev: FfiEvent) => void) | null = null;
986
+ const unsubscribe = () => {
987
+ if (!listener) {
976
988
  return;
977
989
  }
978
- let streamController: ReadableStreamDefaultController<DataStream_Chunk>;
979
- const stream = new ReadableStream<DataStream_Chunk>({
980
- start: (controller) => {
981
- streamController = controller;
982
- this.textStreamControllers.set(streamHeader.streamId!, {
983
- header: streamHeader,
984
- controller: streamController,
985
- startTime: Date.now(),
986
- });
987
- },
988
- });
989
- const info: TextStreamInfo = {
990
- streamId: streamHeader.streamId!,
991
- mimeType: streamHeader.mimeType!,
992
- totalSize: streamHeader.totalLength ? Number(streamHeader.totalLength) : undefined,
993
- topic: streamHeader.topic!,
994
- timestamp: Number(streamHeader.timestamp),
995
- attributes: streamHeader.attributes,
996
- };
997
- streamHandlerCallback(
998
- new TextStreamReader(info, stream, bigIntToNumber(streamHeader.totalLength)),
999
- { identity: participantIdentity },
1000
- );
1001
- }
990
+ FfiClient.instance.off(FfiClientEvent.FfiEvent, listener);
991
+ listener = null;
992
+ this.streamReaders.delete(readerHandle);
993
+ };
994
+
995
+ const stream = new ReadableStream<DataStream_Chunk>({
996
+ start: (controller) => {
997
+ listener = (ev: FfiEvent) => {
998
+ if (
999
+ ev.message.case !== 'byteStreamReaderEvent' ||
1000
+ ev.message.value.readerHandle !== readerHandle
1001
+ ) {
1002
+ return;
1003
+ }
1004
+ const detail = ev.message.value.detail;
1005
+ if (detail.case === 'chunkReceived') {
1006
+ const content = detail.value.content!;
1007
+ if (content.length > 0) {
1008
+ controller.enqueue(new DataStream_Chunk({ content }));
1009
+ }
1010
+ } else if (detail.case === 'eos') {
1011
+ unsubscribe();
1012
+ const error = detail.value.error;
1013
+ if (error) {
1014
+ // Abnormal termination (e.g. remote abort, payload over the
1015
+ // receiver's size limit): surface the error to the consumer
1016
+ // instead of presenting a truncated payload as a clean EOF.
1017
+ controller.error(new Error(error.description ?? 'stream terminated'));
1018
+ } else {
1019
+ controller.close();
1020
+ }
1021
+ }
1022
+ };
1023
+ FfiClient.instance.on(FfiClientEvent.FfiEvent, listener);
1024
+ this.streamReaders.set(readerHandle, { controller, listener });
1025
+
1026
+ // Start the incremental read; the native reader buffers chunks received
1027
+ // before this request, so nothing is lost. This consumes the FFI handle.
1028
+ FfiClient.instance.request<ByteStreamReaderReadIncrementalResponse>({
1029
+ message: {
1030
+ case: 'byteReadIncremental',
1031
+ value: new ByteStreamReaderReadIncrementalRequest({ readerHandle }),
1032
+ },
1033
+ });
1034
+ },
1035
+ // The consumer stopped reading before end-of-stream — ByteStreamReader
1036
+ // .close(), or breaking out of a for-await. The eos event that would have
1037
+ // unsubscribed is never consumed, so the listener would otherwise be run
1038
+ // against every FFI event for the rest of the process. Only the
1039
+ // subscription is released: the reader handle was consumed by the
1040
+ // readIncremental request above, so there is nothing left to dispose.
1041
+ cancel: () => unsubscribe(),
1042
+ });
1043
+
1044
+ streamHandlerCallback(
1045
+ new ByteStreamReader(info, stream, bigIntToNumber(ownedReader.info!.totalLength)),
1046
+ { identity: participantIdentity },
1047
+ );
1002
1048
  }
1003
1049
 
1004
- private handleStreamChunk(chunk: DataStream_Chunk) {
1005
- const fileBuffer = this.byteStreamControllers.get(chunk.streamId!);
1006
- if (fileBuffer) {
1007
- if (chunk.content!.length > 0) {
1008
- fileBuffer.controller.enqueue(chunk);
1009
- }
1050
+ private handleTextStreamOpened(ownedReader: OwnedTextStreamReader, participantIdentity: string) {
1051
+ const info = textStreamInfoFromProto(ownedReader.info!);
1052
+ const readerHandle = ownedReader.handle!.id!;
1053
+
1054
+ const streamHandlerCallback = this.textStreamHandlers.get(info.topic);
1055
+ if (!streamHandlerCallback) {
1056
+ log.debug('ignoring incoming text stream due to no handler for topic: %s', info.topic);
1057
+ // The native reader was never consumed — dispose it to free the handle
1058
+ // and any chunks it has buffered.
1059
+ new FfiHandle(readerHandle).dispose();
1060
+ return;
1010
1061
  }
1011
- const textBuffer = this.textStreamControllers.get(chunk.streamId!);
1012
- if (textBuffer) {
1013
- if (chunk.content!.length > 0) {
1014
- textBuffer.controller.enqueue(chunk);
1062
+
1063
+ const textEncoder = new TextEncoder();
1064
+
1065
+ // Assigned by start(); unsubscribe() drops it once the stream is done with,
1066
+ // whether that's end-of-stream or the consumer walking away early.
1067
+ let listener: ((ev: FfiEvent) => void) | null = null;
1068
+ const unsubscribe = () => {
1069
+ if (!listener) {
1070
+ return;
1015
1071
  }
1016
- }
1017
- }
1072
+ FfiClient.instance.off(FfiClientEvent.FfiEvent, listener);
1073
+ listener = null;
1074
+ this.streamReaders.delete(readerHandle);
1075
+ };
1076
+
1077
+ const stream = new ReadableStream<DataStream_Chunk>({
1078
+ start: (controller) => {
1079
+ listener = (ev: FfiEvent) => {
1080
+ if (
1081
+ ev.message.case !== 'textStreamReaderEvent' ||
1082
+ ev.message.value.readerHandle !== readerHandle
1083
+ ) {
1084
+ return;
1085
+ }
1086
+ const detail = ev.message.value.detail;
1087
+ if (detail.case === 'chunkReceived') {
1088
+ const content = textEncoder.encode(detail.value.content!);
1089
+ if (content.length > 0) {
1090
+ controller.enqueue(new DataStream_Chunk({ content }));
1091
+ }
1092
+ } else if (detail.case === 'eos') {
1093
+ unsubscribe();
1094
+ const error = detail.value.error;
1095
+ if (error) {
1096
+ // Abnormal termination (e.g. remote abort, payload over the
1097
+ // receiver's size limit): surface the error to the consumer
1098
+ // instead of presenting a truncated payload as a clean EOF.
1099
+ controller.error(new Error(error.description ?? 'stream terminated'));
1100
+ } else {
1101
+ controller.close();
1102
+ }
1103
+ }
1104
+ };
1105
+ FfiClient.instance.on(FfiClientEvent.FfiEvent, listener);
1106
+ this.streamReaders.set(readerHandle, { controller, listener });
1107
+
1108
+ // Start the incremental read; the native reader buffers chunks received
1109
+ // before this request, so nothing is lost. This consumes the FFI handle.
1110
+ FfiClient.instance.request<TextStreamReaderReadIncrementalResponse>({
1111
+ message: {
1112
+ case: 'textReadIncremental',
1113
+ value: new TextStreamReaderReadIncrementalRequest({ readerHandle }),
1114
+ },
1115
+ });
1116
+ },
1117
+ // The consumer stopped reading before end-of-stream — TextStreamReader
1118
+ // .close(), or breaking out of a for-await. The eos event that would have
1119
+ // unsubscribed is never consumed, so the listener would otherwise be run
1120
+ // against every FFI event for the rest of the process. Only the
1121
+ // subscription is released: the reader handle was consumed by the
1122
+ // readIncremental request above, so there is nothing left to dispose.
1123
+ cancel: () => unsubscribe(),
1124
+ });
1018
1125
 
1019
- private handleStreamTrailer(trailer: DataStream_Trailer) {
1020
- const streamId = trailer.streamId!;
1021
- const fileBuffer = this.byteStreamControllers.get(streamId);
1022
- if (fileBuffer) {
1023
- fileBuffer.controller.close();
1024
- this.byteStreamControllers.delete(streamId);
1025
- }
1026
- const textBuffer = this.textStreamControllers.get(streamId);
1027
- if (textBuffer) {
1028
- textBuffer.controller.close();
1029
- this.textStreamControllers.delete(streamId);
1030
- }
1126
+ streamHandlerCallback(
1127
+ new TextStreamReader(info, stream, bigIntToNumber(ownedReader.info!.totalLength)),
1128
+ { identity: participantIdentity },
1129
+ );
1031
1130
  }
1032
1131
  }
1033
1132