@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
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2024 LiveKit, Inc.
2
2
  //
3
3
  // SPDX-License-Identifier: Apache-2.0
4
- import { Mutex } from '@livekit/mutex';
4
+ import { type Mutex } from '@livekit/mutex';
5
5
  import {
6
6
  DisconnectReason,
7
7
  type OwnedParticipant,
@@ -10,13 +10,16 @@ import {
10
10
  type ParticipantKindDetail,
11
11
  } from '@livekit/rtc-ffi-bindings';
12
12
  import {
13
+ type ByteStreamOpenCallback,
14
+ ByteStreamOpenRequest,
15
+ type ByteStreamOpenResponse,
16
+ type ByteStreamWriterCloseCallback,
17
+ ByteStreamWriterCloseRequest,
18
+ type ByteStreamWriterCloseResponse,
19
+ type ByteStreamWriterWriteCallback,
20
+ ByteStreamWriterWriteRequest,
21
+ type ByteStreamWriterWriteResponse,
13
22
  ChatMessage as ChatMessageModel,
14
- DataStream_ByteHeader,
15
- DataStream_Chunk,
16
- DataStream_Header,
17
- DataStream_OperationType,
18
- DataStream_TextHeader,
19
- DataStream_Trailer,
20
23
  EditChatMessageRequest,
21
24
  TranscriptionSegment as ProtoTranscriptionSegment,
22
25
  type PublishDataCallback,
@@ -34,15 +37,6 @@ import {
34
37
  type SendChatMessageCallback,
35
38
  SendChatMessageRequest,
36
39
  type SendChatMessageResponse,
37
- type SendStreamChunkCallback,
38
- SendStreamChunkRequest,
39
- type SendStreamChunkResponse,
40
- type SendStreamHeaderCallback,
41
- SendStreamHeaderRequest,
42
- type SendStreamHeaderResponse,
43
- type SendStreamTrailerCallback,
44
- SendStreamTrailerRequest,
45
- type SendStreamTrailerResponse,
46
40
  type SetLocalAttributesCallback,
47
41
  SetLocalAttributesRequest,
48
42
  type SetLocalAttributesResponse,
@@ -52,6 +46,23 @@ import {
52
46
  type SetLocalNameCallback,
53
47
  SetLocalNameRequest,
54
48
  type SetLocalNameResponse,
49
+ StreamByteOptions,
50
+ type StreamSendFileCallback,
51
+ StreamSendFileRequest,
52
+ type StreamSendFileResponse,
53
+ type StreamSendTextCallback,
54
+ StreamSendTextRequest,
55
+ type StreamSendTextResponse,
56
+ StreamTextOptions,
57
+ type TextStreamOpenCallback,
58
+ TextStreamOpenRequest,
59
+ type TextStreamOpenResponse,
60
+ type TextStreamWriterCloseCallback,
61
+ TextStreamWriterCloseRequest,
62
+ type TextStreamWriterCloseResponse,
63
+ type TextStreamWriterWriteCallback,
64
+ TextStreamWriterWriteRequest,
65
+ type TextStreamWriterWriteResponse,
55
66
  type TrackPublishOptions,
56
67
  type UnpublishTrackCallback,
57
68
  UnpublishTrackRequest,
@@ -71,12 +82,10 @@ import {
71
82
  UnregisterRpcMethodRequest,
72
83
  } from '@livekit/rtc-ffi-bindings';
73
84
  import type { PathLike } from 'node:fs';
74
- import { open, stat } from 'node:fs/promises';
85
+ import { fileURLToPath } from 'node:url';
75
86
  import {
76
- type ByteStreamInfo,
77
87
  type ByteStreamOptions,
78
88
  ByteStreamWriter,
79
- type TextStreamInfo,
80
89
  TextStreamWriter,
81
90
  } from './data_streams/index.js';
82
91
  import { FfiClient, FfiHandle } from './ffi_client.js';
@@ -87,9 +96,8 @@ import type { RemoteTrackPublication, TrackPublication } from './track_publicati
87
96
  import { LocalTrackPublication } from './track_publication.js';
88
97
  import type { Transcription } from './transcription.js';
89
98
  import type { ChatMessage } from './types.js';
90
- import { numberToBigInt, splitUtf8 } from './utils.js';
91
-
92
- const STREAM_CHUNK_SIZE = 15_000;
99
+ import { byteStreamInfoFromProto, textStreamInfoFromProto } from './utils.js';
100
+ import { numberToBigInt } from './utils.js';
93
101
 
94
102
  export abstract class Participant {
95
103
  /** @internal */
@@ -166,6 +174,14 @@ export class LocalParticipant extends Participant {
166
174
  // pending FfiClient.waitFor() listeners so they don't leak.
167
175
  private disconnectSignal: AbortSignal;
168
176
 
177
+ // Handle ids of data stream writers that have been opened but not yet
178
+ // closed. The FFI close request consumes the handle (take_handle), so an
179
+ // entry is removed as soon as a close is sent for it; whatever is left when
180
+ // the room disconnects is dropped by disposeOpenStreamWriters(). Only the
181
+ // ids are kept — wrapping them in FfiHandle would make GC drop a handle the
182
+ // close request already consumed.
183
+ private openStreamWriters = new Set<bigint>();
184
+
169
185
  trackPublications: Map<string, LocalTrackPublication> = new Map();
170
186
 
171
187
  constructor(info: OwnedParticipant, ffiEventLock: Mutex, disconnectSignal: AbortSignal) {
@@ -277,96 +293,71 @@ export class LocalParticipant extends Participant {
277
293
  destinationIdentities?: Array<string>;
278
294
  streamId?: string;
279
295
  senderIdentity?: string;
280
- totalSize?: number;
281
296
  }): Promise<TextStreamWriter> {
282
- const senderIdentity = options?.senderIdentity ?? this.identity;
283
- const streamId = options?.streamId ?? crypto.randomUUID();
284
- const destinationIdentities = options?.destinationIdentities;
285
-
286
- const info: TextStreamInfo = {
287
- streamId: streamId,
288
- mimeType: 'text/plain',
289
- topic: options?.topic ?? '',
290
- timestamp: Date.now(),
291
- totalSize: options?.totalSize,
292
- };
293
-
294
- const headerReq = new SendStreamHeaderRequest({
295
- senderIdentity,
296
- destinationIdentities,
297
+ const req = new TextStreamOpenRequest({
297
298
  localParticipantHandle: this.ffi_handle.handle,
298
- header: new DataStream_Header({
299
- streamId,
300
- mimeType: info.mimeType,
301
- topic: info.topic,
302
- timestamp: numberToBigInt(info.timestamp),
303
- totalLength: numberToBigInt(info.totalSize),
299
+ options: new StreamTextOptions({
300
+ topic: options?.topic ?? '',
304
301
  attributes: options?.attributes,
305
- contentHeader: {
306
- case: 'textHeader',
307
- value: new DataStream_TextHeader({
308
- operationType: DataStream_OperationType.CREATE,
309
- version: 0,
310
- replyToStreamId: '',
311
- generated: false,
312
- }),
313
- },
302
+ destinationIdentities: options?.destinationIdentities,
303
+ id: options?.streamId,
304
+ senderIdentity: options?.senderIdentity ?? this.identity,
314
305
  }),
315
306
  });
316
307
 
317
- await this.sendStreamHeader(headerReq);
308
+ const res = FfiClient.instance.request<TextStreamOpenResponse>({
309
+ message: { case: 'textStreamOpen', value: req },
310
+ });
318
311
 
319
- let nextChunkId = 0;
320
- const localHandle = this.ffi_handle.handle;
321
- const sendTrailer = this.sendStreamTrailer;
322
- const sendChunk = this.sendStreamChunk;
312
+ const cb = await FfiClient.instance.waitFor<TextStreamOpenCallback>(
313
+ (ev) => ev.message.case == 'textStreamOpen' && ev.message.value.asyncId == res.asyncId,
314
+ { signal: this.disconnectSignal },
315
+ );
316
+
317
+ if (cb.result.case !== 'writer') {
318
+ throw new Error(cb.result.case === 'error' ? cb.result.value.description : 'unknown error');
319
+ }
320
+
321
+ const writerHandle = cb.result.value.handle!.id!;
322
+ const info = textStreamInfoFromProto(cb.result.value.info!);
323
+ this.openStreamWriters.add(writerHandle);
324
+
325
+ // Sends the close request at most once: it consumes the writer handle on
326
+ // the native side, so a second one would fail and, worse, the handle must
327
+ // no longer be dropped by disconnect cleanup.
328
+ const closeWriter = async (reason?: string) => {
329
+ if (!this.openStreamWriters.delete(writerHandle)) {
330
+ return;
331
+ }
332
+ await this.textStreamWriterClose(new TextStreamWriterCloseRequest({ writerHandle, reason }));
333
+ };
323
334
 
324
335
  const writableStream = new WritableStream<string>({
325
336
  // Implement the sink
326
- async write(text) {
327
- for (const textByteChunk of splitUtf8(text, STREAM_CHUNK_SIZE)) {
328
- const chunkRequest = new SendStreamChunkRequest({
329
- senderIdentity,
330
- localParticipantHandle: localHandle,
331
- destinationIdentities,
332
- chunk: new DataStream_Chunk({
333
- content: textByteChunk,
334
- streamId,
335
- chunkIndex: numberToBigInt(nextChunkId),
336
- }),
337
+ write: async (text) => {
338
+ try {
339
+ await this.textStreamWriterWrite(
340
+ new TextStreamWriterWriteRequest({ writerHandle, text }),
341
+ );
342
+ } catch (err) {
343
+ // A rejected write leaves the writable stream errored, and an errored
344
+ // stream never runs the sink's close()/abort() — a later close() just
345
+ // rejects with this error. Close the native writer here so its handle
346
+ // isn't held for the lifetime of the process and peers get an
347
+ // end-of-stream instead of a stream that never terminates.
348
+ await closeWriter(err instanceof Error ? err.message : String(err ?? '')).catch(() => {
349
+ // Best-effort: the connection may already be gone.
337
350
  });
338
-
339
- await sendChunk(chunkRequest);
340
- nextChunkId += 1;
351
+ throw err;
341
352
  }
342
353
  },
343
- async close() {
344
- const trailerReq = new SendStreamTrailerRequest({
345
- senderIdentity,
346
- localParticipantHandle: localHandle,
347
- destinationIdentities,
348
- trailer: new DataStream_Trailer({
349
- streamId,
350
- reason: '',
351
- }),
352
- });
353
- await sendTrailer(trailerReq);
354
- },
355
- // Send a trailer with the error reason so the remote side's stream
354
+ close: () => closeWriter(),
355
+ // Close the stream with the error reason so the remote side's stream
356
356
  // controller is closed instead of waiting for data that won't arrive.
357
- async abort(err) {
357
+ abort: async (err) => {
358
358
  log.error(err, 'Sink Error');
359
359
  try {
360
- const trailerReq = new SendStreamTrailerRequest({
361
- senderIdentity,
362
- localParticipantHandle: localHandle,
363
- destinationIdentities,
364
- trailer: new DataStream_Trailer({
365
- streamId,
366
- reason: err instanceof Error ? err.message : String(err ?? ''),
367
- }),
368
- });
369
- await sendTrailer(trailerReq);
360
+ await closeWriter(err instanceof Error ? err.message : String(err ?? ''));
370
361
  } catch {
371
362
  // Best-effort: the connection may already be gone.
372
363
  }
@@ -385,15 +376,41 @@ export class LocalParticipant extends Participant {
385
376
  attributes?: Record<string, string>;
386
377
  destinationIdentities?: Array<string>;
387
378
  streamId?: string;
379
+ /**
380
+ * Whether the payload may be compressed on the wire. Defaults to true;
381
+ * compression is only applied when it actually reduces the payload size
382
+ * and every recipient supports it.
383
+ */
384
+ compress?: boolean;
388
385
  },
389
386
  ) {
390
- const writer = await this.streamText({
391
- ...options,
392
- totalSize: new TextEncoder().encode(text).byteLength,
387
+ const req = new StreamSendTextRequest({
388
+ localParticipantHandle: this.ffi_handle.handle,
389
+ options: new StreamTextOptions({
390
+ topic: options?.topic ?? '',
391
+ attributes: options?.attributes,
392
+ destinationIdentities: options?.destinationIdentities,
393
+ id: options?.streamId,
394
+ senderIdentity: this.identity,
395
+ compress: options?.compress,
396
+ }),
397
+ text,
393
398
  });
394
- await writer.write(text);
395
- await writer.close();
396
- return writer.info;
399
+
400
+ const res = FfiClient.instance.request<StreamSendTextResponse>({
401
+ message: { case: 'sendText', value: req },
402
+ });
403
+
404
+ const cb = await FfiClient.instance.waitFor<StreamSendTextCallback>(
405
+ (ev) => ev.message.case == 'sendText' && ev.message.value.asyncId == res.asyncId,
406
+ { signal: this.disconnectSignal },
407
+ );
408
+
409
+ if (cb.result.case !== 'info') {
410
+ throw new Error(cb.result.case === 'error' ? cb.result.value.description : 'unknown error');
411
+ }
412
+
413
+ return textStreamInfoFromProto(cb.result.value);
397
414
  }
398
415
 
399
416
  async streamBytes(options?: {
@@ -405,102 +422,72 @@ export class LocalParticipant extends Participant {
405
422
  mimeType?: string;
406
423
  totalSize?: number;
407
424
  }) {
408
- const senderIdentity = this.identity;
409
- const streamId = options?.streamId ?? crypto.randomUUID();
410
- const destinationIdentities = options?.destinationIdentities;
411
-
412
- const info: ByteStreamInfo = {
413
- streamId: streamId,
414
- mimeType: options?.mimeType ?? 'application/octet-stream',
415
- topic: options?.topic ?? '',
416
- timestamp: Date.now(),
417
- attributes: options?.attributes,
418
- totalSize: options?.totalSize,
419
- name: options?.name ?? 'unknown',
420
- };
421
-
422
- const headerReq = new SendStreamHeaderRequest({
423
- senderIdentity,
424
- destinationIdentities,
425
+ const req = new ByteStreamOpenRequest({
425
426
  localParticipantHandle: this.ffi_handle.handle,
426
- header: new DataStream_Header({
427
- streamId,
428
- mimeType: info.mimeType,
429
- topic: info.topic,
430
- timestamp: numberToBigInt(info.timestamp),
431
- attributes: info.attributes,
432
- totalLength: numberToBigInt(info.totalSize),
433
- contentHeader: {
434
- case: 'byteHeader',
435
- value: new DataStream_ByteHeader({
436
- name: info.name,
437
- }),
438
- },
427
+ options: new StreamByteOptions({
428
+ topic: options?.topic ?? '',
429
+ attributes: options?.attributes,
430
+ destinationIdentities: options?.destinationIdentities,
431
+ id: options?.streamId,
432
+ name: options?.name ?? 'unknown',
433
+ mimeType: options?.mimeType ?? 'application/octet-stream',
434
+ totalLength: numberToBigInt(options?.totalSize),
435
+ senderIdentity: this.identity,
439
436
  }),
440
437
  });
441
438
 
442
- await this.sendStreamHeader(headerReq);
439
+ const res = FfiClient.instance.request<ByteStreamOpenResponse>({
440
+ message: { case: 'byteStreamOpen', value: req },
441
+ });
443
442
 
444
- let chunkId = 0;
445
- const localHandle = this.ffi_handle.handle;
446
- const sendTrailer = this.sendStreamTrailer;
447
- const sendChunk = this.sendStreamChunk;
448
- const writeMutex = new Mutex();
443
+ const cb = await FfiClient.instance.waitFor<ByteStreamOpenCallback>(
444
+ (ev) => ev.message.case == 'byteStreamOpen' && ev.message.value.asyncId == res.asyncId,
445
+ { signal: this.disconnectSignal },
446
+ );
449
447
 
450
- const writableStream = new WritableStream<Uint8Array>({
451
- async write(chunk) {
452
- const unlock = await writeMutex.lock();
448
+ if (cb.result.case !== 'writer') {
449
+ throw new Error(cb.result.case === 'error' ? cb.result.value.description : 'unknown error');
450
+ }
451
+
452
+ const writerHandle = cb.result.value.handle!.id!;
453
+ const info = byteStreamInfoFromProto(cb.result.value.info!);
454
+ this.openStreamWriters.add(writerHandle);
453
455
 
454
- let byteOffset = 0;
456
+ // Sends the close request at most once: it consumes the writer handle on
457
+ // the native side, so a second one would fail and, worse, the handle must
458
+ // no longer be dropped by disconnect cleanup.
459
+ const closeWriter = async (reason?: string) => {
460
+ if (!this.openStreamWriters.delete(writerHandle)) {
461
+ return;
462
+ }
463
+ await this.byteStreamWriterClose(new ByteStreamWriterCloseRequest({ writerHandle, reason }));
464
+ };
465
+
466
+ const writableStream = new WritableStream<Uint8Array>({
467
+ write: async (chunk) => {
455
468
  try {
456
- while (byteOffset < chunk.byteLength) {
457
- const subChunk = chunk.slice(byteOffset, byteOffset + STREAM_CHUNK_SIZE);
458
- const chunkRequest = new SendStreamChunkRequest({
459
- senderIdentity,
460
- localParticipantHandle: localHandle,
461
- destinationIdentities,
462
- chunk: new DataStream_Chunk({
463
- content: subChunk,
464
- streamId,
465
- chunkIndex: numberToBigInt(chunkId),
466
- }),
467
- });
468
-
469
- await sendChunk(chunkRequest);
470
- chunkId += 1;
471
- byteOffset += subChunk.byteLength;
472
- }
473
- } finally {
474
- unlock();
469
+ await this.byteStreamWriterWrite(
470
+ new ByteStreamWriterWriteRequest({ writerHandle, bytes: chunk }),
471
+ );
472
+ } catch (err) {
473
+ // A rejected write leaves the writable stream errored, and an errored
474
+ // stream never runs the sink's close()/abort() — a later close() just
475
+ // rejects with this error. Close the native writer here so its handle
476
+ // isn't held for the lifetime of the process and peers get an
477
+ // end-of-stream instead of a stream that never terminates.
478
+ await closeWriter(err instanceof Error ? err.message : String(err ?? '')).catch(() => {
479
+ // Best-effort: the connection may already be gone.
480
+ });
481
+ throw err;
475
482
  }
476
483
  },
477
- async close() {
478
- const trailerReq = new SendStreamTrailerRequest({
479
- senderIdentity,
480
- localParticipantHandle: localHandle,
481
- destinationIdentities,
482
- trailer: new DataStream_Trailer({
483
- streamId,
484
- reason: '',
485
- }),
486
- });
487
- await sendTrailer(trailerReq);
488
- },
489
- // Send a trailer with the error reason so the remote side's stream
484
+ close: () => closeWriter(),
485
+ // Close the stream with the error reason so the remote side's stream
490
486
  // controller is closed instead of waiting for data that won't arrive.
491
- async abort(err) {
487
+ abort: async (err) => {
492
488
  log.error(err, 'Sink error');
493
489
  try {
494
- const trailerReq = new SendStreamTrailerRequest({
495
- senderIdentity,
496
- localParticipantHandle: localHandle,
497
- destinationIdentities,
498
- trailer: new DataStream_Trailer({
499
- streamId,
500
- reason: err instanceof Error ? err.message : String(err ?? ''),
501
- }),
502
- });
503
- await sendTrailer(trailerReq);
490
+ await closeWriter(err instanceof Error ? err.message : String(err ?? ''));
504
491
  } catch {
505
492
  // Best-effort: the connection may already be gone.
506
493
  }
@@ -514,80 +501,116 @@ export class LocalParticipant extends Participant {
514
501
 
515
502
  /** Sends a file provided as PathLike to specified recipients */
516
503
  async sendFile(path: PathLike, options?: ByteStreamOptions) {
517
- const fileStats = await stat(path);
518
- const file = await open(path);
519
- try {
520
- const stream: ReadableStream<Uint8Array> = file.readableWebStream();
521
- const streamId = crypto.randomUUID();
522
- const destinationIdentities = options?.destinationIdentities;
523
-
524
- const writer = await this.streamBytes({
525
- streamId: streamId,
526
- name: options?.name,
527
- totalSize: fileStats.size,
528
- destinationIdentities,
529
- topic: options?.topic,
530
- mimeType: options?.mimeType,
504
+ const filePath = path instanceof URL ? fileURLToPath(path) : path.toString();
505
+
506
+ const req = new StreamSendFileRequest({
507
+ localParticipantHandle: this.ffi_handle.handle,
508
+ options: new StreamByteOptions({
509
+ topic: options?.topic ?? '',
531
510
  attributes: options?.attributes,
532
- });
511
+ destinationIdentities: options?.destinationIdentities,
512
+ name: options?.name ?? 'unknown',
513
+ mimeType: options?.mimeType ?? 'application/octet-stream',
514
+ senderIdentity: this.identity,
515
+ compress: options?.compress,
516
+ }),
517
+ filePath,
518
+ });
533
519
 
534
- for await (const chunk of stream) {
535
- await writer.write(chunk);
536
- }
537
- await writer.close();
538
- } finally {
539
- await file.close();
520
+ const res = FfiClient.instance.request<StreamSendFileResponse>({
521
+ message: { case: 'sendFile', value: req },
522
+ });
523
+
524
+ const cb = await FfiClient.instance.waitFor<StreamSendFileCallback>(
525
+ (ev) => ev.message.case == 'sendFile' && ev.message.value.asyncId == res.asyncId,
526
+ { signal: this.disconnectSignal },
527
+ );
528
+
529
+ if (cb.result.case === 'error') {
530
+ throw new Error(cb.result.value.description);
540
531
  }
541
532
  }
542
533
 
543
- private async sendStreamHeader(req: SendStreamHeaderRequest) {
544
- const type = 'sendStreamHeader';
545
- const res = FfiClient.instance.request<SendStreamHeaderResponse>({
546
- message: { case: type, value: req },
534
+ private textStreamWriterWrite = async (req: TextStreamWriterWriteRequest) => {
535
+ const res = FfiClient.instance.request<TextStreamWriterWriteResponse>({
536
+ message: { case: 'textStreamWrite', value: req },
547
537
  });
548
538
 
549
- const cb = await FfiClient.instance.waitFor<SendStreamHeaderCallback>(
550
- (ev) => ev.message.case == type && ev.message.value.asyncId == res.asyncId,
539
+ const cb = await FfiClient.instance.waitFor<TextStreamWriterWriteCallback>(
540
+ (ev) => ev.message.case == 'textStreamWriterWrite' && ev.message.value.asyncId == res.asyncId,
551
541
  { signal: this.disconnectSignal },
552
542
  );
553
543
 
554
544
  if (cb.error) {
555
- throw new Error(cb.error);
545
+ throw new Error(cb.error.description);
556
546
  }
557
- }
547
+ };
558
548
 
559
- private sendStreamChunk = async (req: SendStreamChunkRequest) => {
560
- const type = 'sendStreamChunk';
561
- const res = FfiClient.instance.request<SendStreamChunkResponse>({
562
- message: { case: type, value: req },
549
+ private textStreamWriterClose = async (req: TextStreamWriterCloseRequest) => {
550
+ const res = FfiClient.instance.request<TextStreamWriterCloseResponse>({
551
+ message: { case: 'textStreamClose', value: req },
563
552
  });
564
553
 
565
- const cb = await FfiClient.instance.waitFor<SendStreamChunkCallback>(
566
- (ev) => ev.message.case == type && ev.message.value.asyncId == res.asyncId,
554
+ const cb = await FfiClient.instance.waitFor<TextStreamWriterCloseCallback>(
555
+ (ev) => ev.message.case == 'textStreamWriterClose' && ev.message.value.asyncId == res.asyncId,
567
556
  { signal: this.disconnectSignal },
568
557
  );
569
558
 
570
559
  if (cb.error) {
571
- throw new Error(cb.error);
560
+ throw new Error(cb.error.description);
572
561
  }
573
562
  };
574
563
 
575
- private sendStreamTrailer = async (req: SendStreamTrailerRequest) => {
576
- const type = 'sendStreamTrailer';
577
- const res = FfiClient.instance.request<SendStreamTrailerResponse>({
578
- message: { case: type, value: req },
564
+ private byteStreamWriterWrite = async (req: ByteStreamWriterWriteRequest) => {
565
+ const res = FfiClient.instance.request<ByteStreamWriterWriteResponse>({
566
+ message: { case: 'byteStreamWrite', value: req },
579
567
  });
580
568
 
581
- const cb = await FfiClient.instance.waitFor<SendStreamTrailerCallback>(
582
- (ev) => ev.message.case == type && ev.message.value.asyncId == res.asyncId,
569
+ const cb = await FfiClient.instance.waitFor<ByteStreamWriterWriteCallback>(
570
+ (ev) => ev.message.case == 'byteStreamWriterWrite' && ev.message.value.asyncId == res.asyncId,
583
571
  { signal: this.disconnectSignal },
584
572
  );
585
573
 
586
574
  if (cb.error) {
587
- throw new Error(cb.error);
575
+ throw new Error(cb.error.description);
576
+ }
577
+ };
578
+
579
+ private byteStreamWriterClose = async (req: ByteStreamWriterCloseRequest) => {
580
+ const res = FfiClient.instance.request<ByteStreamWriterCloseResponse>({
581
+ message: { case: 'byteStreamClose', value: req },
582
+ });
583
+
584
+ const cb = await FfiClient.instance.waitFor<ByteStreamWriterCloseCallback>(
585
+ (ev) => ev.message.case == 'byteStreamWriterClose' && ev.message.value.asyncId == res.asyncId,
586
+ { signal: this.disconnectSignal },
587
+ );
588
+
589
+ if (cb.error) {
590
+ throw new Error(cb.error.description);
588
591
  }
589
592
  };
590
593
 
594
+ /**
595
+ * Drops the native writers of any data streams that were opened but never
596
+ * closed — a writer the caller abandoned, or one whose write failed. Their
597
+ * handles would otherwise be held by the FFI server for the lifetime of the
598
+ * process. The room is already gone at this point, so the handles are dropped
599
+ * directly rather than closed: there is no end-of-stream left to deliver.
600
+ *
601
+ * @internal
602
+ */
603
+ disposeOpenStreamWriters() {
604
+ for (const writerHandle of this.openStreamWriters) {
605
+ try {
606
+ new FfiHandle(writerHandle).dispose();
607
+ } catch (err) {
608
+ log.warn('failed to dispose data stream writer handle: %s', err);
609
+ }
610
+ }
611
+ this.openStreamWriters.clear();
612
+ }
613
+
591
614
  /**
592
615
  * Sends a chat message to participants in the room
593
616
  *