@livekit/rtc-node 0.13.25 → 0.13.26
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.
- package/dist/audio_mixer.cjs +9 -3
- package/dist/audio_mixer.cjs.map +1 -1
- package/dist/audio_mixer.d.cts +3 -1
- package/dist/audio_mixer.d.ts +3 -1
- package/dist/audio_mixer.d.ts.map +1 -1
- package/dist/audio_mixer.js +9 -3
- package/dist/audio_mixer.js.map +1 -1
- package/dist/ffi_client.cjs +19 -3
- package/dist/ffi_client.cjs.map +1 -1
- package/dist/ffi_client.d.cts +3 -1
- package/dist/ffi_client.d.ts +3 -1
- package/dist/ffi_client.d.ts.map +1 -1
- package/dist/ffi_client.js +19 -3
- package/dist/ffi_client.js.map +1 -1
- package/dist/participant.cjs +108 -63
- package/dist/participant.cjs.map +1 -1
- package/dist/participant.d.cts +2 -1
- package/dist/participant.d.ts +2 -1
- package/dist/participant.d.ts.map +1 -1
- package/dist/participant.js +108 -63
- package/dist/participant.js.map +1 -1
- package/dist/room.cjs +111 -53
- package/dist/room.cjs.map +1 -1
- package/dist/room.d.cts +4 -0
- package/dist/room.d.ts +4 -0
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +103 -45
- package/dist/room.js.map +1 -1
- package/dist/version.cjs +1 -1
- package/dist/version.cjs.map +1 -1
- package/dist/version.d.cts +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +2 -2
- package/src/audio_mixer.test.ts +102 -0
- package/src/audio_mixer.ts +9 -3
- package/src/ffi_client.ts +26 -3
- package/src/participant.ts +100 -49
- package/src/room.ts +140 -48
- package/src/tests/e2e.test.ts +168 -0
- package/src/version.ts +1 -1
package/src/audio_mixer.ts
CHANGED
|
@@ -311,7 +311,7 @@ export class AudioMixer {
|
|
|
311
311
|
// Accumulate data until we have at least chunkSize samples
|
|
312
312
|
while (buf.length < this.chunkSize * this.numChannels && !exhausted && !this.closed) {
|
|
313
313
|
try {
|
|
314
|
-
const result = await
|
|
314
|
+
const result = await this.timeoutRace(iterator.next(), this.streamTimeoutMs);
|
|
315
315
|
|
|
316
316
|
if (result === 'timeout') {
|
|
317
317
|
console.warn(`AudioMixer: stream timeout after ${this.streamTimeoutMs}ms`);
|
|
@@ -412,7 +412,13 @@ export class AudioMixer {
|
|
|
412
412
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
-
|
|
416
|
-
|
|
415
|
+
/** Race a promise against a timeout. The losing setTimeout is automatically
|
|
416
|
+
* cleared via `.finally()` so callers don't need to manage cleanup. */
|
|
417
|
+
private timeoutRace<T>(promise: Promise<T>, ms: number): Promise<T | 'timeout'> {
|
|
418
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
419
|
+
const timeoutPromise = new Promise<'timeout'>((resolve) => {
|
|
420
|
+
timer = setTimeout(() => resolve('timeout'), ms);
|
|
421
|
+
});
|
|
422
|
+
return Promise.race([promise.finally(() => clearTimeout(timer)), timeoutPromise]);
|
|
417
423
|
}
|
|
418
424
|
}
|
package/src/ffi_client.ts
CHANGED
|
@@ -70,14 +70,37 @@ export class FfiClient extends (EventEmitter as new () => TypedEmitter<FfiClient
|
|
|
70
70
|
return livekitRetrievePtr(data);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
async waitFor<T>(
|
|
74
|
-
|
|
73
|
+
async waitFor<T>(
|
|
74
|
+
predicate: (ev: FfiEvent) => boolean,
|
|
75
|
+
options?: { signal?: AbortSignal },
|
|
76
|
+
): Promise<T> {
|
|
77
|
+
return new Promise<T>((resolve, reject) => {
|
|
75
78
|
const listener = (ev: FfiEvent) => {
|
|
76
79
|
if (predicate(ev)) {
|
|
77
|
-
|
|
80
|
+
cleanup();
|
|
78
81
|
resolve(ev.message.value as T);
|
|
79
82
|
}
|
|
80
83
|
};
|
|
84
|
+
|
|
85
|
+
const cleanup = () => {
|
|
86
|
+
this.off(FfiClientEvent.FfiEvent, listener);
|
|
87
|
+
options?.signal?.removeEventListener('abort', onAbort);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// If an AbortSignal is provided, remove the listener when the signal
|
|
91
|
+
// fires so that pending waitFor() calls don't leak listeners after
|
|
92
|
+
// the room disconnects or the operation is cancelled.
|
|
93
|
+
const onAbort = () => {
|
|
94
|
+
cleanup();
|
|
95
|
+
reject(options?.signal?.reason ?? new Error('waitFor aborted'));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (options?.signal?.aborted) {
|
|
99
|
+
reject(options.signal.reason ?? new Error('waitFor aborted'));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
options?.signal?.addEventListener('abort', onAbort);
|
|
81
104
|
this.on(FfiClientEvent.FfiEvent, listener);
|
|
82
105
|
});
|
|
83
106
|
}
|
package/src/participant.ts
CHANGED
|
@@ -157,11 +157,16 @@ export class LocalParticipant extends Participant {
|
|
|
157
157
|
|
|
158
158
|
private ffiEventLock: Mutex;
|
|
159
159
|
|
|
160
|
+
// Signal that fires when the owning Room disconnects, used to cancel
|
|
161
|
+
// pending FfiClient.waitFor() listeners so they don't leak.
|
|
162
|
+
private disconnectSignal: AbortSignal;
|
|
163
|
+
|
|
160
164
|
trackPublications: Map<string, LocalTrackPublication> = new Map();
|
|
161
165
|
|
|
162
|
-
constructor(info: OwnedParticipant, ffiEventLock: Mutex) {
|
|
166
|
+
constructor(info: OwnedParticipant, ffiEventLock: Mutex, disconnectSignal: AbortSignal) {
|
|
163
167
|
super(info);
|
|
164
168
|
this.ffiEventLock = ffiEventLock;
|
|
169
|
+
this.disconnectSignal = disconnectSignal;
|
|
165
170
|
}
|
|
166
171
|
|
|
167
172
|
async publishData(data: Uint8Array, options: DataPublishOptions) {
|
|
@@ -178,9 +183,10 @@ export class LocalParticipant extends Participant {
|
|
|
178
183
|
message: { case: 'publishData', value: req },
|
|
179
184
|
});
|
|
180
185
|
|
|
181
|
-
const cb = await FfiClient.instance.waitFor<PublishDataCallback>(
|
|
182
|
-
|
|
183
|
-
|
|
186
|
+
const cb = await FfiClient.instance.waitFor<PublishDataCallback>(
|
|
187
|
+
(ev) => ev.message.case == 'publishData' && ev.message.value.asyncId == res.asyncId,
|
|
188
|
+
{ signal: this.disconnectSignal },
|
|
189
|
+
);
|
|
184
190
|
|
|
185
191
|
if (cb.error) {
|
|
186
192
|
throw new Error(cb.error);
|
|
@@ -198,9 +204,10 @@ export class LocalParticipant extends Participant {
|
|
|
198
204
|
message: { case: 'publishSipDtmf', value: req },
|
|
199
205
|
});
|
|
200
206
|
|
|
201
|
-
const cb = await FfiClient.instance.waitFor<PublishSipDtmfCallback>(
|
|
202
|
-
|
|
203
|
-
|
|
207
|
+
const cb = await FfiClient.instance.waitFor<PublishSipDtmfCallback>(
|
|
208
|
+
(ev) => ev.message.case == 'publishSipDtmf' && ev.message.value.asyncId == res.asyncId,
|
|
209
|
+
{ signal: this.disconnectSignal },
|
|
210
|
+
);
|
|
204
211
|
|
|
205
212
|
if (cb.error) {
|
|
206
213
|
throw new Error(cb.error);
|
|
@@ -229,9 +236,10 @@ export class LocalParticipant extends Participant {
|
|
|
229
236
|
message: { case: 'publishTranscription', value: req },
|
|
230
237
|
});
|
|
231
238
|
|
|
232
|
-
const cb = await FfiClient.instance.waitFor<PublishTranscriptionCallback>(
|
|
233
|
-
|
|
234
|
-
|
|
239
|
+
const cb = await FfiClient.instance.waitFor<PublishTranscriptionCallback>(
|
|
240
|
+
(ev) => ev.message.case == 'publishTranscription' && ev.message.value.asyncId == res.asyncId,
|
|
241
|
+
{ signal: this.disconnectSignal },
|
|
242
|
+
);
|
|
235
243
|
|
|
236
244
|
if (cb.error) {
|
|
237
245
|
throw new Error(cb.error);
|
|
@@ -248,9 +256,10 @@ export class LocalParticipant extends Participant {
|
|
|
248
256
|
message: { case: 'setLocalMetadata', value: req },
|
|
249
257
|
});
|
|
250
258
|
|
|
251
|
-
await FfiClient.instance.waitFor<SetLocalMetadataCallback>(
|
|
252
|
-
|
|
253
|
-
|
|
259
|
+
await FfiClient.instance.waitFor<SetLocalMetadataCallback>(
|
|
260
|
+
(ev) => ev.message.case == 'setLocalMetadata' && ev.message.value.asyncId == res.asyncId,
|
|
261
|
+
{ signal: this.disconnectSignal },
|
|
262
|
+
);
|
|
254
263
|
}
|
|
255
264
|
|
|
256
265
|
/**
|
|
@@ -335,8 +344,24 @@ export class LocalParticipant extends Participant {
|
|
|
335
344
|
});
|
|
336
345
|
await sendTrailer(trailerReq);
|
|
337
346
|
},
|
|
338
|
-
|
|
347
|
+
// Send a trailer with the error reason so the remote side's stream
|
|
348
|
+
// controller is closed instead of waiting for data that won't arrive.
|
|
349
|
+
async abort(err) {
|
|
339
350
|
log.error(err, 'Sink Error');
|
|
351
|
+
try {
|
|
352
|
+
const trailerReq = new SendStreamTrailerRequest({
|
|
353
|
+
senderIdentity,
|
|
354
|
+
localParticipantHandle: localHandle,
|
|
355
|
+
destinationIdentities,
|
|
356
|
+
trailer: new DataStream_Trailer({
|
|
357
|
+
streamId,
|
|
358
|
+
reason: err instanceof Error ? err.message : String(err ?? ''),
|
|
359
|
+
}),
|
|
360
|
+
});
|
|
361
|
+
await sendTrailer(trailerReq);
|
|
362
|
+
} catch {
|
|
363
|
+
// Best-effort: the connection may already be gone.
|
|
364
|
+
}
|
|
340
365
|
},
|
|
341
366
|
});
|
|
342
367
|
|
|
@@ -450,8 +475,24 @@ export class LocalParticipant extends Participant {
|
|
|
450
475
|
});
|
|
451
476
|
await sendTrailer(trailerReq);
|
|
452
477
|
},
|
|
453
|
-
|
|
478
|
+
// Send a trailer with the error reason so the remote side's stream
|
|
479
|
+
// controller is closed instead of waiting for data that won't arrive.
|
|
480
|
+
async abort(err) {
|
|
454
481
|
log.error(err, 'Sink error');
|
|
482
|
+
try {
|
|
483
|
+
const trailerReq = new SendStreamTrailerRequest({
|
|
484
|
+
senderIdentity,
|
|
485
|
+
localParticipantHandle: localHandle,
|
|
486
|
+
destinationIdentities,
|
|
487
|
+
trailer: new DataStream_Trailer({
|
|
488
|
+
streamId,
|
|
489
|
+
reason: err instanceof Error ? err.message : String(err ?? ''),
|
|
490
|
+
}),
|
|
491
|
+
});
|
|
492
|
+
await sendTrailer(trailerReq);
|
|
493
|
+
} catch {
|
|
494
|
+
// Best-effort: the connection may already be gone.
|
|
495
|
+
}
|
|
455
496
|
},
|
|
456
497
|
});
|
|
457
498
|
|
|
@@ -494,44 +535,47 @@ export class LocalParticipant extends Participant {
|
|
|
494
535
|
message: { case: type, value: req },
|
|
495
536
|
});
|
|
496
537
|
|
|
497
|
-
const cb = await FfiClient.instance.waitFor<SendStreamHeaderCallback>(
|
|
498
|
-
|
|
499
|
-
|
|
538
|
+
const cb = await FfiClient.instance.waitFor<SendStreamHeaderCallback>(
|
|
539
|
+
(ev) => ev.message.case == type && ev.message.value.asyncId == res.asyncId,
|
|
540
|
+
{ signal: this.disconnectSignal },
|
|
541
|
+
);
|
|
500
542
|
|
|
501
543
|
if (cb.error) {
|
|
502
544
|
throw new Error(cb.error);
|
|
503
545
|
}
|
|
504
546
|
}
|
|
505
547
|
|
|
506
|
-
private async
|
|
548
|
+
private sendStreamChunk = async (req: SendStreamChunkRequest) => {
|
|
507
549
|
const type = 'sendStreamChunk';
|
|
508
550
|
const res = FfiClient.instance.request<SendStreamChunkResponse>({
|
|
509
551
|
message: { case: type, value: req },
|
|
510
552
|
});
|
|
511
553
|
|
|
512
|
-
const cb = await FfiClient.instance.waitFor<SendStreamChunkCallback>(
|
|
513
|
-
|
|
514
|
-
|
|
554
|
+
const cb = await FfiClient.instance.waitFor<SendStreamChunkCallback>(
|
|
555
|
+
(ev) => ev.message.case == type && ev.message.value.asyncId == res.asyncId,
|
|
556
|
+
{ signal: this.disconnectSignal },
|
|
557
|
+
);
|
|
515
558
|
|
|
516
559
|
if (cb.error) {
|
|
517
560
|
throw new Error(cb.error);
|
|
518
561
|
}
|
|
519
|
-
}
|
|
562
|
+
};
|
|
520
563
|
|
|
521
|
-
private async
|
|
564
|
+
private sendStreamTrailer = async (req: SendStreamTrailerRequest) => {
|
|
522
565
|
const type = 'sendStreamTrailer';
|
|
523
566
|
const res = FfiClient.instance.request<SendStreamTrailerResponse>({
|
|
524
567
|
message: { case: type, value: req },
|
|
525
568
|
});
|
|
526
569
|
|
|
527
|
-
const cb = await FfiClient.instance.waitFor<SendStreamTrailerCallback>(
|
|
528
|
-
|
|
529
|
-
|
|
570
|
+
const cb = await FfiClient.instance.waitFor<SendStreamTrailerCallback>(
|
|
571
|
+
(ev) => ev.message.case == type && ev.message.value.asyncId == res.asyncId,
|
|
572
|
+
{ signal: this.disconnectSignal },
|
|
573
|
+
);
|
|
530
574
|
|
|
531
575
|
if (cb.error) {
|
|
532
576
|
throw new Error(cb.error);
|
|
533
577
|
}
|
|
534
|
-
}
|
|
578
|
+
};
|
|
535
579
|
|
|
536
580
|
/**
|
|
537
581
|
* Sends a chat message to participants in the room
|
|
@@ -557,9 +601,10 @@ export class LocalParticipant extends Participant {
|
|
|
557
601
|
message: { case: 'sendChatMessage', value: req },
|
|
558
602
|
});
|
|
559
603
|
|
|
560
|
-
const cb = await FfiClient.instance.waitFor<SendChatMessageCallback>(
|
|
561
|
-
|
|
562
|
-
|
|
604
|
+
const cb = await FfiClient.instance.waitFor<SendChatMessageCallback>(
|
|
605
|
+
(ev) => ev.message.case == 'chatMessage' && ev.message.value.asyncId == res.asyncId,
|
|
606
|
+
{ signal: this.disconnectSignal },
|
|
607
|
+
);
|
|
563
608
|
|
|
564
609
|
switch (cb.message.case) {
|
|
565
610
|
case 'chatMessage':
|
|
@@ -603,9 +648,10 @@ export class LocalParticipant extends Participant {
|
|
|
603
648
|
message: { case: 'editChatMessage', value: req },
|
|
604
649
|
});
|
|
605
650
|
|
|
606
|
-
const cb = await FfiClient.instance.waitFor<SendChatMessageCallback>(
|
|
607
|
-
|
|
608
|
-
|
|
651
|
+
const cb = await FfiClient.instance.waitFor<SendChatMessageCallback>(
|
|
652
|
+
(ev) => ev.message.case == 'chatMessage' && ev.message.value.asyncId == res.asyncId,
|
|
653
|
+
{ signal: this.disconnectSignal },
|
|
654
|
+
);
|
|
609
655
|
|
|
610
656
|
switch (cb.message.case) {
|
|
611
657
|
case 'chatMessage':
|
|
@@ -632,9 +678,10 @@ export class LocalParticipant extends Participant {
|
|
|
632
678
|
message: { case: 'setLocalName', value: req },
|
|
633
679
|
});
|
|
634
680
|
|
|
635
|
-
await FfiClient.instance.waitFor<SetLocalNameCallback>(
|
|
636
|
-
|
|
637
|
-
|
|
681
|
+
await FfiClient.instance.waitFor<SetLocalNameCallback>(
|
|
682
|
+
(ev) => ev.message.case == 'setLocalName' && ev.message.value.asyncId == res.asyncId,
|
|
683
|
+
{ signal: this.disconnectSignal },
|
|
684
|
+
);
|
|
638
685
|
}
|
|
639
686
|
|
|
640
687
|
async setAttributes(attributes: Record<string, string>) {
|
|
@@ -647,9 +694,10 @@ export class LocalParticipant extends Participant {
|
|
|
647
694
|
message: { case: 'setLocalAttributes', value: req },
|
|
648
695
|
});
|
|
649
696
|
|
|
650
|
-
await FfiClient.instance.waitFor<SetLocalAttributesCallback>(
|
|
651
|
-
|
|
652
|
-
|
|
697
|
+
await FfiClient.instance.waitFor<SetLocalAttributesCallback>(
|
|
698
|
+
(ev) => ev.message.case == 'setLocalAttributes' && ev.message.value.asyncId == res.asyncId,
|
|
699
|
+
{ signal: this.disconnectSignal },
|
|
700
|
+
);
|
|
653
701
|
}
|
|
654
702
|
|
|
655
703
|
async publishTrack(
|
|
@@ -669,9 +717,10 @@ export class LocalParticipant extends Participant {
|
|
|
669
717
|
});
|
|
670
718
|
|
|
671
719
|
try {
|
|
672
|
-
const cb = await FfiClient.instance.waitFor<PublishTrackCallback>(
|
|
673
|
-
|
|
674
|
-
|
|
720
|
+
const cb = await FfiClient.instance.waitFor<PublishTrackCallback>(
|
|
721
|
+
(ev) => ev.message.case == 'publishTrack' && ev.message.value.asyncId == res.asyncId,
|
|
722
|
+
{ signal: this.disconnectSignal },
|
|
723
|
+
);
|
|
675
724
|
|
|
676
725
|
switch (cb.message.case) {
|
|
677
726
|
case 'publication':
|
|
@@ -702,9 +751,10 @@ export class LocalParticipant extends Participant {
|
|
|
702
751
|
message: { case: 'unpublishTrack', value: req },
|
|
703
752
|
});
|
|
704
753
|
|
|
705
|
-
const cb = await FfiClient.instance.waitFor<UnpublishTrackCallback>(
|
|
706
|
-
|
|
707
|
-
|
|
754
|
+
const cb = await FfiClient.instance.waitFor<UnpublishTrackCallback>(
|
|
755
|
+
(ev) => ev.message.case == 'unpublishTrack' && ev.message.value.asyncId == res.asyncId,
|
|
756
|
+
{ signal: this.disconnectSignal },
|
|
757
|
+
);
|
|
708
758
|
|
|
709
759
|
if (cb.error) {
|
|
710
760
|
throw new Error(cb.error);
|
|
@@ -744,9 +794,10 @@ export class LocalParticipant extends Participant {
|
|
|
744
794
|
message: { case: 'performRpc', value: req },
|
|
745
795
|
});
|
|
746
796
|
|
|
747
|
-
const cb = await FfiClient.instance.waitFor<PerformRpcCallback>(
|
|
748
|
-
|
|
749
|
-
|
|
797
|
+
const cb = await FfiClient.instance.waitFor<PerformRpcCallback>(
|
|
798
|
+
(ev) => ev.message.case === 'performRpc' && ev.message.value.asyncId === res.asyncId,
|
|
799
|
+
{ signal: this.disconnectSignal },
|
|
800
|
+
);
|
|
750
801
|
|
|
751
802
|
if (cb.error) {
|
|
752
803
|
throw RpcError.fromProto(cb.error);
|
package/src/room.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { Mutex } from '@livekit/mutex';
|
|
5
5
|
import { EncryptionState, type EncryptionType } from '@livekit/rtc-ffi-bindings';
|
|
6
6
|
import type { FfiEvent } from '@livekit/rtc-ffi-bindings';
|
|
7
|
-
import
|
|
7
|
+
import { DisconnectReason, type OwnedParticipant } from '@livekit/rtc-ffi-bindings';
|
|
8
8
|
import type { DataStream_Trailer, DisconnectCallback } from '@livekit/rtc-ffi-bindings';
|
|
9
9
|
import {
|
|
10
10
|
type ConnectCallback,
|
|
@@ -96,6 +96,15 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
96
96
|
|
|
97
97
|
private preConnectEvents: FfiEvent[] = [];
|
|
98
98
|
|
|
99
|
+
// Aborted on disconnect to cancel any pending FfiClient.waitFor() listeners,
|
|
100
|
+
// preventing them from leaking when the room goes away.
|
|
101
|
+
private disconnectController = new AbortController();
|
|
102
|
+
|
|
103
|
+
// Guards cleanupOnDisconnect so the ConnectionStateChanged/Disconnected
|
|
104
|
+
// events fire exactly once, no matter which path (explicit disconnect()
|
|
105
|
+
// vs. FFI 'disconnected' event) wins the race.
|
|
106
|
+
private hasCleanedUp = false;
|
|
107
|
+
|
|
99
108
|
private _token?: string;
|
|
100
109
|
private _serverUrl?: string;
|
|
101
110
|
|
|
@@ -131,6 +140,11 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
131
140
|
return this._serverUrl;
|
|
132
141
|
}
|
|
133
142
|
|
|
143
|
+
// Shared promise for concurrent getSid() callers. Without this, each call
|
|
144
|
+
// registers its own RoomSidChanged + Disconnected listeners, and if many
|
|
145
|
+
// calls race only one of each pair is cleaned up — leaking the rest.
|
|
146
|
+
private sidPromise?: Promise<string>;
|
|
147
|
+
|
|
134
148
|
/**
|
|
135
149
|
* Gets the room's server ID. This ID is assigned by the LiveKit server
|
|
136
150
|
* and is unique for each room session.
|
|
@@ -144,19 +158,26 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
144
158
|
if (this.info?.sid && this.info.sid !== '') {
|
|
145
159
|
return this.info.sid;
|
|
146
160
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
161
|
+
if (!this.sidPromise) {
|
|
162
|
+
this.sidPromise = new Promise<string>((resolve, reject) => {
|
|
163
|
+
const handleDisconnect = () => {
|
|
150
164
|
this.off(RoomEvent.RoomSidChanged, handleRoomUpdate);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
165
|
+
this.sidPromise = undefined;
|
|
166
|
+
reject('Room disconnected before room server id was available');
|
|
167
|
+
};
|
|
168
|
+
const handleRoomUpdate = (sid: string) => {
|
|
169
|
+
if (sid !== '') {
|
|
170
|
+
this.off(RoomEvent.RoomSidChanged, handleRoomUpdate);
|
|
171
|
+
this.off(RoomEvent.Disconnected as any, handleDisconnect);
|
|
172
|
+
this.sidPromise = undefined;
|
|
173
|
+
resolve(sid);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
this.on(RoomEvent.RoomSidChanged, handleRoomUpdate);
|
|
177
|
+
this.once(RoomEvent.Disconnected, handleDisconnect);
|
|
158
178
|
});
|
|
159
|
-
}
|
|
179
|
+
}
|
|
180
|
+
return this.sidPromise;
|
|
160
181
|
}
|
|
161
182
|
|
|
162
183
|
get numParticipants(): number {
|
|
@@ -219,45 +240,56 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
219
240
|
|
|
220
241
|
FfiClient.instance.on(FfiClientEvent.FfiEvent, this.onFfiEvent);
|
|
221
242
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const cb = await FfiClient.instance.waitFor<ConnectCallback>((ev: FfiEvent) => {
|
|
230
|
-
return ev.message.case == 'connect' && ev.message.value.asyncId == res.asyncId;
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
log.debug('Connect callback received');
|
|
243
|
+
try {
|
|
244
|
+
const res = FfiClient.instance.request<ConnectResponse>({
|
|
245
|
+
message: {
|
|
246
|
+
case: 'connect',
|
|
247
|
+
value: req,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
234
250
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
this.e2eeManager = e2eeEnabled && new E2EEManager(this.ffiHandle.handle, e2eeOptions);
|
|
251
|
+
const cb = await FfiClient.instance.waitFor<ConnectCallback>((ev: FfiEvent) => {
|
|
252
|
+
return ev.message.case == 'connect' && ev.message.value.asyncId == res.asyncId;
|
|
253
|
+
});
|
|
239
254
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
255
|
+
log.debug('Connect callback received');
|
|
256
|
+
|
|
257
|
+
switch (cb.message.case) {
|
|
258
|
+
case 'result':
|
|
259
|
+
this.ffiHandle = new FfiHandle(cb.message.value.room!.handle!.id!);
|
|
260
|
+
this.e2eeManager = e2eeEnabled && new E2EEManager(this.ffiHandle.handle, e2eeOptions);
|
|
261
|
+
|
|
262
|
+
this._token = token;
|
|
263
|
+
this._serverUrl = url;
|
|
264
|
+
this.info = cb.message.value.room!.info;
|
|
265
|
+
this.connectionState = ConnectionState.CONN_CONNECTED;
|
|
266
|
+
// Reset the abort controller for this connection session so that
|
|
267
|
+
// a previous disconnect doesn't immediately cancel new operations.
|
|
268
|
+
this.disconnectController = new AbortController();
|
|
269
|
+
this.hasCleanedUp = false;
|
|
270
|
+
this.localParticipant = new LocalParticipant(
|
|
271
|
+
cb.message.value.localParticipant!,
|
|
272
|
+
this.ffiEventLock,
|
|
273
|
+
this.disconnectController.signal,
|
|
274
|
+
);
|
|
248
275
|
|
|
249
|
-
|
|
250
|
-
|
|
276
|
+
for (const pt of cb.message.value.participants) {
|
|
277
|
+
const rp = this.createRemoteParticipant(pt.participant!);
|
|
251
278
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
279
|
+
for (const pub of pt.publications) {
|
|
280
|
+
const publication = new RemoteTrackPublication(pub);
|
|
281
|
+
rp.trackPublications.set(publication.sid!, publication);
|
|
282
|
+
}
|
|
255
283
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
284
|
+
break;
|
|
285
|
+
case 'error':
|
|
286
|
+
default:
|
|
287
|
+
throw new ConnectError(cb.message.value || '');
|
|
288
|
+
}
|
|
289
|
+
} catch (e) {
|
|
290
|
+
FfiClient.instance.off(FfiClientEvent.FfiEvent, this.onFfiEvent);
|
|
291
|
+
this.preConnectEvents = [];
|
|
292
|
+
throw e;
|
|
261
293
|
}
|
|
262
294
|
}
|
|
263
295
|
|
|
@@ -283,10 +315,59 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
283
315
|
return ev.message.case == 'disconnect' && ev.message.value.asyncId == res.asyncId;
|
|
284
316
|
});
|
|
285
317
|
|
|
318
|
+
this.cleanupOnDisconnect(DisconnectReason.CLIENT_INITIATED);
|
|
286
319
|
FfiClient.instance.removeListener(FfiClientEvent.FfiEvent, this.onFfiEvent);
|
|
320
|
+
|
|
287
321
|
this.removeAllListeners();
|
|
288
322
|
}
|
|
289
323
|
|
|
324
|
+
// Runs at most once per connection session. The FFI layer and explicit
|
|
325
|
+
// disconnect() both race to get here — whichever wins emits the events,
|
|
326
|
+
// the other is a no-op. A reconnect via connect() clears hasCleanedUp.
|
|
327
|
+
private cleanupOnDisconnect(reason: DisconnectReason = DisconnectReason.CLIENT_INITIATED) {
|
|
328
|
+
if (this.hasCleanedUp) return;
|
|
329
|
+
this.hasCleanedUp = true;
|
|
330
|
+
|
|
331
|
+
// Error all in-progress stream controllers to prevent FD leaks.
|
|
332
|
+
// Streams that were receiving data but never got a trailer (e.g. the sender
|
|
333
|
+
// disconnected mid-transfer) would otherwise keep their ReadableStream open
|
|
334
|
+
// indefinitely, leaking the underlying controller and any buffered chunks.
|
|
335
|
+
// Using error() instead of close() signals an abnormal termination to consumers.
|
|
336
|
+
for (const [, streamController] of this.byteStreamControllers) {
|
|
337
|
+
try {
|
|
338
|
+
streamController.controller.error(new Error('Disconnected while receiving'));
|
|
339
|
+
} catch {
|
|
340
|
+
// controller may already be closed or errored
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
this.byteStreamControllers.clear();
|
|
344
|
+
|
|
345
|
+
for (const [, streamController] of this.textStreamControllers) {
|
|
346
|
+
try {
|
|
347
|
+
streamController.controller.error(new Error('Disconnected while receiving'));
|
|
348
|
+
} catch {
|
|
349
|
+
// controller may already be closed or errored
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
this.textStreamControllers.clear();
|
|
353
|
+
|
|
354
|
+
// Clear sidPromise before removing listeners so that a reconnect
|
|
355
|
+
// doesn't return a stale, permanently-pending promise.
|
|
356
|
+
this.sidPromise = undefined;
|
|
357
|
+
// Abort all pending FfiClient.waitFor() listeners so they don't leak.
|
|
358
|
+
// This causes any in-flight operations (publishData, publishTrack, etc.)
|
|
359
|
+
// to reject and clean up their event listeners.
|
|
360
|
+
this.disconnectController.abort();
|
|
361
|
+
|
|
362
|
+
// Only emit ConnectionStateChanged if the FFI 'connectionStateChanged'
|
|
363
|
+
// path didn't already flip us to DISCONNECTED.
|
|
364
|
+
if (this.connectionState !== ConnectionState.CONN_DISCONNECTED) {
|
|
365
|
+
this.connectionState = ConnectionState.CONN_DISCONNECTED;
|
|
366
|
+
this.emit(RoomEvent.ConnectionStateChanged, this.connectionState);
|
|
367
|
+
}
|
|
368
|
+
this.emit(RoomEvent.Disconnected, reason);
|
|
369
|
+
}
|
|
370
|
+
|
|
290
371
|
/**
|
|
291
372
|
* Registers a handler for incoming text data streams on a specific topic.
|
|
292
373
|
* Text streams are used for receiving structured text data from other participants.
|
|
@@ -418,6 +499,9 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
418
499
|
participant.trackPublications.delete(ev.value.publicationSid!);
|
|
419
500
|
if (publication) {
|
|
420
501
|
this.emit(RoomEvent.TrackUnpublished, publication, participant);
|
|
502
|
+
// Dispose eagerly so handles don't accumulate when a participant
|
|
503
|
+
// publishes and unpublishes many tracks during a long-lived session.
|
|
504
|
+
publication.ffiHandle.dispose();
|
|
421
505
|
} else {
|
|
422
506
|
log.warn(`RoomEvent.TrackUnpublished: Could not find publication`);
|
|
423
507
|
}
|
|
@@ -594,12 +678,20 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
594
678
|
this.emit(RoomEvent.EncryptionError, new Error('internal server error'));
|
|
595
679
|
}
|
|
596
680
|
} else if (ev.case == 'connectionStateChanged') {
|
|
597
|
-
|
|
681
|
+
const newState = ev.value.state!;
|
|
682
|
+
// Skip redundant transitions — cleanupOnDisconnect may have already
|
|
683
|
+
// flipped us to DISCONNECTED, and we don't want to emit the event twice.
|
|
684
|
+
if (this.connectionState === newState) {
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
this.connectionState = newState;
|
|
598
688
|
this.emit(RoomEvent.ConnectionStateChanged, this.connectionState);
|
|
599
689
|
/*} else if (ev.case == 'connected') {
|
|
600
690
|
this.emit(RoomEvent.Connected);*/
|
|
601
691
|
} else if (ev.case == 'disconnected') {
|
|
602
|
-
|
|
692
|
+
// cleanupOnDisconnect emits RoomEvent.Disconnected itself (guarded by
|
|
693
|
+
// hasCleanedUp so it fires exactly once across both disconnect paths).
|
|
694
|
+
this.cleanupOnDisconnect(ev.value.reason!);
|
|
603
695
|
} else if (ev.case == 'reconnecting') {
|
|
604
696
|
this.emit(RoomEvent.Reconnecting);
|
|
605
697
|
} else if (ev.case == 'reconnected') {
|