@livekit/rtc-node 0.13.33 → 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 (59) hide show
  1. package/dist/{audio_stream-CnODFAmo.d.cts → audio_stream-BNVaONuH.d.cts} +36 -11
  2. package/dist/audio_stream.d.cts +2 -2
  3. package/dist/data_streams/index.d.cts +1 -1
  4. package/dist/data_streams/stream_reader.cjs +97 -58
  5. package/dist/data_streams/stream_reader.cjs.map +1 -1
  6. package/dist/data_streams/stream_reader.d.cts +1 -1
  7. package/dist/data_streams/stream_reader.d.ts +28 -11
  8. package/dist/data_streams/stream_reader.d.ts.map +1 -1
  9. package/dist/data_streams/stream_writer.d.cts +1 -1
  10. package/dist/data_streams/types.cjs.map +1 -1
  11. package/dist/data_streams/types.d.cts +1 -1
  12. package/dist/data_streams/types.d.ts +6 -0
  13. package/dist/data_streams/types.d.ts.map +1 -1
  14. package/dist/e2ee.cjs +5 -1
  15. package/dist/e2ee.cjs.map +1 -1
  16. package/dist/e2ee.d.cts +3 -1
  17. package/dist/e2ee.d.ts +3 -1
  18. package/dist/e2ee.d.ts.map +1 -1
  19. package/dist/index.d.cts +3 -3
  20. package/dist/participant.cjs +178 -196
  21. package/dist/participant.cjs.map +1 -1
  22. package/dist/participant.d.cts +2 -2
  23. package/dist/participant.d.ts +22 -5
  24. package/dist/participant.d.ts.map +1 -1
  25. package/dist/room.cjs +166 -126
  26. package/dist/room.cjs.map +1 -1
  27. package/dist/room.d.cts +3 -3
  28. package/dist/room.d.ts +16 -8
  29. package/dist/room.d.ts.map +1 -1
  30. package/dist/tests/e2e_common.cjs +157 -0
  31. package/dist/tests/e2e_common.cjs.map +1 -0
  32. package/dist/tests/e2e_common.d.cts +56 -0
  33. package/dist/tests/e2e_common.d.ts +37 -0
  34. package/dist/tests/e2e_common.d.ts.map +1 -0
  35. package/dist/track.d.cts +2 -2
  36. package/dist/track_publication.d.cts +2 -2
  37. package/dist/{types-_PdPVish.d.cts → types-f7wTlFjs.d.cts} +34 -11
  38. package/dist/utils.cjs +27 -2
  39. package/dist/utils.cjs.map +1 -1
  40. package/dist/utils.d.cts +8 -1
  41. package/dist/utils.d.ts +6 -0
  42. package/dist/utils.d.ts.map +1 -1
  43. package/dist/version.cjs +1 -1
  44. package/dist/version.cjs.map +1 -1
  45. package/dist/version.d.cts +1 -1
  46. package/dist/version.d.ts +1 -1
  47. package/dist/video_stream.d.cts +2 -2
  48. package/package.json +2 -2
  49. package/src/audio_stream_room_lifecycle.test.ts +4 -1
  50. package/src/data_streams/stream_reader.ts +132 -82
  51. package/src/data_streams/types.ts +6 -0
  52. package/src/e2ee.ts +7 -0
  53. package/src/participant.ts +251 -228
  54. package/src/room.ts +233 -144
  55. package/src/tests/e2e.test.ts +50 -139
  56. package/src/tests/e2e_common.ts +163 -0
  57. package/src/tests/e2e_data_streams.test.ts +639 -0
  58. package/src/utils.ts +30 -0
  59. package/src/version.ts +1 -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,30 +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
- RoomDataStreamOptions,
33
32
  type RoomInfo,
34
33
  type SimulateScenarioCallback,
35
34
  type SimulateScenarioKind,
36
35
  type SimulateScenarioResponse,
36
+ TextStreamReaderReadIncrementalRequest,
37
+ type TextStreamReaderReadIncrementalResponse,
37
38
  } from '@livekit/rtc-ffi-bindings';
38
39
  import { TrackKind } from '@livekit/rtc-ffi-bindings';
39
40
  import type { TypedEventEmitter as TypedEmitter } from '@livekit/typed-emitter';
40
41
  import EventEmitter from 'events';
41
42
  import { ByteStreamReader, TextStreamReader } from './data_streams/stream_reader.js';
42
- import type {
43
- ByteStreamHandler,
44
- ByteStreamInfo,
45
- StreamController,
46
- TextStreamHandler,
47
- TextStreamInfo,
48
- } from './data_streams/types.js';
43
+ import { type ByteStreamHandler, type TextStreamHandler } from './data_streams/types.js';
49
44
  import type { E2EEOptions } from './e2ee.js';
50
45
  import { E2EEManager, defaultE2EEOptions } from './e2ee.js';
51
46
  import { FfiClient, FfiClientEvent, FfiHandle } from './ffi_client.js';
@@ -57,9 +52,12 @@ import { RemoteAudioTrack, RemoteVideoTrack } from './track.js';
57
52
  import type { LocalTrackPublication, TrackPublication } from './track_publication.js';
