@livekit/rtc-node 0.13.33 → 0.13.35

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 (60) hide show
  1. package/README.md +0 -5
  2. package/dist/{audio_stream-CnODFAmo.d.cts → audio_stream-BNVaONuH.d.cts} +36 -11
  3. package/dist/audio_stream.d.cts +2 -2
  4. package/dist/data_streams/index.d.cts +1 -1
  5. package/dist/data_streams/stream_reader.cjs +97 -58
  6. package/dist/data_streams/stream_reader.cjs.map +1 -1
  7. package/dist/data_streams/stream_reader.d.cts +1 -1
  8. package/dist/data_streams/stream_reader.d.ts +28 -11
  9. package/dist/data_streams/stream_reader.d.ts.map +1 -1
  10. package/dist/data_streams/stream_writer.d.cts +1 -1
  11. package/dist/data_streams/types.cjs.map +1 -1
  12. package/dist/data_streams/types.d.cts +1 -1
  13. package/dist/data_streams/types.d.ts +6 -0
  14. package/dist/data_streams/types.d.ts.map +1 -1
  15. package/dist/e2ee.cjs +5 -1
  16. package/dist/e2ee.cjs.map +1 -1
  17. package/dist/e2ee.d.cts +3 -1
  18. package/dist/e2ee.d.ts +3 -1
  19. package/dist/e2ee.d.ts.map +1 -1
  20. package/dist/index.d.cts +3 -3
  21. package/dist/participant.cjs +178 -196
  22. package/dist/participant.cjs.map +1 -1
  23. package/dist/participant.d.cts +2 -2
  24. package/dist/participant.d.ts +22 -5
  25. package/dist/participant.d.ts.map +1 -1
  26. package/dist/room.cjs +166 -126
  27. package/dist/room.cjs.map +1 -1
  28. package/dist/room.d.cts +3 -3
  29. package/dist/room.d.ts +16 -8
  30. package/dist/room.d.ts.map +1 -1
  31. package/dist/tests/e2e_common.cjs +157 -0
  32. package/dist/tests/e2e_common.cjs.map +1 -0
  33. package/dist/tests/e2e_common.d.cts +56 -0
  34. package/dist/tests/e2e_common.d.ts +37 -0
  35. package/dist/tests/e2e_common.d.ts.map +1 -0
  36. package/dist/track.d.cts +2 -2
  37. package/dist/track_publication.d.cts +2 -2
  38. package/dist/{types-_PdPVish.d.cts → types-f7wTlFjs.d.cts} +34 -11
  39. package/dist/utils.cjs +27 -2
  40. package/dist/utils.cjs.map +1 -1
  41. package/dist/utils.d.cts +8 -1
  42. package/dist/utils.d.ts +6 -0
  43. package/dist/utils.d.ts.map +1 -1
  44. package/dist/version.cjs +1 -1
  45. package/dist/version.cjs.map +1 -1
  46. package/dist/version.d.cts +1 -1
  47. package/dist/version.d.ts +1 -1
  48. package/dist/video_stream.d.cts +2 -2
  49. package/package.json +2 -2
  50. package/src/audio_stream_room_lifecycle.test.ts +4 -1
  51. package/src/data_streams/stream_reader.ts +132 -82
  52. package/src/data_streams/types.ts +6 -0
  53. package/src/e2ee.ts +7 -0
  54. package/src/participant.ts +251 -228
  55. package/src/room.ts +233 -144
  56. package/src/tests/e2e.test.ts +50 -139
  57. package/src/tests/e2e_common.ts +163 -0
  58. package/src/tests/e2e_data_streams.test.ts +639 -0
  59. package/src/utils.ts +30 -0
  60. package/src/version.ts +1 -1
