@voltkit-rtc/client 0.7.0 → 0.7.2
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/CHANGELOG.md +99 -0
- package/README.md +39 -2
- package/dist/cjs/broadcast.d.ts +21 -2
- package/dist/cjs/broadcast.d.ts.map +1 -1
- package/dist/cjs/broadcast.js +75 -11
- package/dist/cjs/broadcast.js.map +1 -1
- package/dist/cjs/chat.d.ts +13 -0
- package/dist/cjs/chat.d.ts.map +1 -1
- package/dist/cjs/chat.js +65 -9
- package/dist/cjs/chat.js.map +1 -1
- package/dist/cjs/index.d.ts +78 -3
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +269 -61
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/broadcast.d.ts +21 -2
- package/dist/esm/broadcast.d.ts.map +1 -1
- package/dist/esm/broadcast.js +75 -11
- package/dist/esm/broadcast.js.map +1 -1
- package/dist/esm/chat.d.ts +13 -0
- package/dist/esm/chat.d.ts.map +1 -1
- package/dist/esm/chat.js +65 -9
- package/dist/esm/chat.js.map +1 -1
- package/dist/esm/index.d.ts +78 -3
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +269 -61
- package/dist/esm/index.js.map +1 -1
- package/package.json +1 -1
- package/src/broadcast.ts +68 -11
- package/src/chat.ts +63 -8
- package/src/index.ts +266 -56
package/src/index.ts
CHANGED
|
@@ -349,7 +349,11 @@ export interface ClientEvents extends ChatEvents {
|
|
|
349
349
|
localStreamChanged: (stream: MediaStream | null) => void;
|
|
350
350
|
/** Media stopped arriving but the connection is intact -- the publisher is reconnecting. */
|
|
351
351
|
streamInterrupted: (e: { reason: string }) => void;
|
|
352
|
-
/**
|
|
352
|
+
/**
|
|
353
|
+
* The session ended and will not recover. Nothing further will be emitted.
|
|
354
|
+
* Whatever the reason, the camera, microphone, screen, music and recording
|
|
355
|
+
* are released with it, as close() does.
|
|
356
|
+
*/
|
|
353
357
|
closed: (reason: ClosedReason) => void;
|
|
354
358
|
/**
|
|
355
359
|
* The account behind this session ran out of minutes or reached a usage
|
|
@@ -411,6 +415,8 @@ interface Pending {
|
|
|
411
415
|
resolve: (v: unknown) => void;
|
|
412
416
|
reject: (e: Error) => void;
|
|
413
417
|
timer: ReturnType<typeof setTimeout>;
|
|
418
|
+
/** The socket it was sent on: only that socket can answer it. */
|
|
419
|
+
ws: WebSocket;
|
|
414
420
|
}
|
|
415
421
|
|
|
416
422
|
const RPC_TIMEOUT_MS = 15_000;
|
|
@@ -436,6 +442,9 @@ const isLayer = (value: unknown): value is number => Number.isInteger(value) &&
|
|
|
436
442
|
*/
|
|
437
443
|
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
438
444
|
|
|
445
|
+
/** Why a connect() gave up: close() ran while it was under way, and close wins. */
|
|
446
|
+
const closedWhileConnecting = () => new RpcError('not-connected', 'the session was closed while connecting');
|
|
447
|
+
|
|
439
448
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
440
449
|
const unpublishedWhile = (what: string) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
441
450
|
|
|
@@ -488,6 +497,16 @@ export class StreamClient {
|
|
|
488
497
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
489
498
|
private music: MusicSource | null = null;
|
|
490
499
|
private musicProducerId: string | null = null;
|
|
500
|
+
/**
|
|
501
|
+
* Bumped by every publishMusic() and stopMusic(). A music producer still
|
|
502
|
+
* being made when it changes belongs to a publish that is over: it is
|
|
503
|
+
* closed, not kept. publishGeneration alone missed stopMusic(), and the
|
|
504
|
+
* music went out after stopMusic() had returned.
|
|
505
|
+
*/
|
|
506
|
+
private musicOperation = 0;
|
|
507
|
+
// Music publishes run one at a time: the server takes one producer per
|
|
508
|
+
// source, so the next is produced only once the last one is settled.
|
|
509
|
+
private musicQueue: Promise<unknown> = Promise.resolve();
|
|
491
510
|
private screenStream: MediaStream | null = null;
|
|
492
511
|
private screenMode: ScreenMode = 'detail';
|
|
493
512
|
private screenAudioEnabled = true;
|
|
@@ -504,13 +523,23 @@ export class StreamClient {
|
|
|
504
523
|
private frameRateWarmupSamples = 2;
|
|
505
524
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
506
525
|
private readonly remoteSources = new Map<string, TrackSource>();
|
|
526
|
+
/** Identity of each pending/active subscription; cleared on close/reconnect. */
|
|
527
|
+
private readonly subscriptions = new Map<string, object>();
|
|
507
528
|
/**
|
|
508
529
|
* The layers asked for each remote video, by producerId. A reconnect
|
|
509
530
|
* consumes everything again, and the new consumer would otherwise start at
|
|
510
531
|
* the full picture.
|
|
511
532
|
*/
|
|
512
533
|
private readonly preferredLayers = new Map<string, PreferredLayers>();
|
|
534
|
+
/** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
|
|
535
|
+
private readonly endRoomWaiters = new Set<() => void>();
|
|
513
536
|
private closedByUs = false;
|
|
537
|
+
/**
|
|
538
|
+
* Bumped when the session ends for good: close(), or finish(). A connect()
|
|
539
|
+
* under way when it did -- the app's, or a reconnect's -- sees the change
|
|
540
|
+
* after its next await and stops, so a closed session never comes back.
|
|
541
|
+
*/
|
|
542
|
+
private lifecycle = 0;
|
|
514
543
|
private reconnecting = false;
|
|
515
544
|
private reconnectAttempt = 0;
|
|
516
545
|
/** Set once a connect() has joined and set up. Only such a session is reconnected. */
|
|
@@ -539,6 +568,17 @@ export class StreamClient {
|
|
|
539
568
|
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
540
569
|
private tokenExpiry: number | null;
|
|
541
570
|
private tokenTimer: ReturnType<typeof setTimeout> | null = null;
|
|
571
|
+
/**
|
|
572
|
+
* Bumped whenever the server hands this client a token (its own refresh, a
|
|
573
|
+
* promotion's). An updateToken() that started before then keeps the token
|
|
574
|
+
* it was given only if nothing newer arrived while it waited: its reply,
|
|
575
|
+
* or its fallback once the socket closed, used to replace a promotion's
|
|
576
|
+
* token, and the reconnect joined with the old one.
|
|
577
|
+
*/
|
|
578
|
+
private serverTokenRevision = 0;
|
|
579
|
+
/** updateToken() calls in call order, and the newest one whose token is held: a later call wins over an earlier one answered after it. */
|
|
580
|
+
private tokenRequests = 0;
|
|
581
|
+
private tokenRequestHeld = 0;
|
|
542
582
|
/** Whose each received track is and whether it is switched off, by producerId. */
|
|
543
583
|
private readonly remoteInfo = new Map<string, { identity: string; kind: 'audio' | 'video'; paused: boolean }>();
|
|
544
584
|
/** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
|
|
@@ -597,10 +637,23 @@ export class StreamClient {
|
|
|
597
637
|
/** Over for good: the state, then the event that says why. */
|
|
598
638
|
private finish(reason: ClosedReason): void {
|
|
599
639
|
this.closedByUs = true;
|
|
640
|
+
this.lifecycle += 1;
|
|
600
641
|
this.setState('closed');
|
|
601
642
|
this.emit('closed', reason);
|
|
602
643
|
}
|
|
603
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Over for good without the app asking: finish, then release everything
|
|
647
|
+
* local as close() does -- camera, microphone, screen, music, recording.
|
|
648
|
+
* kicked and roomClosed always did; a replaced socket, a close without
|
|
649
|
+
* roomClosed and a reconnect that gave up left them capturing (and the
|
|
650
|
+
* recording uploading) after `closed`, until the app called close().
|
|
651
|
+
*/
|
|
652
|
+
private end(reason: ClosedReason): void {
|
|
653
|
+
this.finish(reason);
|
|
654
|
+
this.close();
|
|
655
|
+
}
|
|
656
|
+
|
|
604
657
|
private stopTimers(): void {
|
|
605
658
|
if (this.tokenTimer) clearTimeout(this.tokenTimer);
|
|
606
659
|
this.tokenTimer = null;
|
|
@@ -624,20 +677,31 @@ export class StreamClient {
|
|
|
624
677
|
// ------------------------------------------------------------- connection
|
|
625
678
|
|
|
626
679
|
async connect(): Promise<JoinResult> {
|
|
680
|
+
const lifecycle = this.lifecycle;
|
|
681
|
+
const closed = () => lifecycle !== this.lifecycle;
|
|
627
682
|
let result: JoinResult;
|
|
628
683
|
if (this.state === 'idle' || this.state === 'closed') this.setState('connecting');
|
|
684
|
+
// The socket this call opens. A later connect() replaces it, and this
|
|
685
|
+
// call's failure must then leave the newer socket, and the state, alone.
|
|
686
|
+
let socket: WebSocket | null = null;
|
|
629
687
|
try {
|
|
630
|
-
|
|
688
|
+
const opening = this.openSocket();
|
|
689
|
+
socket = this.ws;
|
|
690
|
+
await opening;
|
|
631
691
|
result = (await this.rpc('join', { token: this.token })) as JoinResult;
|
|
632
692
|
this.joinResult = result;
|
|
633
693
|
|
|
634
694
|
this.device = this.createDevice();
|
|
635
695
|
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
696
|
+
// close() ran while the Device loaded: going on would set the state
|
|
697
|
+
// back to connected, and emit joined, after closed.
|
|
698
|
+
if (closed()) throw closedWhileConnecting();
|
|
636
699
|
} catch (err) {
|
|
637
700
|
// A session that never got going: the app has the error. Left open,
|
|
638
701
|
// the socket would be closed by the server's join deadline instead, and
|
|
639
702
|
// that close would start reconnecting a session that never began.
|
|
640
|
-
|
|
703
|
+
const superseded = this.ws !== null && this.ws !== socket;
|
|
704
|
+
if (!this.joinedOnce && !superseded) {
|
|
641
705
|
this.detachSocket();
|
|
642
706
|
if (this.state === 'connecting') this.setState('idle');
|
|
643
707
|
}
|
|
@@ -657,6 +721,7 @@ export class StreamClient {
|
|
|
657
721
|
// decides what may return; reconnecting never turns a blocked device on.
|
|
658
722
|
if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
|
|
659
723
|
await this.unpublish();
|
|
724
|
+
if (closed()) throw closedWhileConnecting();
|
|
660
725
|
}
|
|
661
726
|
const own = result.participants.find((p) => p.identity === result.identity);
|
|
662
727
|
if (own?.muted) this.applyLocalModeration('audio');
|
|
@@ -677,16 +742,19 @@ export class StreamClient {
|
|
|
677
742
|
for (const producerId of this.remoteVideoLayers.keys()) if (!present.has(producerId)) this.remoteVideoLayers.delete(producerId);
|
|
678
743
|
|
|
679
744
|
// Subscribe to everything already publishing before we announce readiness.
|
|
745
|
+
// A close() part-way stops it: what is left would only fail, after closed.
|
|
680
746
|
for (const p of result.producers) {
|
|
681
|
-
|
|
747
|
+
if (closed()) throw closedWhileConnecting();
|
|
748
|
+
await this.consume(p).catch((err) => { if (!closed()) this.emit('error', err as Error); });
|
|
682
749
|
}
|
|
683
750
|
|
|
684
751
|
// Restore our own broadcast. This is what makes an origin failover
|
|
685
752
|
// survivable for the host: it reconnects onto the new origin and the
|
|
686
753
|
// stream comes back on its own.
|
|
687
754
|
if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
|
|
688
|
-
await this.republish().catch((err) => this.emit('error', err as Error));
|
|
755
|
+
await this.republish().catch((err) => { if (!closed()) this.emit('error', err as Error); });
|
|
689
756
|
}
|
|
757
|
+
if (closed()) throw closedWhileConnecting();
|
|
690
758
|
this.syncNetworkWatch();
|
|
691
759
|
return result;
|
|
692
760
|
}
|
|
@@ -735,17 +803,16 @@ export class StreamClient {
|
|
|
735
803
|
// Another session for this identity is live. Reconnecting would
|
|
736
804
|
// displace it, and it would displace us right back, forever.
|
|
737
805
|
this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
|
|
738
|
-
// The
|
|
739
|
-
// videoLimited sampling must not, or it reports after `closed`.
|
|
806
|
+
// The videoLimited sampling stops first, or it reports after `closed`.
|
|
740
807
|
this.limitWatch.reset();
|
|
741
|
-
this.
|
|
808
|
+
this.end('replaced');
|
|
742
809
|
return;
|
|
743
810
|
}
|
|
744
811
|
if (ev.code === CLOSE_ENDED) {
|
|
745
812
|
// Normally roomClosed has already ended the session; this covers a
|
|
746
813
|
// close that arrived without it.
|
|
747
814
|
this.limitWatch.reset();
|
|
748
|
-
this.
|
|
815
|
+
this.end('room-closed');
|
|
749
816
|
return;
|
|
750
817
|
}
|
|
751
818
|
// Before a first successful join the app is still waiting on
|
|
@@ -758,16 +825,32 @@ export class StreamClient {
|
|
|
758
825
|
});
|
|
759
826
|
}
|
|
760
827
|
|
|
761
|
-
private detachSocket(): void {
|
|
828
|
+
private detachSocket(reason = 'superseded', why = 'the connection was replaced'): void {
|
|
762
829
|
const ws = this.ws;
|
|
763
830
|
this.ws = null;
|
|
764
|
-
if (ws
|
|
831
|
+
if (!ws) return;
|
|
832
|
+
if (ws.readyState <= WebSocket.OPEN) {
|
|
765
833
|
try {
|
|
766
|
-
ws.close(1000,
|
|
834
|
+
ws.close(1000, reason);
|
|
767
835
|
} catch {
|
|
768
836
|
/* already closing */
|
|
769
837
|
}
|
|
770
838
|
}
|
|
839
|
+
// Its replies are no longer read, so what waits on it would only wait out
|
|
840
|
+
// its 15 s timeout: it fails now, as on a close. Never replayed -- the
|
|
841
|
+
// server may already have acted on it.
|
|
842
|
+
this.failPending(ws, new RpcError('not-connected', why));
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* A reconnect that ends the session lets go of its socket. A refused join
|
|
847
|
+
* leaves that socket open (connect() keeps it once a session has joined),
|
|
848
|
+
* and the session would otherwise report closed with it still attached and
|
|
849
|
+
* still sending.
|
|
850
|
+
*/
|
|
851
|
+
private releaseSocket(): void {
|
|
852
|
+
this.detachSocket('gave up', 'the session has ended');
|
|
853
|
+
this.failAllPending(new RpcError('not-connected', 'the session has ended'));
|
|
771
854
|
}
|
|
772
855
|
|
|
773
856
|
/**
|
|
@@ -786,7 +869,8 @@ export class StreamClient {
|
|
|
786
869
|
this.reconnectAttempt += 1;
|
|
787
870
|
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
788
871
|
this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
789
|
-
this.
|
|
872
|
+
this.releaseSocket();
|
|
873
|
+
this.end('gave-up');
|
|
790
874
|
return;
|
|
791
875
|
}
|
|
792
876
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
@@ -801,12 +885,16 @@ export class StreamClient {
|
|
|
801
885
|
// for good, with no `closed`.
|
|
802
886
|
if (this.ws) return;
|
|
803
887
|
} catch (err) {
|
|
888
|
+
// Ended while this attempt was under way -- by close(), or by the
|
|
889
|
+
// server -- and that has been reported: nothing follows closed.
|
|
890
|
+
if (this.closedByUs) return;
|
|
804
891
|
this.emit('error', err as Error);
|
|
805
892
|
// Stop on failures a retry cannot resolve (the registry's terminal
|
|
806
893
|
// codes). Hammering a room that has ended produces one error per
|
|
807
894
|
// attempt forever and drowns out whatever actually went wrong.
|
|
808
895
|
if (err instanceof RpcError && isTerminalError(err.code)) {
|
|
809
|
-
this.
|
|
896
|
+
this.releaseSocket();
|
|
897
|
+
this.end('gave-up');
|
|
810
898
|
return;
|
|
811
899
|
}
|
|
812
900
|
}
|
|
@@ -822,6 +910,7 @@ export class StreamClient {
|
|
|
822
910
|
// others have already reported themselves and set closedByUs.
|
|
823
911
|
const wasOpen = !this.closedByUs;
|
|
824
912
|
this.closedByUs = true;
|
|
913
|
+
this.lifecycle += 1;
|
|
825
914
|
this.publishGeneration += 1;
|
|
826
915
|
this.wasPublishing = false;
|
|
827
916
|
this.teardownMedia();
|
|
@@ -841,11 +930,16 @@ export class StreamClient {
|
|
|
841
930
|
const ws = this.ws;
|
|
842
931
|
this.ws = null;
|
|
843
932
|
ws?.close(1000, 'client closed');
|
|
933
|
+
// Detached first, so the socket's own close handler no longer fails what
|
|
934
|
+
// was waiting on it: done here, or a join or a request in flight would
|
|
935
|
+
// wait out its 15 s timeout.
|
|
936
|
+
this.failAllPending(new RpcError('not-connected', 'the session was closed'));
|
|
844
937
|
this.setState('closed');
|
|
845
938
|
if (wasOpen) this.emit('closed', 'left');
|
|
846
939
|
}
|
|
847
940
|
|
|
848
941
|
private teardownMedia(): void {
|
|
942
|
+
this.subscriptions.clear();
|
|
849
943
|
for (const c of this.consumers.values()) c.close();
|
|
850
944
|
this.consumers.clear();
|
|
851
945
|
this.remoteInfo.clear();
|
|
@@ -920,17 +1014,27 @@ export class StreamClient {
|
|
|
920
1014
|
* `tokenExpiring`. On a live connection the server checks it like a join
|
|
921
1015
|
* token (`bad-token`, or `token-mismatch` for another room or identity; the
|
|
922
1016
|
* session then keeps its token); otherwise it is kept for the next join,
|
|
923
|
-
* which checks it there. Resolves to when
|
|
1017
|
+
* which checks it there. Resolves to when the token now held expires (unix
|
|
1018
|
+
* ms), when known. A newer token that arrives while this waits is kept
|
|
1019
|
+
* instead of this one: the server's (its own refresh, or a promotion's,
|
|
1020
|
+
* which carries the new role) or a later updateToken()'s.
|
|
924
1021
|
*/
|
|
925
1022
|
async updateToken(token: string): Promise<{ expiresAt: number | null }> {
|
|
926
1023
|
if (typeof token !== 'string' || token.trim() === '') throw new TypeError('updateToken needs a token');
|
|
927
1024
|
if (this.state === 'closed') throw new RpcError('not-connected', 'this session is closed; create a new client');
|
|
1025
|
+
const request = ++this.tokenRequests;
|
|
1026
|
+
const revision = this.serverTokenRevision;
|
|
1027
|
+
const keep = (expiresAt: number | null) => {
|
|
1028
|
+
if (revision === this.serverTokenRevision && request > this.tokenRequestHeld) {
|
|
1029
|
+
this.tokenRequestHeld = request;
|
|
1030
|
+
this.setToken(token, expiresAt);
|
|
1031
|
+
}
|
|
1032
|
+
return { expiresAt: this.tokenExpiry };
|
|
1033
|
+
};
|
|
928
1034
|
if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
|
|
929
1035
|
try {
|
|
930
1036
|
const res = (await this.rpc('refreshToken', { token })) as { expiresAt?: unknown } | undefined;
|
|
931
|
-
|
|
932
|
-
this.setToken(token, at);
|
|
933
|
-
return { expiresAt: this.tokenExpiry };
|
|
1037
|
+
return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
|
|
934
1038
|
} catch (err) {
|
|
935
1039
|
// An older server has no refreshToken, and a socket that dropped
|
|
936
1040
|
// during the call is reconnecting: either way keep the token for the
|
|
@@ -938,8 +1042,7 @@ export class StreamClient {
|
|
|
938
1042
|
if (!(err instanceof RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected')) throw err;
|
|
939
1043
|
}
|
|
940
1044
|
}
|
|
941
|
-
|
|
942
|
-
return { expiresAt: this.tokenExpiry };
|
|
1045
|
+
return keep(null);
|
|
943
1046
|
}
|
|
944
1047
|
|
|
945
1048
|
/** The token reconnects use from now on, with its expiry (read from the token when not given). */
|
|
@@ -1003,11 +1106,21 @@ export class StreamClient {
|
|
|
1003
1106
|
this.pending.delete(id);
|
|
1004
1107
|
reject(new RpcError('timeout', `${method} timed out`));
|
|
1005
1108
|
}, RPC_TIMEOUT_MS);
|
|
1006
|
-
this.pending.set(id, { resolve, reject, timer });
|
|
1109
|
+
this.pending.set(id, { resolve, reject, timer, ws: this.ws });
|
|
1007
1110
|
this.ws.send(JSON.stringify({ id, method, params }));
|
|
1008
1111
|
});
|
|
1009
1112
|
}
|
|
1010
1113
|
|
|
1114
|
+
/** Fail what was sent on `ws` and is still waiting. */
|
|
1115
|
+
private failPending(ws: WebSocket, err: Error): void {
|
|
1116
|
+
for (const [id, p] of this.pending) {
|
|
1117
|
+
if (p.ws !== ws) continue;
|
|
1118
|
+
clearTimeout(p.timer);
|
|
1119
|
+
this.pending.delete(id);
|
|
1120
|
+
p.reject(err);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1011
1124
|
private failAllPending(err: Error): void {
|
|
1012
1125
|
for (const p of this.pending.values()) {
|
|
1013
1126
|
clearTimeout(p.timer);
|
|
@@ -1023,6 +1136,9 @@ export class StreamClient {
|
|
|
1023
1136
|
} catch {
|
|
1024
1137
|
return;
|
|
1025
1138
|
}
|
|
1139
|
+
// Valid JSON that is not a message object ("null" would throw below) is
|
|
1140
|
+
// ignored like any other frame that is not a message.
|
|
1141
|
+
if (typeof msg !== 'object' || msg === null) return;
|
|
1026
1142
|
|
|
1027
1143
|
if (typeof msg.id === 'number') {
|
|
1028
1144
|
const p = this.pending.get(msg.id);
|
|
@@ -1048,21 +1164,20 @@ export class StreamClient {
|
|
|
1048
1164
|
break;
|
|
1049
1165
|
case 'producerClosed': {
|
|
1050
1166
|
const { producerId, identity } = data as { producerId: string; identity: string };
|
|
1167
|
+
this.subscriptions.delete(producerId);
|
|
1051
1168
|
this.preferredLayers.delete(producerId);
|
|
1052
1169
|
this.receiveQualities.delete(producerId);
|
|
1053
1170
|
this.remoteVideoLayers.delete(producerId);
|
|
1054
1171
|
this.skippedVideos.delete(producerId);
|
|
1055
1172
|
const consumer = this.consumers.get(producerId);
|
|
1056
|
-
|
|
1057
|
-
this.videoSlots.delete(producerId);
|
|
1058
|
-
consumer.close();
|
|
1173
|
+
consumer?.close();
|
|
1059
1174
|
this.consumers.delete(producerId);
|
|
1060
1175
|
this.remoteSources.delete(producerId);
|
|
1061
1176
|
this.remoteInfo.delete(producerId);
|
|
1062
|
-
this.emit('trackEnded', { producerId, identity });
|
|
1177
|
+
if (consumer) this.emit('trackEnded', { producerId, identity });
|
|
1063
1178
|
// A video slot opened under the fan-out limit: the oldest video left
|
|
1064
1179
|
// waiting takes it.
|
|
1065
|
-
|
|
1180
|
+
this.releaseVideoSlot(producerId);
|
|
1066
1181
|
break;
|
|
1067
1182
|
}
|
|
1068
1183
|
case 'producerPaused': {
|
|
@@ -1158,13 +1273,19 @@ export class StreamClient {
|
|
|
1158
1273
|
// fresh one periodically so a reconnect at any point still works --
|
|
1159
1274
|
// without this, any disconnect after the token's lifetime is terminal.
|
|
1160
1275
|
const e = data as { token?: string; role?: Role; expiresAt?: number };
|
|
1161
|
-
if (e.token)
|
|
1276
|
+
if (e.token) {
|
|
1277
|
+
this.serverTokenRevision += 1;
|
|
1278
|
+
this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
|
|
1279
|
+
}
|
|
1162
1280
|
break;
|
|
1163
1281
|
}
|
|
1164
1282
|
case 'mustReconnect': {
|
|
1165
1283
|
const e = data as { reason: string; token?: string; retry: boolean };
|
|
1166
1284
|
// A promotion hands us a fresh token carrying the new role.
|
|
1167
|
-
if (e.token)
|
|
1285
|
+
if (e.token) {
|
|
1286
|
+
this.serverTokenRevision += 1;
|
|
1287
|
+
this.setToken(e.token, null);
|
|
1288
|
+
}
|
|
1168
1289
|
this.emit('mustReconnect', e);
|
|
1169
1290
|
if (e.retry) {
|
|
1170
1291
|
this.teardownMedia();
|
|
@@ -1190,6 +1311,10 @@ export class StreamClient {
|
|
|
1190
1311
|
this.closedByUs = true;
|
|
1191
1312
|
this.emit('roomClosed', d as never);
|
|
1192
1313
|
this.finish('room-closed');
|
|
1314
|
+
// Before close() fails what is pending: an endRoom() this event
|
|
1315
|
+
// answers succeeded.
|
|
1316
|
+
for (const ended of this.endRoomWaiters) ended();
|
|
1317
|
+
this.endRoomWaiters.clear();
|
|
1193
1318
|
this.close();
|
|
1194
1319
|
break;
|
|
1195
1320
|
case 'kicked':
|
|
@@ -1923,7 +2048,11 @@ export class StreamClient {
|
|
|
1923
2048
|
if (this.screenStream === screen) await this.stopScreen('stopped');
|
|
1924
2049
|
this.emit('error', err as Error);
|
|
1925
2050
|
});
|
|
1926
|
-
if (this.music)
|
|
2051
|
+
if (this.music) {
|
|
2052
|
+
const music = this.music;
|
|
2053
|
+
const operation = this.musicOperation;
|
|
2054
|
+
await this.musicTask(() => this.produceMusic(music, generation, operation)).catch((err) => this.emit('error', err as Error));
|
|
2055
|
+
}
|
|
1927
2056
|
if (generation !== this.publishGeneration) return;
|
|
1928
2057
|
|
|
1929
2058
|
let stream = this.localStream;
|
|
@@ -2134,26 +2263,41 @@ export class StreamClient {
|
|
|
2134
2263
|
async publishMusic(input: MusicInput, options?: MusicOptions): Promise<void> {
|
|
2135
2264
|
if (!this.mediaPermissions.audio) throw new RpcError('media-disabled', 'audio is switched off for this project');
|
|
2136
2265
|
const generation = this.publishGeneration;
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2266
|
+
// Taken now, in call order: a music publish still under way is over, and
|
|
2267
|
+
// a stopMusic() from here on ends this one.
|
|
2268
|
+
const operation = ++this.musicOperation;
|
|
2269
|
+
await this.musicTask(async () => {
|
|
2270
|
+
if (generation !== this.publishGeneration || operation !== this.musicOperation) throw unpublishedWhile('music');
|
|
2271
|
+
if (this.music) await this.closeMusic();
|
|
2272
|
+
const music = new MusicSource(input, options);
|
|
2273
|
+
try {
|
|
2274
|
+
await this.produceMusic(music, generation, operation);
|
|
2275
|
+
// Checked again here: an unpublish() or stopMusic() after the
|
|
2276
|
+
// producer was kept has already closed it, and the music must not be
|
|
2277
|
+
// remembered for a reconnect to publish again.
|
|
2278
|
+
if (generation !== this.publishGeneration || operation !== this.musicOperation) throw unpublishedWhile('music');
|
|
2279
|
+
} catch (err) {
|
|
2280
|
+
music.close();
|
|
2281
|
+
throw err;
|
|
2282
|
+
}
|
|
2283
|
+
this.music = music;
|
|
2284
|
+
this.wasPublishing = true;
|
|
2285
|
+
this.emitLocalMedia();
|
|
2286
|
+
});
|
|
2152
2287
|
}
|
|
2153
2288
|
|
|
2154
|
-
private
|
|
2289
|
+
private musicTask<T>(task: () => Promise<T>): Promise<T> {
|
|
2290
|
+
const run = this.musicQueue.then(task, task);
|
|
2291
|
+
this.musicQueue = run.catch(() => undefined);
|
|
2292
|
+
return run;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
private async produceMusic(music: MusicSource, generation: number, operation: number): Promise<void> {
|
|
2296
|
+
// unpublish(), stopMusic() or a newer publishMusic() can run at any wait
|
|
2297
|
+
// below, and wins.
|
|
2298
|
+
const current = () => generation === this.publishGeneration && operation === this.musicOperation;
|
|
2155
2299
|
const transport = await this.ensureSendTransport();
|
|
2156
|
-
if (
|
|
2300
|
+
if (!current()) throw unpublishedWhile('music');
|
|
2157
2301
|
const producer = await transport.produce({
|
|
2158
2302
|
track: music.track,
|
|
2159
2303
|
stopTracks: false,
|
|
@@ -2162,7 +2306,14 @@ export class StreamClient {
|
|
|
2162
2306
|
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
|
|
2163
2307
|
appData: { source: 'music' },
|
|
2164
2308
|
});
|
|
2165
|
-
|
|
2309
|
+
if (!current()) {
|
|
2310
|
+
producer.close();
|
|
2311
|
+
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
2312
|
+
throw unpublishedWhile('music');
|
|
2313
|
+
}
|
|
2314
|
+
// Kept and named the music in one step, with no wait between: stopMusic()
|
|
2315
|
+
// finds the music producer by that name.
|
|
2316
|
+
this.producers.set(producer.id, producer);
|
|
2166
2317
|
this.musicProducerId = producer.id;
|
|
2167
2318
|
// Music switched off before a reconnect comes back paused here.
|
|
2168
2319
|
await this.pauseOnServer(producer);
|
|
@@ -2193,8 +2344,18 @@ export class StreamClient {
|
|
|
2193
2344
|
this.emitLocalMedia();
|
|
2194
2345
|
}
|
|
2195
2346
|
|
|
2196
|
-
/**
|
|
2347
|
+
/**
|
|
2348
|
+
* Stop publishing music. The element or stream it came from is left as it
|
|
2349
|
+
* was. A publishMusic() still under way is stopped too: it rejects with
|
|
2350
|
+
* `unpublished` and publishes nothing.
|
|
2351
|
+
*/
|
|
2197
2352
|
async stopMusic(): Promise<void> {
|
|
2353
|
+
this.musicOperation += 1;
|
|
2354
|
+
await this.closeMusic();
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
/** Close the music being published, here and at the server. */
|
|
2358
|
+
private async closeMusic(): Promise<void> {
|
|
2198
2359
|
const id = this.musicProducerId;
|
|
2199
2360
|
this.musicProducerId = null;
|
|
2200
2361
|
this.music?.close();
|
|
@@ -2216,8 +2377,9 @@ export class StreamClient {
|
|
|
2216
2377
|
}
|
|
2217
2378
|
|
|
2218
2379
|
private async consume(remote: RemoteProducer): Promise<void> {
|
|
2380
|
+
if (this.closedByUs) return;
|
|
2219
2381
|
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
2220
|
-
if (this.
|
|
2382
|
+
if (this.subscriptions.has(remote.producerId)) return;
|
|
2221
2383
|
// Your own camera is already local; pulling it back off the SFU wastes a
|
|
2222
2384
|
// consumer and shows you a delayed copy of yourself.
|
|
2223
2385
|
if (remote.identity === this.joinResult?.identity) return;
|
|
@@ -2236,34 +2398,67 @@ export class StreamClient {
|
|
|
2236
2398
|
}
|
|
2237
2399
|
this.videoSlots.add(remote.producerId);
|
|
2238
2400
|
}
|
|
2401
|
+
const operation = {};
|
|
2402
|
+
this.subscriptions.set(remote.producerId, operation);
|
|
2239
2403
|
try {
|
|
2240
|
-
await this.consumeNow(remote);
|
|
2404
|
+
await this.consumeNow(remote, operation);
|
|
2241
2405
|
} catch (err) {
|
|
2242
|
-
|
|
2406
|
+
// A closed producer or replaced session has already released its slot.
|
|
2407
|
+
// Its late failure must neither release a newer slot nor report a ghost error.
|
|
2408
|
+
if (this.subscriptions.get(remote.producerId) !== operation) return;
|
|
2409
|
+
this.subscriptions.delete(remote.producerId);
|
|
2410
|
+
this.consumers.get(remote.producerId)?.close();
|
|
2411
|
+
this.consumers.delete(remote.producerId);
|
|
2412
|
+
this.remoteVideoLayers.delete(remote.producerId);
|
|
2413
|
+
this.remoteSources.delete(remote.producerId);
|
|
2414
|
+
this.remoteInfo.delete(remote.producerId);
|
|
2415
|
+
this.releaseVideoSlot(remote.producerId);
|
|
2243
2416
|
throw err;
|
|
2244
2417
|
}
|
|
2245
2418
|
}
|
|
2246
2419
|
|
|
2420
|
+
/**
|
|
2421
|
+
* Free a video slot, and let the oldest video waiting take it -- whatever
|
|
2422
|
+
* freed it: an ended video, or a subscription that failed. Not while
|
|
2423
|
+
* reconnecting: a failure caused by the socket dropping would otherwise
|
|
2424
|
+
* start every waiting video on a dead connection, each failing with
|
|
2425
|
+
* not-connected; the reconnect's join lists them all again anyway.
|
|
2426
|
+
*/
|
|
2427
|
+
private releaseVideoSlot(producerId: string): void {
|
|
2428
|
+
if (this.videoSlots.delete(producerId) && !this.closedByUs && this.state !== 'reconnecting') this.consumeSkipped();
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2247
2431
|
/** The oldest videos left waiting at the fan-out limit take the slots that opened. */
|
|
2248
|
-
private
|
|
2432
|
+
private consumeSkipped(): void {
|
|
2249
2433
|
for (const [producerId, remote] of this.skippedVideos) {
|
|
2250
2434
|
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
2251
2435
|
if (limit !== null && this.videoSlots.size >= limit) return;
|
|
2252
2436
|
this.skippedVideos.delete(producerId);
|
|
2253
|
-
await
|
|
2437
|
+
// consume reserves synchronously, before its first await. Consecutive
|
|
2438
|
+
// releases cannot admit the same producer or exceed the limit.
|
|
2439
|
+
void this.consume(remote).catch((err) => this.emit('error', err as Error));
|
|
2254
2440
|
}
|
|
2255
2441
|
}
|
|
2256
2442
|
|
|
2257
|
-
private async consumeNow(remote: RemoteProducer): Promise<void> {
|
|
2443
|
+
private async consumeNow(remote: RemoteProducer, operation: object): Promise<void> {
|
|
2258
2444
|
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
2445
|
+
const device = this.device;
|
|
2446
|
+
const current = (made?: Consumer) => {
|
|
2447
|
+
if (!this.closedByUs && this.subscriptions.get(remote.producerId) === operation) return;
|
|
2448
|
+
made?.close();
|
|
2449
|
+
throw new RpcError('not-connected', 'subscription ended during setup');
|
|
2450
|
+
};
|
|
2259
2451
|
const transport = await this.ensureRecvTransport();
|
|
2452
|
+
current();
|
|
2260
2453
|
const params = (await this.rpc('consume', {
|
|
2261
2454
|
transportId: transport.id,
|
|
2262
2455
|
producerId: remote.producerId,
|
|
2263
|
-
rtpCapabilities:
|
|
2456
|
+
rtpCapabilities: device.rtpCapabilities,
|
|
2264
2457
|
})) as { id: string; producerId: string; kind: 'audio' | 'video'; rtpParameters: never };
|
|
2265
2458
|
|
|
2459
|
+
current();
|
|
2266
2460
|
const consumer = await transport.consume(params);
|
|
2461
|
+
current(consumer);
|
|
2267
2462
|
this.consumers.set(remote.producerId, consumer);
|
|
2268
2463
|
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? (params as { videoLayers?: VideoLayer[] }).videoLayers ?? []);
|
|
2269
2464
|
|
|
@@ -2279,10 +2474,12 @@ export class StreamClient {
|
|
|
2279
2474
|
if (layers) {
|
|
2280
2475
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
|
|
2281
2476
|
}
|
|
2477
|
+
current(consumer);
|
|
2282
2478
|
|
|
2283
2479
|
// The server created it paused. Resume only now that our end exists,
|
|
2284
2480
|
// otherwise the first frames arrive before there is anything to decode them.
|
|
2285
2481
|
await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
2482
|
+
current(consumer);
|
|
2286
2483
|
|
|
2287
2484
|
const kind = consumer.kind as 'audio' | 'video';
|
|
2288
2485
|
const source = remote.source ?? defaultSource(kind);
|
|
@@ -2424,7 +2621,20 @@ export class StreamClient {
|
|
|
2424
2621
|
}
|
|
2425
2622
|
promote(identity: string, role: Role): Promise<unknown> { return this.rpc('promote', { identity, role }); }
|
|
2426
2623
|
kick(identity: string, reason?: string): Promise<unknown> { return this.rpc('kick', { identity, reason }); }
|
|
2427
|
-
|
|
2624
|
+
/**
|
|
2625
|
+
* End the room for everyone. The server tells the room it has closed
|
|
2626
|
+
* before it replies, and that roomClosed closes this session, so the reply
|
|
2627
|
+
* may never be read: roomClosed is then the answer, and endRoom() resolves
|
|
2628
|
+
* as the reply would have, with `{ ok: true }`. It still fails when the
|
|
2629
|
+
* server refuses, or the connection drops with neither.
|
|
2630
|
+
*/
|
|
2631
|
+
endRoom(): Promise<unknown> {
|
|
2632
|
+
return new Promise((resolve, reject) => {
|
|
2633
|
+
const ended = () => resolve({ ok: true });
|
|
2634
|
+
this.endRoomWaiters.add(ended);
|
|
2635
|
+
this.rpc('endRoom').then(resolve, reject).finally(() => this.endRoomWaiters.delete(ended));
|
|
2636
|
+
});
|
|
2637
|
+
}
|
|
2428
2638
|
getRoomState(): Promise<unknown> { return this.rpc('getRoomState'); }
|
|
2429
2639
|
|
|
2430
2640
|
// ------------------------------------------------------- slots and metadata
|