58
53
  import { RemoteTrackPublication } from './track_publication.js';
59
54
  import type { ChatMessage } from './types.js';
60
- import { bigIntToNumber } from './utils.js';
61
-
62
- export { RoomDataStreamOptions };
55
+ import {
56
+ bigIntToNumber,
57
+ byteStreamInfoFromProto,
58
+ numberToBigInt,
59
+ textStreamInfoFromProto,
60
+ } from './utils.js';
63
61
 
64
62
  export interface RtcConfiguration {
65
63
  iceTransportType: IceTransportType;
@@ -73,6 +71,15 @@ export const defaultRtcConfiguration: RtcConfiguration = {
73
71
  iceServers: [],
74
72
  };
75
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
+
76
83
  export interface RoomOptions {
77
84
  autoSubscribe: boolean;
78
85
  dynacast: boolean;
@@ -82,6 +89,8 @@ export interface RoomOptions {
82
89
  e2ee?: E2EEOptions;
83
90
  encryption?: E2EEOptions;
84
91
  rtcConfig?: RtcConfiguration;
92
+ /** Options controlling data stream behavior for this room. */
93
+ dataStream?: RoomDataStreamOptions;
85
94
  }
86
95
 
87
96
  export const defaultRoomOptions = new FfiRoomOptions({
@@ -104,8 +113,15 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
104
113
  */
105
114
  private ffiEventLock = new Mutex();
106
115
 
107
- private byteStreamControllers = new Map<string, StreamController<DataStream_Chunk>>();
108
- 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
+ >();
109
125
  private byteStreamHandlers = new Map<string, ByteStreamHandler>();
110
126
  private textStreamHandlers = new Map<string, TextStreamHandler>();
111
127
 
@@ -275,23 +291,39 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
275
291
  * @throws ConnectError - if connection fails
276
292
  */
277
293
  async connect(url: string, token: string, opts?: RoomOptions) {
278
- const options = { ...defaultRoomOptions, ...opts };
279
- // The Node SDK still implements data streams in TypeScript on top of raw FFI
280
- // packets, so always advertise only legacy (v1) data stream support to other
281
- // clients, preserving any other data stream options provided by the user.
282
- options.dataStream = new RoomDataStreamOptions({
283
- ...options.dataStream,
284
- useLegacyClientImplementation: true,
285
- });
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 };
286
298
  const e2eeEnabled = options.encryption || options.e2ee;
287
- const e2eeOptions = options.encryption
288
- ? { ...defaultE2EEOptions, ...options.encryption }
289
- : { ...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
+ }
290
322
 
291
323
  const req = new ConnectRequest({
292
324
  url: url,
293
325
  token: token,
294
- options,
326
+ options: mergedOptions,
295
327
  });
296
328
 
297
329
  FfiClient.instance.on(FfiClientEvent.FfiEvent, this.onFfiEvent);
@@ -431,28 +463,20 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
431
463
  if (this.hasCleanedUp) return;
432
464
  this.hasCleanedUp = true;
433
465
 
434
- // Error all in-progress stream controllers to prevent FD leaks.
435
- // Streams that were receiving data but never got a trailer (e.g. the sender
436
- // disconnected mid-transfer) would otherwise keep their ReadableStream open
437
- // 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.
438
470
  // Using error() instead of close() signals an abnormal termination to consumers.
439
- for (const [, streamController] of this.byteStreamControllers) {
471
+ for (const [, { controller, listener }] of this.streamReaders) {
472
+ FfiClient.instance.off(FfiClientEvent.FfiEvent, listener);
440
473
  try {
441
- streamController.controller.error(new Error('Disconnected while receiving'));
474
+ controller.error(new Error('Disconnected while receiving'));
442
475
  } catch {
443
476
  // controller may already be closed or errored
444
477
  }
445
478
  }
446
- this.byteStreamControllers.clear();
447
-
448
- for (const [, streamController] of this.textStreamControllers) {
449
- try {
450
- streamController.controller.error(new Error('Disconnected while receiving'));
451
- } catch {
452
- // controller may already be closed or errored
453
- }
454
- }
455
- this.textStreamControllers.clear();
479
+ this.streamReaders.clear();
456
480
 
457
481
  // Detach every track from this room so attached FrameProcessors receive
458
482
  // onStreamInfoCleared/onCredentialsCleared and the tokenRefreshed listener
@@ -461,6 +485,11 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
461
485
  // listener until GC. setRoom(null) is idempotent, so this is safe even if a
462
486
  // track was already detached (e.g. via unsubscribe/unpublish).
463
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
+
464
493
  for (const pub of this.localParticipant.trackPublications.values()) {
465
494
  pub.track?.setRoom(null);
466
495
  }
@@ -850,12 +879,10 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
850
879
  this.emit(RoomEvent.Reconnected);
851
880
  } else if (ev.case == 'roomSidChanged') {
852
881
  this.emit(RoomEvent.RoomSidChanged, ev.value.sid!);
853
- } else if (ev.case === 'streamHeaderReceived' && ev.value.header) {
854
- this.handleStreamHeader(ev.value.header, ev.value.participantIdentity!);
855
- } else if (ev.case === 'streamChunkReceived' && ev.value.chunk) {
856
- this.handleStreamChunk(ev.value.chunk);
857
- } else if (ev.case === 'streamTrailerReceived' && ev.value.trailer) {
858
- 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!);
859
886
  } else if (ev.case === 'roomUpdated') {
860
887
  this.info = ev.value;
861
888
  this.emit(RoomEvent.RoomUpdated);
@@ -940,104 +967,166 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
940
967
  return participant;
941
968
  }
942
969
 
943
- private handleStreamHeader(streamHeader: DataStream_Header, participantIdentity: string) {
944
- if (streamHeader.contentHeader.case === 'byteHeader') {
945
- 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!;
946
973
 
947
- if (!streamHandlerCallback) {
948
- log.debug(
949
- 'ignoring incoming byte stream due to no handler for topic: %s',
950
- streamHeader.topic ?? 'undefined',
951
- );
952
- return;
953
- }
954
- let streamController: ReadableStreamDefaultController<DataStream_Chunk>;
955
- const stream = new ReadableStream({
956
- start: (controller) => {
957
- streamController = controller;
958
- this.byteStreamControllers.set(streamHeader.streamId!, {
959
- header: streamHeader,
960
- controller: streamController,
961
- startTime: Date.now(),
962
- });
963
- },
964
- });
965
- const info: ByteStreamInfo = {
966
- streamId: streamHeader.streamId!,
967
- name: streamHeader.contentHeader.value.name ?? 'unknown',
968
- mimeType: streamHeader.mimeType!,
969
- totalSize: streamHeader.totalLength ? Number(streamHeader.totalLength) : undefined,
970
- topic: streamHeader.topic!,
971
- timestamp: bigIntToNumber(streamHeader.timestamp!),
972
- attributes: streamHeader.attributes,
973
- };
974
- streamHandlerCallback(
975
- new ByteStreamReader(info, stream, bigIntToNumber(streamHeader.totalLength)),
976
- { identity: participantIdentity },
977
- );
978
- } else if (streamHeader.contentHeader.case === 'textHeader') {
979
- const streamHandlerCallback = this.textStreamHandlers.get(streamHeader.topic ?? '');
980
-
981
- if (!streamHandlerCallback) {
982
- log.debug(
983
- 'ignoring incoming text stream due to no handler for topic: %s',
984
- streamHeader.topic ?? 'undefined',
985
- );
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) {
986
988
  return;
987
989
  }
988
- let streamController: ReadableStreamDefaultController<DataStream_Chunk>;
989
- const stream = new ReadableStream<DataStream_Chunk>({
990
- start: (controller) => {
991
- streamController = controller;
992
- this.textStreamControllers.set(streamHeader.streamId!, {
993
- header: streamHeader,
994
- controller: streamController,
995
- startTime: Date.now(),
996
- });
997
- },
998
- });
999
- const info: TextStreamInfo = {
1000
- streamId: streamHeader.streamId!,
1001
- mimeType: streamHeader.mimeType!,
1002
- totalSize: streamHeader.totalLength ? Number(streamHeader.totalLength) : undefined,
1003
- topic: streamHeader.topic!,
1004
- timestamp: Number(streamHeader.timestamp),
1005
- attributes: streamHeader.attributes,
1006
- };
1007
- streamHandlerCallback(
1008
- new TextStreamReader(info, stream, bigIntToNumber(streamHeader.totalLength)),
1009
- { identity: participantIdentity },
1010
- );
1011
- }
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
+ );
1012
1048
  }
1013
1049
 
1014
- private handleStreamChunk(chunk: DataStream_Chunk) {
1015
- const fileBuffer = this.byteStreamControllers.get(chunk.streamId!);
1016
- if (fileBuffer) {
1017
- if (chunk.content!.length > 0) {
1018
- fileBuffer.controller.enqueue(chunk);
1019
- }
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;
1020
1061
  }
1021
- const textBuffer = this.textStreamControllers.get(chunk.streamId!);
1022
- if (textBuffer) {
1023
- if (chunk.content!.length > 0) {
1024
- 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;
1025
1071
  }
1026
- }
1027
- }
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
+ });
1028
1125
 
1029
- private handleStreamTrailer(trailer: DataStream_Trailer) {
1030
- const streamId = trailer.streamId!;
1031
- const fileBuffer = this.byteStreamControllers.get(streamId);
1032
- if (fileBuffer) {
1033
- fileBuffer.controller.close();
1034
- this.byteStreamControllers.delete(streamId);
1035
- }
1036
- const textBuffer = this.textStreamControllers.get(streamId);
1037
- if (textBuffer) {
1038
- textBuffer.controller.close();
1039
- this.textStreamControllers.delete(streamId);
1040
- }
1126
+ streamHandlerCallback(
1127
+ new TextStreamReader(info, stream, bigIntToNumber(ownedReader.info!.totalLength)),
1128
+ { identity: participantIdentity },
1129
+ );
1041
1130
  }
1042
1131
  }
1043
1132