@@ -0,0 +1,639 @@
1
+ // SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ //
5
+ // End-to-end tests for data streams (text/byte streams over the FFI-backed
6
+ // rust implementation). Ported from rust-sdks `livekit/tests/data_stream_test.rs`
7
+ // where the node API surface allows; see comments on individual tests for
8
+ // intentional deviations.
9
+ import { afterAll, expect, it as itRaw } from 'vitest';
10
+ import type { Room, TextStreamReader } from '../index.js';
11
+ import { dispose } from '../index.js';
12
+ import {
13
+ concatUint8,
14
+ connectTestRooms,
15
+ describeE2E,
16
+ testTimeoutMs,
17
+ waitFor,
18
+ withTimeout,
19
+ } from './e2e_common.js';
20
+
21
+ // use concurrent testing if available on the runner (currently not supported by bun's api)
22
+ const it = typeof itRaw.concurrent === 'function' ? itRaw.concurrent : itRaw;
23
+
24
+ /** How many of this room's incoming stream readers are still subscribed to FFI
25
+ * events.
26
+ *
27
+ * Reads the room's private reader registry rather than counting FfiClient
28
+ * listeners: an entry is added when a reader subscribes and removed when it
29
+ * unsubscribes, so the count is specific to this room and unaffected by the
30
+ * other tests running concurrently. */
31
+ function subscribedReaderCount(room: Room): number {
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
+ return ((room as any).streamReaders as Map<unknown, unknown>).size;
34
+ }
35
+
36
+ async function waitForNoSubscribedReaders(room: Room): Promise<void> {
37
+ await waitFor(() => subscribedReaderCount(room) === 0, {
38
+ timeoutMs: 5000,
39
+ debugName: 'stream reader to unsubscribe from FFI events',
40
+ });
41
+ }
42
+
43
+ /** Pseudo-random lowercase text.
44
+ *
45
+ * Counterpart of rust's `pseudo_random_text`: random lowercase carries
46
+ * ~4.7 bits of entropy per 8-bit byte, so deflate compresses it well under
47
+ * its raw size — exercising the chunked-compressed wire path. */
48
+ function pseudoRandomText(length: number): string {
49
+ const chars = new Array<string>(length);
50
+ for (let i = 0; i < length; i++) {
51
+ chars[i] = String.fromCharCode(97 + Math.floor(Math.random() * 26));
52
+ }
53
+ return chars.join('');
54
+ }
55
+
56
+ /** Pseudo-random bytes.
57
+ *
58
+ * Uniform random bytes are genuinely incompressible: deflate cannot shrink
59
+ * them, so the send path must fall back to an uncompressed wire format. */
60
+ function pseudoRandomBytes(length: number): Uint8Array {
61
+ const out = new Uint8Array(length);
62
+ for (let i = 0; i < length; i++) {
63
+ out[i] = Math.floor(Math.random() * 256);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ describeE2E('livekit-rtc data streams e2e', () => {
69
+ afterAll(async () => {
70
+ await dispose();
71
+ });
72
+
73
+ // Port of rust `test_send_text`. The `is_compressed` / `is_inline` info
74
+ // assertions are not portable: the FFI stream-info protos don't expose them.
75
+ it(
76
+ 'sends and receives a small text stream',
77
+ async () => {
78
+ const { rooms } = await connectTestRooms(2);
79
+ const [receivingRoom, sendingRoom] = rooms;
80
+ const senderIdentity = sendingRoom!.localParticipant!.identity;
81
+
82
+ const topic = 'some-topic';
83
+ const textToSend = 'some-text';
84
+
85
+ const receivedText = withTimeout(
86
+ new Promise<string>((resolve) => {
87
+ receivingRoom!.registerTextStreamHandler(topic, async (reader, sender) => {
88
+ expect(sender.identity).toBe(senderIdentity);
89
+ resolve(await reader.readAll());
90
+ });
91
+ }),
92
+ testTimeoutMs,
93
+ 'Timed out waiting for text stream',
94
+ );
95
+
96
+ const textInfo = await sendingRoom!.localParticipant!.sendText(textToSend, { topic });
97
+ expect(textInfo.streamId).toBeTruthy();
98
+ expect(textInfo.totalSize).toBe(textToSend.length);
99
+ expect(textInfo.mimeType).toBe('text/plain');
100
+ expect(textInfo.topic).toBe(topic);
101
+
102
+ expect(await receivedText).toBe(textToSend);
103
+
104
+ await Promise.all(rooms.map((r) => r.disconnect()));
105
+ },
106
+ testTimeoutMs,
107
+ );
108
+
109
+ // Port of rust `test_send_bytes`. Node has no `sendBytes` (in-memory one-shot)
110
+ // wrapper yet, so this uses the incremental `streamBytes` writer — which by
111
+ // design is never inlined or compressed on the wire.
112
+ it(
113
+ 'sends and receives a small byte stream',
114
+ async () => {
115
+ const { rooms } = await connectTestRooms(2);
116
+ const [receivingRoom, sendingRoom] = rooms;
117
+ const senderIdentity = sendingRoom!.localParticipant!.identity;
118
+
119
+ const topic = 'some-topic';
120
+ const bytesToSend = new Uint8Array(16).fill(0xfa);
121
+
122
+ const receivedBytes = withTimeout(
123
+ new Promise<Uint8Array>((resolve) => {
124
+ receivingRoom!.registerByteStreamHandler(topic, async (reader, sender) => {
125
+ expect(sender.identity).toBe(senderIdentity);
126
+ const chunks = await reader.readAll();
127
+ resolve(concatUint8(chunks));
128
+ });
129
+ }),
130
+ testTimeoutMs,
131
+ 'Timed out waiting for byte stream',
132
+ );
133
+
134
+ const writer = await sendingRoom!.localParticipant!.streamBytes({
135
+ topic,
136
+ totalSize: bytesToSend.byteLength,
137
+ });
138
+ await writer.write(bytesToSend);
139
+ await writer.close();
140
+
141
+ const byteInfo = writer.info;
142
+ expect(byteInfo.streamId).toBeTruthy();
143
+ expect(byteInfo.mimeType).toBe('application/octet-stream');
144
+ expect(byteInfo.topic).toBe(topic);
145
+
146
+ expect(await receivedBytes).toEqual(bytesToSend);
147
+
148
+ await Promise.all(rooms.map((r) => r.disconnect()));
149
+ },
150
+ testTimeoutMs,
151
+ );
152
+
153
+ // Port of rust `test_send_large_compressible_text`: ~50 KB of deterministic
154
+ // pseudo-random lowercase is too big to inline and compresses well under its
155
+ // raw size, so the sender emits chunked deflate-raw and the receiver
156
+ // decompresses — validating the v2 compression path on the real wire.
157
+ it(
158
+ 'round-trips a large compressible text (chunked + compressed wire path)',
159
+ async () => {
160
+ const { rooms } = await connectTestRooms(2);
161
+ const [receivingRoom, sendingRoom] = rooms;
162
+
163
+ const topic = 'large-compressible-text';
164
+ const text = pseudoRandomText(50_000);
165
+
166
+ const receivedText = withTimeout(
167
+ new Promise<string>((resolve) => {
168
+ receivingRoom!.registerTextStreamHandler(topic, async (reader) => {
169
+ resolve(await reader.readAll());
170
+ });
171
+ }),
172
+ testTimeoutMs,
173
+ 'Timed out waiting for large text stream',
174
+ );
175
+
176
+ const info = await sendingRoom!.localParticipant!.sendText(text, { topic });
177
+ expect(info.totalSize).toBe(text.length);
178
+
179
+ expect(await receivedText).toBe(text);
180
+
181
+ await Promise.all(rooms.map((r) => r.disconnect()));
182
+ },
183
+ testTimeoutMs,
184
+ );
185
+
186
+ // Port of rust `test_send_large_incompressible_random_bytes`, adapted to the
187
+ // incremental `streamBytes` writer (no `sendBytes` wrapper in node yet):
188
+ // uniform random bytes exercise the chunked, uncompressed wire path with a
189
+ // payload spanning many chunks.
190
+ it(
191
+ 'round-trips large incompressible random bytes',
192
+ async () => {
193
+ const { rooms } = await connectTestRooms(2);
194
+ const [receivingRoom, sendingRoom] = rooms;
195
+
196
+ const topic = 'large-random-bytes';
197
+ const payload = pseudoRandomBytes(1_800_000);
198
+
199
+ const receivedBytes = withTimeout(
200
+ new Promise<Uint8Array>((resolve) => {
201
+ receivingRoom!.registerByteStreamHandler(topic, async (reader) => {
202
+ resolve(concatUint8(await reader.readAll()));
203
+ });
204
+ }),
205
+ testTimeoutMs * 2,
206
+ 'Timed out waiting for large byte stream',
207
+ );
208
+
209
+ const writer = await sendingRoom!.localParticipant!.streamBytes({
210
+ topic,
211
+ totalSize: payload.byteLength,
212
+ });
213
+ const writeChunkSize = 64 * 1024;
214
+ for (let offset = 0; offset < payload.byteLength; offset += writeChunkSize) {
215
+ await writer.write(payload.subarray(offset, offset + writeChunkSize));
216
+ }
217
+ await writer.close();
218
+
219
+ const received = await receivedBytes;
220
+ expect(received.byteLength).toBe(payload.byteLength);
221
+ expect(received).toEqual(payload);
222
+
223
+ await Promise.all(rooms.map((r) => r.disconnect()));
224
+ },
225
+ testTimeoutMs * 3,
226
+ );
227
+
228
+ // Port of rust `test_send_large_bytes`: a 50 KB patterned (compressible)
229
+ // payload via the byte-stream path.
230
+ it(
231
+ 'round-trips large patterned bytes',
232
+ async () => {
233
+ const { rooms } = await connectTestRooms(2);
234
+ const [receivingRoom, sendingRoom] = rooms;
235
+
236
+ const topic = 'large-patterned-bytes';
237
+ const payload = new Uint8Array(50_000);
238
+ for (let i = 0; i < payload.length; i++) payload[i] = i % 251;
239
+
240
+ const receivedBytes = withTimeout(
241
+ new Promise<Uint8Array>((resolve) => {
242
+ receivingRoom!.registerByteStreamHandler(topic, async (reader) => {
243
+ resolve(concatUint8(await reader.readAll()));
244
+ });
245
+ }),
246
+ testTimeoutMs,
247
+ 'Timed out waiting for patterned byte stream',
248
+ );
249
+
250
+ const writer = await sendingRoom!.localParticipant!.streamBytes({
251
+ topic,
252
+ totalSize: payload.byteLength,
253
+ });
254
+ await writer.write(payload);
255
+ await writer.close();
256
+
257
+ expect(await receivedBytes).toEqual(payload);
258
+
259
+ await Promise.all(rooms.map((r) => r.disconnect()));
260
+ },
261
+ testTimeoutMs,
262
+ );
263
+
264
+ // Incremental writer path for text (multi-write, unicode content): ensures
265
+ // UTF-8-aware chunking on the sender doesn't split multi-byte characters.
266
+ it(
267
+ 'round-trips an incremental text stream with multi-byte characters',
268
+ async () => {
269
+ const { rooms } = await connectTestRooms(2);
270
+ const [receivingRoom, sendingRoom] = rooms;
271
+
272
+ const topic = 'incremental-unicode-text';
273
+ const pieces = ['héllo wörld — ', '日本語のテキスト、', '🌍🚀 emoji tail'];
274
+ const expected = pieces.join('');
275
+
276
+ const receivedText = withTimeout(
277
+ new Promise<string>((resolve) => {
278
+ receivingRoom!.registerTextStreamHandler(topic, async (reader) => {
279
+ resolve(await reader.readAll());
280
+ });
281
+ }),
282
+ testTimeoutMs,
283
+ 'Timed out waiting for incremental text stream',
284
+ );
285
+
286
+ const writer = await sendingRoom!.localParticipant!.streamText({ topic });
287
+ for (const piece of pieces) {
288
+ await writer.write(piece);
289
+ }
290
+ await writer.close();
291
+
292
+ expect(await receivedText).toBe(expected);
293
+
294
+ await Promise.all(rooms.map((r) => r.disconnect()));
295
+ },
296
+ testTimeoutMs,
297
+ );
298
+
299
+ // Moved from e2e.test.ts (kept alongside the other data-stream coverage).
300
+ it(
301
+ 'cleans up stream controllers when disconnecting during an active stream',
302
+ async () => {
303
+ const { rooms } = await connectTestRooms(2);
304
+ const [receivingRoom, sendingRoom] = rooms;
305
+ const topic = 'cleanup-stream-topic';
306
+
307
+ // Register a handler on the receiving side that will intentionally
308
+ // NOT fully consume the stream — simulating an abandoned transfer.
309
+ let readerReceived = false;
310
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
311
+ receivingRoom!.registerTextStreamHandler(topic, async (_reader, _sender) => {
312
+ readerReceived = true;
313
+ // Deliberately do not call reader.readAll() so the stream stays open
314
+ });
315
+
316
+ // Start sending a text stream but don't close it
317
+ const writer = await sendingRoom!.localParticipant!.streamText({ topic });
318
+ await writer.write('partial data');
319
+
320
+ // Wait for the receiving side to get the stream header
321
+ const deadline = Date.now() + 5000;
322
+ while (!readerReceived && Date.now() < deadline) {
323
+ await new Promise((r) => setTimeout(r, 50));
324
+ }
325
+ expect(readerReceived).toBe(true);
326
+
327
+ // Disconnect the receiving room while the stream is still open.
328
+ // This should close the stream controller without throwing.
329
+ await receivingRoom!.disconnect();
330
+
331
+ // Also close the writer and disconnect the sender
332
+ await writer.close();
333
+ await sendingRoom!.disconnect();
334
+
335
+ // If we got here without hanging or throwing, the stream controller
336
+ // was properly cleaned up on disconnect.
337
+ },
338
+ testTimeoutMs,
339
+ );
340
+
341
+ it(
342
+ 'releases the FFI subscription when a reader is abandoned mid-iteration',
343
+ async () => {
344
+ const { rooms } = await connectTestRooms(2);
345
+ const [receivingRoom, sendingRoom] = rooms;
346
+ const topic = 'abandon-topic';
347
+
348
+ // Unlike python, walking out of the loop is enough: `break` runs the
349
+ // async iterator's return(), which closes the reader for us.
350
+ const abandoned = withTimeout(
351
+ new Promise<{ reader: TextStreamReader; firstChunk: string }>((resolve) => {
352
+ receivingRoom!.registerTextStreamHandler(topic, async (reader) => {
353
+ for await (const chunk of reader) {
354
+ resolve({ reader, firstChunk: chunk });
355
+ break; // walk away without draining to end-of-stream
356
+ }
357
+ });
358
+ }),
359
+ testTimeoutMs,
360
+ 'Timed out waiting for the reader to be abandoned',
361
+ );
362
+
363
+ await sendingRoom!.localParticipant!.sendText(pseudoRandomText(50_000), { topic });
364
+ const { reader, firstChunk } = await abandoned;
365
+ expect(firstChunk.length).toBeGreaterThan(0);
366
+
367
+ await waitForNoSubscribedReaders(receivingRoom!);
368
+
369
+ // Closing an already-closed reader is a no-op.
370
+ await reader.close();
371
+ expect(subscribedReaderCount(receivingRoom!)).toBe(0);
372
+
373
+ await Promise.all(rooms.map((r) => r.disconnect()));
374
+ },
375
+ testTimeoutMs,
376
+ );
377
+
378
+ it(
379
+ 'releases the FFI subscription when a reader is never read',
380
+ async () => {
381
+ const { rooms } = await connectTestRooms(2);
382
+ const [receivingRoom, sendingRoom] = rooms;
383
+ const topic = 'noread-topic';
384
+
385
+ const handed = withTimeout(
386
+ new Promise<TextStreamReader>((resolve) => {
387
+ receivingRoom!.registerTextStreamHandler(topic, async (reader) => {
388
+ resolve(reader); // never read
389
+ });
390
+ }),
391
+ testTimeoutMs,
392
+ 'Timed out waiting for the stream handler to be called',
393
+ );
394
+
395
+ const writer = await sendingRoom!.localParticipant!.streamText({ topic });
396
+ await writer.write('some data');
397
+
398
+ const reader = await handed;
399
+ expect(subscribedReaderCount(receivingRoom!)).toBe(1);
400
+
401
+ await reader.close();
402
+ expect(subscribedReaderCount(receivingRoom!)).toBe(0);
403
+ await reader.close(); // idempotent
404
+
405
+ await writer.close();
406
+ await Promise.all(rooms.map((r) => r.disconnect()));
407
+ },
408
+ testTimeoutMs,
409
+ );
410
+
411
+ it(
412
+ 'ends an in-flight read when the reader is closed mid-stream',
413
+ async () => {
414
+ const { rooms } = await connectTestRooms(2);
415
+ const [receivingRoom, sendingRoom] = rooms;
416
+ const topic = 'close-during-read-topic';
417
+
418
+ const reading = withTimeout(
419
+ new Promise<{ reader: TextStreamReader; read: Promise<string> }>((resolve) => {
420
+ receivingRoom!.registerTextStreamHandler(topic, async (reader) => {
421
+ // readAll() blocks on a stream that is deliberately left open.
422
+ resolve({ reader, read: reader.readAll() });
423
+ });
424
+ }),
425
+ testTimeoutMs,
426
+ 'Timed out waiting for the stream handler to be called',
427
+ );
428
+
429
+ const writer = await sendingRoom!.localParticipant!.streamText({ topic });
430
+ await writer.write('first chunk');
431
+
432
+ const { reader, read } = await reading;
433
+ // Let the read consume the first chunk and block waiting for more.
434
+ await waitFor(() => subscribedReaderCount(receivingRoom!) === 1, {
435
+ timeoutMs: 5000,
436
+ debugName: 'reader to subscribe',
437
+ });
438
+
439
+ await reader.close();
440
+
441
+ // The blocked read ends rather than hanging, with what it had so far.
442
+ const text = await withTimeout(read, testTimeoutMs, 'Timed out on the in-flight read');
443
+ expect('first chunk'.startsWith(text)).toBe(true);
444
+ expect(subscribedReaderCount(receivingRoom!)).toBe(0);
445
+
446
+ await writer.close();
447
+ await Promise.all(rooms.map((r) => r.disconnect()));
448
+ },
449
+ testTimeoutMs,
450
+ );
451
+
452
+ it(
453
+ 'drops the subscription of an unread reader when the room disconnects',
454
+ async () => {
455
+ const { rooms } = await connectTestRooms(2);
456
+ const [receivingRoom, sendingRoom] = rooms;
457
+ const topic = 'disconnect-unread-topic';
458
+
459
+ const handed = withTimeout(
460
+ new Promise<TextStreamReader>((resolve) => {
461
+ receivingRoom!.registerTextStreamHandler(topic, async (reader) => {
462
+ resolve(reader); // never read
463
+ });
464
+ }),
465
+ testTimeoutMs,
466
+ 'Timed out waiting for the stream handler to be called',
467
+ );
468
+
469
+ const writer = await sendingRoom!.localParticipant!.streamText({ topic });
470
+ await writer.write('buffered ');
471
+ await writer.write('data');
472
+
473
+ const reader = await handed;
474
+ expect(subscribedReaderCount(receivingRoom!)).toBe(1);
475
+
476
+ await receivingRoom!.disconnect();
477
+ expect(subscribedReaderCount(receivingRoom!)).toBe(0);
478
+
479
+ // The read settles rather than hanging. Which way it settles is a race
480
+ // the SDK doesn't control: the native side emits a clean end-of-stream
481
+ // when the room drops the stream (livekit-ffi `read_incremental` sends
482
+ // `eos { error: None }` once the channel closes), so either that lands
483
+ // first and the read returns the chunks buffered so far, or disconnect
484
+ // cleanup errors the stream first and the read reports that. Python is
485
+ // deterministic here because it injects a synthetic error end-of-stream.
486
+ const readResult = await withTimeout(
487
+ reader.readAll().then(
488
+ (text) => ({ text }),
489
+ (err: unknown) => ({ err }),
490
+ ),
491
+ testTimeoutMs,
492
+ 'Timed out reading a reader whose room disconnected',
493
+ );
494
+ if ('err' in readResult) {
495
+ expect(String(readResult.err)).toMatch(/Disconnected while receiving/);
496
+ } else {
497
+ expect('buffered data'.startsWith(readResult.text)).toBe(true);
498
+ }
499
+
500
+ await writer.close();
501
+ await sendingRoom!.disconnect();
502
+ },
503
+ testTimeoutMs,
504
+ );
505
+
506
+ it(
507
+ 'needs no close once a reader has been read to completion',
508
+ async () => {
509
+ const { rooms } = await connectTestRooms(2);
510
+ const [receivingRoom, sendingRoom] = rooms;
511
+ const topic = 'drain-topic';
512
+
513
+ const drained = withTimeout(
514
+ new Promise<{ reader: TextStreamReader; text: string }>((resolve) => {
515
+ receivingRoom!.registerTextStreamHandler(topic, async (reader) => {
516
+ resolve({ reader, text: await reader.readAll() });
517
+ });
518
+ }),
519
+ testTimeoutMs,
520
+ 'Timed out waiting for the text stream',
521
+ );
522
+
523
+ await sendingRoom!.localParticipant!.sendText('hello', { topic });
524
+ const { reader, text } = await drained;
525
+ expect(text).toBe('hello');
526
+
527
+ await waitForNoSubscribedReaders(receivingRoom!);
528
+ await reader.close(); // no-op
529
+ expect(subscribedReaderCount(receivingRoom!)).toBe(0);
530
+
531
+ await Promise.all(rooms.map((r) => r.disconnect()));
532
+ },
533
+ testTimeoutMs,
534
+ );
535
+
536
+ it(
537
+ 'reads a closed reader as an empty stream',
538
+ async () => {
539
+ const { rooms } = await connectTestRooms(2);
540
+ const [receivingRoom, sendingRoom] = rooms;
541
+ const topic = 'read-after-close-topic';
542
+
543
+ const handed = withTimeout(
544
+ new Promise<TextStreamReader>((resolve) => {
545
+ receivingRoom!.registerTextStreamHandler(topic, async (reader) => {
546
+ resolve(reader); // never read
547
+ });
548
+ }),
549
+ testTimeoutMs,
550
+ 'Timed out waiting for the stream handler to be called',
551
+ );
552
+
553
+ const writer = await sendingRoom!.localParticipant!.streamText({ topic });
554
+ await writer.write('never consumed');
555
+
556
+ const reader = await handed;
557
+ await reader.close();
558
+
559
+ const text = await withTimeout(
560
+ reader.readAll(),
561
+ testTimeoutMs,
562
+ 'Timed out reading a closed reader',
563
+ );
564
+ expect(text).toBe('');
565
+
566
+ await writer.close();
567
+ await Promise.all(rooms.map((r) => r.disconnect()));
568
+ },
569
+ testTimeoutMs,
570
+ );
571
+
572
+ // `dataStream.maxPayloadByteLength` caps what a receiver will accept (5gb by
573
+ // default). The native side emits the stream to the handler first and then
574
+ // fails it, so the error surfaces on the read rather than as a missing stream.
575
+ it(
576
+ 'fails the read when an incoming stream exceeds maxPayloadByteLength',
577
+ async () => {
578
+ const maxPayloadByteLength = 1000;
579
+ const { rooms } = await connectTestRooms(2, { dataStream: { maxPayloadByteLength } });
580
+ const [receivingRoom, sendingRoom] = rooms;
581
+ const topic = 'oversized-topic';
582
+
583
+ const handed = withTimeout(
584
+ new Promise<TextStreamReader>((resolve) => {
585
+ receivingRoom!.registerTextStreamHandler(topic, resolve);
586
+ }),
587
+ testTimeoutMs,
588
+ 'Timed out waiting for the oversized stream to be handed to the handler',
589
+ );
590
+
591
+ // sendText declares the payload's total length up front, so the receiver
592
+ // rejects the stream on the header without reading any of it.
593
+ await sendingRoom!.localParticipant!.sendText(pseudoRandomText(maxPayloadByteLength * 5), {
594
+ topic,
595
+ });
596
+
597
+ const reader = await handed;
598
+ await expect(reader.readAll()).rejects.toThrow(/payload exceeds maximum size/);
599
+
600
+ await Promise.all(rooms.map((r) => r.disconnect()));
601
+ },
602
+ testTimeoutMs,
603
+ );
604
+
605
+ it(
606
+ 'releases the FFI subscription when a progress callback throws',
607
+ async () => {
608
+ const { rooms } = await connectTestRooms(2);
609
+ const [receivingRoom, sendingRoom] = rooms;
610
+ const topic = 'throwing-progress-topic';
611
+
612
+ const handed = withTimeout(
613
+ new Promise<TextStreamReader>((resolve) => {
614
+ receivingRoom!.registerTextStreamHandler(topic, resolve);
615
+ }),
616
+ testTimeoutMs,
617
+ 'Timed out waiting for the stream handler to be called',
618
+ );
619
+
620
+ // Left open so the stream is still live when the callback throws: the
621
+ // reader has to cancel it, rather than assume a terminal stream event
622
+ // already dropped the FFI subscription.
623
+ const writer = await sendingRoom!.localParticipant!.streamText({ topic });
624
+ await writer.write('first chunk');
625
+
626
+ const reader = await handed;
627
+ reader.onProgress = () => {
628
+ throw new Error('progress callback exploded');
629
+ };
630
+
631
+ await expect(reader.readAll()).rejects.toThrow(/progress callback exploded/);
632
+ expect(subscribedReaderCount(receivingRoom!)).toBe(0);
633
+
634
+ await writer.close();
635
+ await Promise.all(rooms.map((r) => r.disconnect()));
636
+ },
637
+ testTimeoutMs,
638
+ );
639
+ });
package/src/utils.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  // SPDX-FileCopyrightText: 2024 LiveKit, Inc.
2
2
  //
3
3
  // SPDX-License-Identifier: Apache-2.0
4
+ import type {
5
+ ByteStreamInfo as ProtoByteStreamInfo,
6
+ TextStreamInfo as ProtoTextStreamInfo,
7
+ } from '@livekit/rtc-ffi-bindings';
8
+ import type { ByteStreamInfo, TextStreamInfo } from './data_streams/types.js';
4
9
 
5
10
  /** convert bigints to numbers preserving undefined values */
6
11
  export function bigIntToNumber<T extends bigint | undefined>(
@@ -40,3 +45,28 @@ export function splitUtf8(s: string, n: number): Uint8Array[] {
40
45
  }
41
46
  return result;
42
47
  }
48
+
49
+ /** @internal */
50
+ export function textStreamInfoFromProto(info: ProtoTextStreamInfo): TextStreamInfo {
51
+ return {
52
+ streamId: info.streamId!,
53
+ mimeType: info.mimeType!,
54
+ topic: info.topic!,
55
+ timestamp: bigIntToNumber(info.timestamp!),
56
+ totalSize: info.totalLength ? bigIntToNumber(info.totalLength) : undefined,
57
+ attributes: info.attributes,
58
+ };
59
+ }
60
+
61
+ /** @internal */
62
+ export function byteStreamInfoFromProto(info: ProtoByteStreamInfo): ByteStreamInfo {
63
+ return {
64
+ streamId: info.streamId!,
65
+ name: info.name ?? 'unknown',
66
+ mimeType: info.mimeType!,
67
+ topic: info.topic!,
68
+ timestamp: bigIntToNumber(info.timestamp!),
69
+ totalSize: info.totalLength !== undefined ? bigIntToNumber(info.totalLength) : undefined,
70
+ attributes: info.attributes,
71
+ };
72
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = "0.13.33";
1
+ export const SDK_VERSION = "0.13.35";