@livekit/rtc-node 0.13.25 → 0.13.27
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 +114 -54
- package/dist/room.cjs.map +1 -1
- package/dist/room.d.cts +7 -1
- package/dist/room.d.ts +7 -1
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +107 -47
- 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 +142 -50
- 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,11 +96,20 @@ 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;
|
|
110
|
+
private _connectionState: ConnectionState = ConnectionState.CONN_DISCONNECTED;
|
|
101
111
|
|
|
102
112
|
e2eeManager?: E2EEManager;
|
|
103
|
-
connectionState: ConnectionState = ConnectionState.CONN_DISCONNECTED;
|
|
104
113
|
|
|
105
114
|
remoteParticipants: Map<string, RemoteParticipant> = new Map();
|
|
106
115
|
localParticipant?: LocalParticipant;
|
|
@@ -109,6 +118,10 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
109
118
|
super();
|
|
110
119
|
}
|
|
111
120
|
|
|
121
|
+
get connectionState() {
|
|
122
|
+
return this._connectionState;
|
|
123
|
+
}
|
|
124
|
+
|
|
112
125
|
get name(): string | undefined {
|
|
113
126
|
return this.info?.name;
|
|
114
127
|
}
|
|
@@ -131,6 +144,11 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
131
144
|
return this._serverUrl;
|
|
132
145
|
}
|
|
133
146
|
|
|
147
|
+
// Shared promise for concurrent getSid() callers. Without this, each call
|
|
148
|
+
// registers its own RoomSidChanged + Disconnected listeners, and if many
|
|
149
|
+
// calls race only one of each pair is cleaned up — leaking the rest.
|
|
150
|
+
private sidPromise?: Promise<string>;
|
|
151
|
+
|
|
134
152
|
/**
|
|
135
153
|
* Gets the room's server ID. This ID is assigned by the LiveKit server
|
|
136
154
|
* and is unique for each room session.
|
|
@@ -144,19 +162,26 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
144
162
|
if (this.info?.sid && this.info.sid !== '') {
|
|
145
163
|
return this.info.sid;
|
|
146
164
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
165
|
+
if (!this.sidPromise) {
|
|
166
|
+
this.sidPromise = new Promise<string>((resolve, reject) => {
|
|
167
|
+
const handleDisconnect = () => {
|
|
150
168
|
this.off(RoomEvent.RoomSidChanged, handleRoomUpdate);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
169
|
+
this.sidPromise = undefined;
|
|
170
|
+
reject('Room disconnected before room server id was available');
|
|
171
|
+
};
|
|
172
|
+
const handleRoomUpdate = (sid: string) => {
|
|
173
|
+
if (sid !== '') {
|
|
174
|
+
this.off(RoomEvent.RoomSidChanged, handleRoomUpdate);
|
|
175
|
+
this.off(RoomEvent.Disconnected as any, handleDisconnect);
|
|
176
|
+
this.sidPromise = undefined;
|
|
177
|
+
resolve(sid);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
this.on(RoomEvent.RoomSidChanged, handleRoomUpdate);
|
|
181
|
+
this.once(RoomEvent.Disconnected, handleDisconnect);
|
|
158
182
|
});
|
|
159
|
-
}
|
|
183
|
+
}
|
|
184
|
+
return this.sidPromise;
|
|
160
185
|
}
|
|
161
186
|
|
|
162
187
|
get numParticipants(): number {
|
|
@@ -219,45 +244,56 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
219
244
|
|
|
220
245
|
FfiClient.instance.on(FfiClientEvent.FfiEvent, this.onFfiEvent);
|
|
221
246
|
|
|
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');
|
|
247
|
+
try {
|
|
248
|
+
const res = FfiClient.instance.request<ConnectResponse>({
|
|
249
|
+
message: {
|
|
250
|
+
case: 'connect',
|
|
251
|
+
value: req,
|
|
252
|
+
},
|
|
253
|
+
});
|
|
234
254
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
this.e2eeManager = e2eeEnabled && new E2EEManager(this.ffiHandle.handle, e2eeOptions);
|
|
255
|
+
const cb = await FfiClient.instance.waitFor<ConnectCallback>((ev: FfiEvent) => {
|
|
256
|
+
return ev.message.case == 'connect' && ev.message.value.asyncId == res.asyncId;
|
|
257
|
+
});
|
|
239
258
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
259
|
+
log.debug('Connect callback received');
|
|
260
|
+
|
|
261
|
+
switch (cb.message.case) {
|
|
262
|
+
case 'result':
|
|
263
|
+
this.ffiHandle = new FfiHandle(cb.message.value.room!.handle!.id!);
|
|
264
|
+
this.e2eeManager = e2eeEnabled && new E2EEManager(this.ffiHandle.handle, e2eeOptions);
|
|
265
|
+
|
|
266
|
+
this._token = token;
|
|
267
|
+
this._serverUrl = url;
|
|
268
|
+
this.info = cb.message.value.room!.info;
|
|
269
|
+
// Reset the abort controller for this connection session so that
|
|
270
|
+
// a previous disconnect doesn't immediately cancel new operations.
|
|
271
|
+
this.disconnectController = new AbortController();
|
|
272
|
+
this.hasCleanedUp = false;
|
|
273
|
+
this.localParticipant = new LocalParticipant(
|
|
274
|
+
cb.message.value.localParticipant!,
|
|
275
|
+
this.ffiEventLock,
|
|
276
|
+
this.disconnectController.signal,
|
|
277
|
+
);
|
|
248
278
|
|
|
249
|
-
|
|
250
|
-
|
|
279
|
+
for (const pt of cb.message.value.participants) {
|
|
280
|
+
const rp = this.createRemoteParticipant(pt.participant!);
|
|
251
281
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
282
|
+
for (const pub of pt.publications) {
|
|
283
|
+
const publication = new RemoteTrackPublication(pub);
|
|
284
|
+
rp.trackPublications.set(publication.sid!, publication);
|
|
285
|
+
}
|
|
255
286
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
287
|
+
this.updateConnectionState(ConnectionState.CONN_CONNECTED);
|
|
288
|
+
break;
|
|
289
|
+
case 'error':
|
|
290
|
+
default:
|
|
291
|
+
throw new ConnectError(cb.message.value || '');
|
|
292
|
+
}
|
|
293
|
+
} catch (e) {
|
|
294
|
+
FfiClient.instance.off(FfiClientEvent.FfiEvent, this.onFfiEvent);
|
|
295
|
+
this.preConnectEvents = [];
|
|
296
|
+
throw e;
|
|
261
297
|
}
|
|
262
298
|
}
|
|
263
299
|
|
|
@@ -283,10 +319,62 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
283
319
|
return ev.message.case == 'disconnect' && ev.message.value.asyncId == res.asyncId;
|
|
284
320
|
});
|
|
285
321
|
|
|
322
|
+
this.cleanupOnDisconnect(DisconnectReason.CLIENT_INITIATED);
|
|
286
323
|
FfiClient.instance.removeListener(FfiClientEvent.FfiEvent, this.onFfiEvent);
|
|
324
|
+
|
|
287
325
|
this.removeAllListeners();
|
|
288
326
|
}
|
|
289
327
|
|
|
328
|
+
private updateConnectionState(newState: ConnectionState) {
|
|
329
|
+
if (this._connectionState === newState) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this._connectionState = newState;
|
|
333
|
+
this.emit(RoomEvent.ConnectionStateChanged, this._connectionState);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Runs at most once per connection session. The FFI layer and explicit
|
|
337
|
+
// disconnect() both race to get here — whichever wins emits the events,
|
|
338
|
+
// the other is a no-op. A reconnect via connect() clears hasCleanedUp.
|
|
339
|
+
private cleanupOnDisconnect(reason: DisconnectReason = DisconnectReason.CLIENT_INITIATED) {
|
|
340
|
+
if (this.hasCleanedUp) return;
|
|
341
|
+
this.hasCleanedUp = true;
|
|
342
|
+
|
|
343
|
+
// Error all in-progress stream controllers to prevent FD leaks.
|
|
344
|
+
// Streams that were receiving data but never got a trailer (e.g. the sender
|
|
345
|
+
// disconnected mid-transfer) would otherwise keep their ReadableStream open
|
|
346
|
+
// indefinitely, leaking the underlying controller and any buffered chunks.
|
|
347
|
+
// Using error() instead of close() signals an abnormal termination to consumers.
|
|
348
|
+
for (const [, streamController] of this.byteStreamControllers) {
|
|
349
|
+
try {
|
|
350
|
+
streamController.controller.error(new Error('Disconnected while receiving'));
|
|
351
|
+
} catch {
|
|
352
|
+
// controller may already be closed or errored
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
this.byteStreamControllers.clear();
|
|
356
|
+
|
|
357
|
+
for (const [, streamController] of this.textStreamControllers) {
|
|
358
|
+
try {
|
|
359
|
+
streamController.controller.error(new Error('Disconnected while receiving'));
|
|
360
|
+
} catch {
|
|
361
|
+
// controller may already be closed or errored
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
this.textStreamControllers.clear();
|
|
365
|
+
|
|
366
|
+
// Clear sidPromise before removing listeners so that a reconnect
|
|
367
|
+
// doesn't return a stale, permanently-pending promise.
|
|
368
|
+
this.sidPromise = undefined;
|
|
369
|
+
// Abort all pending FfiClient.waitFor() listeners so they don't leak.
|
|
370
|
+
// This causes any in-flight operations (publishData, publishTrack, etc.)
|
|
371
|
+
// to reject and clean up their event listeners.
|
|
372
|
+
this.disconnectController.abort();
|
|
373
|
+
|
|
374
|
+
this.updateConnectionState(ConnectionState.CONN_DISCONNECTED);
|
|
375
|
+
this.emit(RoomEvent.Disconnected, reason);
|
|
376
|
+
}
|
|
377
|
+
|
|
290
378
|
/**
|
|
291
379
|
* Registers a handler for incoming text data streams on a specific topic.
|
|
292
380
|
* Text streams are used for receiving structured text data from other participants.
|
|
@@ -418,6 +506,9 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
418
506
|
participant.trackPublications.delete(ev.value.publicationSid!);
|
|
419
507
|
if (publication) {
|
|
420
508
|
this.emit(RoomEvent.TrackUnpublished, publication, participant);
|
|
509
|
+
// Dispose eagerly so handles don't accumulate when a participant
|
|
510
|
+
// publishes and unpublishes many tracks during a long-lived session.
|
|
511
|
+
publication.ffiHandle.dispose();
|
|
421
512
|
} else {
|
|
422
513
|
log.warn(`RoomEvent.TrackUnpublished: Could not find publication`);
|
|
423
514
|
}
|
|
@@ -594,12 +685,13 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
|
|
|
594
685
|
this.emit(RoomEvent.EncryptionError, new Error('internal server error'));
|
|
595
686
|
}
|
|
596
687
|
} else if (ev.case == 'connectionStateChanged') {
|
|
597
|
-
this.
|
|
598
|
-
this.emit(RoomEvent.ConnectionStateChanged, this.connectionState);
|
|
688
|
+
this.updateConnectionState(ev.value.state!);
|
|
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') {
|