@voltkit-rtc/client 0.7.0 → 0.7.1
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 +41 -0
- package/README.md +14 -0
- package/dist/cjs/broadcast.d.ts.map +1 -1
- package/dist/cjs/broadcast.js +16 -7
- package/dist/cjs/broadcast.js.map +1 -1
- package/dist/cjs/chat.d.ts +11 -0
- package/dist/cjs/chat.d.ts.map +1 -1
- package/dist/cjs/chat.js +40 -5
- package/dist/cjs/chat.js.map +1 -1
- package/dist/cjs/index.d.ts +22 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +74 -5
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/broadcast.d.ts.map +1 -1
- package/dist/esm/broadcast.js +16 -7
- package/dist/esm/broadcast.js.map +1 -1
- package/dist/esm/chat.d.ts +11 -0
- package/dist/esm/chat.d.ts.map +1 -1
- package/dist/esm/chat.js +40 -5
- package/dist/esm/chat.js.map +1 -1
- package/dist/esm/index.d.ts +22 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +74 -5
- package/dist/esm/index.js.map +1 -1
- package/package.json +1 -1
- package/src/broadcast.ts +16 -7
- package/src/chat.ts +37 -4
- package/src/index.ts +67 -5
package/src/index.ts
CHANGED
|
@@ -436,6 +436,9 @@ const isLayer = (value: unknown): value is number => Number.isInteger(value) &&
|
|
|
436
436
|
*/
|
|
437
437
|
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
438
438
|
|
|
439
|
+
/** Why a connect() gave up: close() ran while it was under way, and close wins. */
|
|
440
|
+
const closedWhileConnecting = () => new RpcError('not-connected', 'the session was closed while connecting');
|
|
441
|
+
|
|
439
442
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
440
443
|
const unpublishedWhile = (what: string) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
441
444
|
|
|
@@ -510,7 +513,15 @@ export class StreamClient {
|
|
|
510
513
|
* the full picture.
|
|
511
514
|
*/
|
|
512
515
|
private readonly preferredLayers = new Map<string, PreferredLayers>();
|
|
516
|
+
/** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
|
|
517
|
+
private readonly endRoomWaiters = new Set<() => void>();
|
|
513
518
|
private closedByUs = false;
|
|
519
|
+
/**
|
|
520
|
+
* Bumped when the session ends for good: close(), or finish(). A connect()
|
|
521
|
+
* under way when it did -- the app's, or a reconnect's -- sees the change
|
|
522
|
+
* after its next await and stops, so a closed session never comes back.
|
|
523
|
+
*/
|
|
524
|
+
private lifecycle = 0;
|
|
514
525
|
private reconnecting = false;
|
|
515
526
|
private reconnectAttempt = 0;
|
|
516
527
|
/** Set once a connect() has joined and set up. Only such a session is reconnected. */
|
|
@@ -597,6 +608,7 @@ export class StreamClient {
|
|
|
597
608
|
/** Over for good: the state, then the event that says why. */
|
|
598
609
|
private finish(reason: ClosedReason): void {
|
|
599
610
|
this.closedByUs = true;
|
|
611
|
+
this.lifecycle += 1;
|
|
600
612
|
this.setState('closed');
|
|
601
613
|
this.emit('closed', reason);
|
|
602
614
|
}
|
|
@@ -624,6 +636,8 @@ export class StreamClient {
|
|
|
624
636
|
// ------------------------------------------------------------- connection
|
|
625
637
|
|
|
626
638
|
async connect(): Promise<JoinResult> {
|
|
639
|
+
const lifecycle = this.lifecycle;
|
|
640
|
+
const closed = () => lifecycle !== this.lifecycle;
|
|
627
641
|
let result: JoinResult;
|
|
628
642
|
if (this.state === 'idle' || this.state === 'closed') this.setState('connecting');
|
|
629
643
|
try {
|
|
@@ -633,6 +647,9 @@ export class StreamClient {
|
|
|
633
647
|
|
|
634
648
|
this.device = this.createDevice();
|
|
635
649
|
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
650
|
+
// close() ran while the Device loaded: going on would set the state
|
|
651
|
+
// back to connected, and emit joined, after closed.
|
|
652
|
+
if (closed()) throw closedWhileConnecting();
|
|
636
653
|
} catch (err) {
|
|
637
654
|
// A session that never got going: the app has the error. Left open,
|
|
638
655
|
// the socket would be closed by the server's join deadline instead, and
|
|
@@ -657,6 +674,7 @@ export class StreamClient {
|
|
|
657
674
|
// decides what may return; reconnecting never turns a blocked device on.
|
|
658
675
|
if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
|
|
659
676
|
await this.unpublish();
|
|
677
|
+
if (closed()) throw closedWhileConnecting();
|
|
660
678
|
}
|
|
661
679
|
const own = result.participants.find((p) => p.identity === result.identity);
|
|
662
680
|
if (own?.muted) this.applyLocalModeration('audio');
|
|
@@ -677,16 +695,19 @@ export class StreamClient {
|
|
|
677
695
|
for (const producerId of this.remoteVideoLayers.keys()) if (!present.has(producerId)) this.remoteVideoLayers.delete(producerId);
|
|
678
696
|
|
|
679
697
|
// Subscribe to everything already publishing before we announce readiness.
|
|
698
|
+
// A close() part-way stops it: what is left would only fail, after closed.
|
|
680
699
|
for (const p of result.producers) {
|
|
681
|
-
|
|
700
|
+
if (closed()) throw closedWhileConnecting();
|
|
701
|
+
await this.consume(p).catch((err) => { if (!closed()) this.emit('error', err as Error); });
|
|
682
702
|
}
|
|
683
703
|
|
|
684
704
|
// Restore our own broadcast. This is what makes an origin failover
|
|
685
705
|
// survivable for the host: it reconnects onto the new origin and the
|
|
686
706
|
// stream comes back on its own.
|
|
687
707
|
if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
|
|
688
|
-
await this.republish().catch((err) => this.emit('error', err as Error));
|
|
708
|
+
await this.republish().catch((err) => { if (!closed()) this.emit('error', err as Error); });
|
|
689
709
|
}
|
|
710
|
+
if (closed()) throw closedWhileConnecting();
|
|
690
711
|
this.syncNetworkWatch();
|
|
691
712
|
return result;
|
|
692
713
|
}
|
|
@@ -758,18 +779,29 @@ export class StreamClient {
|
|
|
758
779
|
});
|
|
759
780
|
}
|
|
760
781
|
|
|
761
|
-
private detachSocket(): void {
|
|
782
|
+
private detachSocket(reason = 'superseded'): void {
|
|
762
783
|
const ws = this.ws;
|
|
763
784
|
this.ws = null;
|
|
764
785
|
if (ws && ws.readyState <= WebSocket.OPEN) {
|
|
765
786
|
try {
|
|
766
|
-
ws.close(1000,
|
|
787
|
+
ws.close(1000, reason);
|
|
767
788
|
} catch {
|
|
768
789
|
/* already closing */
|
|
769
790
|
}
|
|
770
791
|
}
|
|
771
792
|
}
|
|
772
793
|
|
|
794
|
+
/**
|
|
795
|
+
* A reconnect that ends the session lets go of its socket. A refused join
|
|
796
|
+
* leaves that socket open (connect() keeps it once a session has joined),
|
|
797
|
+
* and the session would otherwise report closed with it still attached and
|
|
798
|
+
* still sending.
|
|
799
|
+
*/
|
|
800
|
+
private releaseSocket(): void {
|
|
801
|
+
this.detachSocket('gave up');
|
|
802
|
+
this.failAllPending(new RpcError('not-connected', 'the session has ended'));
|
|
803
|
+
}
|
|
804
|
+
|
|
773
805
|
/**
|
|
774
806
|
* Reconnection is mandatory, not optional: nodes get drained, relays die and
|
|
775
807
|
* promotions move you to a different node. Always re-ask the orchestrator
|
|
@@ -786,6 +818,7 @@ export class StreamClient {
|
|
|
786
818
|
this.reconnectAttempt += 1;
|
|
787
819
|
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
788
820
|
this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
821
|
+
this.releaseSocket();
|
|
789
822
|
this.finish('gave-up');
|
|
790
823
|
return;
|
|
791
824
|
}
|
|
@@ -801,11 +834,15 @@ export class StreamClient {
|
|
|
801
834
|
// for good, with no `closed`.
|
|
802
835
|
if (this.ws) return;
|
|
803
836
|
} catch (err) {
|
|
837
|
+
// Ended while this attempt was under way -- by close(), or by the
|
|
838
|
+
// server -- and that has been reported: nothing follows closed.
|
|
839
|
+
if (this.closedByUs) return;
|
|
804
840
|
this.emit('error', err as Error);
|
|
805
841
|
// Stop on failures a retry cannot resolve (the registry's terminal
|
|
806
842
|
// codes). Hammering a room that has ended produces one error per
|
|
807
843
|
// attempt forever and drowns out whatever actually went wrong.
|
|
808
844
|
if (err instanceof RpcError && isTerminalError(err.code)) {
|
|
845
|
+
this.releaseSocket();
|
|
809
846
|
this.finish('gave-up');
|
|
810
847
|
return;
|
|
811
848
|
}
|
|
@@ -822,6 +859,7 @@ export class StreamClient {
|
|
|
822
859
|
// others have already reported themselves and set closedByUs.
|
|
823
860
|
const wasOpen = !this.closedByUs;
|
|
824
861
|
this.closedByUs = true;
|
|
862
|
+
this.lifecycle += 1;
|
|
825
863
|
this.publishGeneration += 1;
|
|
826
864
|
this.wasPublishing = false;
|
|
827
865
|
this.teardownMedia();
|
|
@@ -841,6 +879,10 @@ export class StreamClient {
|
|
|
841
879
|
const ws = this.ws;
|
|
842
880
|
this.ws = null;
|
|
843
881
|
ws?.close(1000, 'client closed');
|
|
882
|
+
// Detached first, so the socket's own close handler no longer fails what
|
|
883
|
+
// was waiting on it: done here, or a join or a request in flight would
|
|
884
|
+
// wait out its 15 s timeout.
|
|
885
|
+
this.failAllPending(new RpcError('not-connected', 'the session was closed'));
|
|
844
886
|
this.setState('closed');
|
|
845
887
|
if (wasOpen) this.emit('closed', 'left');
|
|
846
888
|
}
|
|
@@ -1023,6 +1065,9 @@ export class StreamClient {
|
|
|
1023
1065
|
} catch {
|
|
1024
1066
|
return;
|
|
1025
1067
|
}
|
|
1068
|
+
// Valid JSON that is not a message object ("null" would throw below) is
|
|
1069
|
+
// ignored like any other frame that is not a message.
|
|
1070
|
+
if (typeof msg !== 'object' || msg === null) return;
|
|
1026
1071
|
|
|
1027
1072
|
if (typeof msg.id === 'number') {
|
|
1028
1073
|
const p = this.pending.get(msg.id);
|
|
@@ -1190,6 +1235,10 @@ export class StreamClient {
|
|
|
1190
1235
|
this.closedByUs = true;
|
|
1191
1236
|
this.emit('roomClosed', d as never);
|
|
1192
1237
|
this.finish('room-closed');
|
|
1238
|
+
// Before close() fails what is pending: an endRoom() this event
|
|
1239
|
+
// answers succeeded.
|
|
1240
|
+
for (const ended of this.endRoomWaiters) ended();
|
|
1241
|
+
this.endRoomWaiters.clear();
|
|
1193
1242
|
this.close();
|
|
1194
1243
|
break;
|
|
1195
1244
|
case 'kicked':
|
|
@@ -2424,7 +2473,20 @@ export class StreamClient {
|
|
|
2424
2473
|
}
|
|
2425
2474
|
promote(identity: string, role: Role): Promise<unknown> { return this.rpc('promote', { identity, role }); }
|
|
2426
2475
|
kick(identity: string, reason?: string): Promise<unknown> { return this.rpc('kick', { identity, reason }); }
|
|
2427
|
-
|
|
2476
|
+
/**
|
|
2477
|
+
* End the room for everyone. The server tells the room it has closed
|
|
2478
|
+
* before it replies, and that roomClosed closes this session, so the reply
|
|
2479
|
+
* may never be read: roomClosed is then the answer, and endRoom() resolves
|
|
2480
|
+
* as the reply would have, with `{ ok: true }`. It still fails when the
|
|
2481
|
+
* server refuses, or the connection drops with neither.
|
|
2482
|
+
*/
|
|
2483
|
+
endRoom(): Promise<unknown> {
|
|
2484
|
+
return new Promise((resolve, reject) => {
|
|
2485
|
+
const ended = () => resolve({ ok: true });
|
|
2486
|
+
this.endRoomWaiters.add(ended);
|
|
2487
|
+
this.rpc('endRoom').then(resolve, reject).finally(() => this.endRoomWaiters.delete(ended));
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
2428
2490
|
getRoomState(): Promise<unknown> { return this.rpc('getRoomState'); }
|
|
2429
2491
|
|
|
2430
2492
|
// ------------------------------------------------------- slots and metadata
|