@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/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
- await this.consume(p).catch((err) => this.emit('error', err as Error));
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, 'superseded');
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
- endRoom(): Promise<unknown> { return this.rpc('endRoom'); }
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