@voltkit-rtc/client 0.7.1 → 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/dist/esm/index.js CHANGED
@@ -110,6 +110,16 @@ export class StreamClient {
110
110
  /** In-app music being published, kept across reconnects so it can be published again. */
111
111
  music = null;
112
112
  musicProducerId = null;
113
+ /**
114
+ * Bumped by every publishMusic() and stopMusic(). A music producer still
115
+ * being made when it changes belongs to a publish that is over: it is
116
+ * closed, not kept. publishGeneration alone missed stopMusic(), and the
117
+ * music went out after stopMusic() had returned.
118
+ */
119
+ musicOperation = 0;
120
+ // Music publishes run one at a time: the server takes one producer per
121
+ // source, so the next is produced only once the last one is settled.
122
+ musicQueue = Promise.resolve();
113
123
  screenStream = null;
114
124
  screenMode = 'detail';
115
125
  screenAudioEnabled = true;
@@ -126,6 +136,8 @@ export class StreamClient {
126
136
  frameRateWarmupSamples = 2;
127
137
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
128
138
  remoteSources = new Map();
139
+ /** Identity of each pending/active subscription; cleared on close/reconnect. */
140
+ subscriptions = new Map();
129
141
  /**
130
142
  * The layers asked for each remote video, by producerId. A reconnect
131
143
  * consumes everything again, and the new consumer would otherwise start at
@@ -164,6 +176,17 @@ export class StreamClient {
164
176
  /** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
165
177
  tokenExpiry;
166
178
  tokenTimer = null;
179
+ /**
180
+ * Bumped whenever the server hands this client a token (its own refresh, a
181
+ * promotion's). An updateToken() that started before then keeps the token
182
+ * it was given only if nothing newer arrived while it waited: its reply,
183
+ * or its fallback once the socket closed, used to replace a promotion's
184
+ * token, and the reconnect joined with the old one.
185
+ */
186
+ serverTokenRevision = 0;
187
+ /** updateToken() calls in call order, and the newest one whose token is held: a later call wins over an earlier one answered after it. */
188
+ tokenRequests = 0;
189
+ tokenRequestHeld = 0;
167
190
  /** Whose each received track is and whether it is switched off, by producerId. */
168
191
  remoteInfo = new Map();
169
192
  /** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
@@ -225,6 +248,17 @@ export class StreamClient {
225
248
  this.setState('closed');
226
249
  this.emit('closed', reason);
227
250
  }
251
+ /**
252
+ * Over for good without the app asking: finish, then release everything
253
+ * local as close() does -- camera, microphone, screen, music, recording.
254
+ * kicked and roomClosed always did; a replaced socket, a close without
255
+ * roomClosed and a reconnect that gave up left them capturing (and the
256
+ * recording uploading) after `closed`, until the app called close().
257
+ */
258
+ end(reason) {
259
+ this.finish(reason);
260
+ this.close();
261
+ }
228
262
  stopTimers() {
229
263
  if (this.tokenTimer)
230
264
  clearTimeout(this.tokenTimer);
@@ -251,8 +285,13 @@ export class StreamClient {
251
285
  let result;
252
286
  if (this.state === 'idle' || this.state === 'closed')
253
287
  this.setState('connecting');
288
+ // The socket this call opens. A later connect() replaces it, and this
289
+ // call's failure must then leave the newer socket, and the state, alone.
290
+ let socket = null;
254
291
  try {
255
- await this.openSocket();
292
+ const opening = this.openSocket();
293
+ socket = this.ws;
294
+ await opening;
256
295
  result = (await this.rpc('join', { token: this.token }));
257
296
  this.joinResult = result;
258
297
  this.device = this.createDevice();
@@ -266,7 +305,8 @@ export class StreamClient {
266
305
  // A session that never got going: the app has the error. Left open,
267
306
  // the socket would be closed by the server's join deadline instead, and
268
307
  // that close would start reconnecting a session that never began.
269
- if (!this.joinedOnce) {
308
+ const superseded = this.ws !== null && this.ws !== socket;
309
+ if (!this.joinedOnce && !superseded) {
270
310
  this.detachSocket();
271
311
  if (this.state === 'connecting')
272
312
  this.setState('idle');
@@ -370,17 +410,16 @@ export class StreamClient {
370
410
  // Another session for this identity is live. Reconnecting would
371
411
  // displace it, and it would displace us right back, forever.
372
412
  this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
373
- // The producers outlive this close until the app calls close(); the
374
- // videoLimited sampling must not, or it reports after `closed`.
413
+ // The videoLimited sampling stops first, or it reports after `closed`.
375
414
  this.limitWatch.reset();
376
- this.finish('replaced');
415
+ this.end('replaced');
377
416
  return;
378
417
  }
379
418
  if (ev.code === CLOSE_ENDED) {
380
419
  // Normally roomClosed has already ended the session; this covers a
381
420
  // close that arrived without it.
382
421
  this.limitWatch.reset();
383
- this.finish('room-closed');
422
+ this.end('room-closed');
384
423
  return;
385
424
  }
386
425
  // Before a first successful join the app is still waiting on
@@ -393,10 +432,12 @@ export class StreamClient {
393
432
  });
394
433
  });
395
434
  }
396
- detachSocket(reason = 'superseded') {
435
+ detachSocket(reason = 'superseded', why = 'the connection was replaced') {
397
436
  const ws = this.ws;
398
437
  this.ws = null;
399
- if (ws && ws.readyState <= WebSocket.OPEN) {
438
+ if (!ws)
439
+ return;
440
+ if (ws.readyState <= WebSocket.OPEN) {
400
441
  try {
401
442
  ws.close(1000, reason);
402
443
  }
@@ -404,6 +445,10 @@ export class StreamClient {
404
445
  /* already closing */
405
446
  }
406
447
  }
448
+ // Its replies are no longer read, so what waits on it would only wait out
449
+ // its 15 s timeout: it fails now, as on a close. Never replayed -- the
450
+ // server may already have acted on it.
451
+ this.failPending(ws, new RpcError('not-connected', why));
407
452
  }
408
453
  /**
409
454
  * A reconnect that ends the session lets go of its socket. A refused join
@@ -412,7 +457,7 @@ export class StreamClient {
412
457
  * still sending.
413
458
  */
414
459
  releaseSocket() {
415
- this.detachSocket('gave up');
460
+ this.detachSocket('gave up', 'the session has ended');
416
461
  this.failAllPending(new RpcError('not-connected', 'the session has ended'));
417
462
  }
418
463
  /**
@@ -433,7 +478,7 @@ export class StreamClient {
433
478
  if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
434
479
  this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
435
480
  this.releaseSocket();
436
- this.finish('gave-up');
481
+ this.end('gave-up');
437
482
  return;
438
483
  }
439
484
  const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
@@ -461,7 +506,7 @@ export class StreamClient {
461
506
  // attempt forever and drowns out whatever actually went wrong.
462
507
  if (err instanceof RpcError && isTerminalError(err.code)) {
463
508
  this.releaseSocket();
464
- this.finish('gave-up');
509
+ this.end('gave-up');
465
510
  return;
466
511
  }
467
512
  }
@@ -506,6 +551,7 @@ export class StreamClient {
506
551
  this.emit('closed', 'left');
507
552
  }
508
553
  teardownMedia() {
554
+ this.subscriptions.clear();
509
555
  for (const c of this.consumers.values())
510
556
  c.close();
511
557
  this.consumers.clear();
@@ -576,19 +622,29 @@ export class StreamClient {
576
622
  * `tokenExpiring`. On a live connection the server checks it like a join
577
623
  * token (`bad-token`, or `token-mismatch` for another room or identity; the
578
624
  * session then keeps its token); otherwise it is kept for the next join,
579
- * which checks it there. Resolves to when it expires (unix ms), when known.
625
+ * which checks it there. Resolves to when the token now held expires (unix
626
+ * ms), when known. A newer token that arrives while this waits is kept
627
+ * instead of this one: the server's (its own refresh, or a promotion's,
628
+ * which carries the new role) or a later updateToken()'s.
580
629
  */
581
630
  async updateToken(token) {
582
631
  if (typeof token !== 'string' || token.trim() === '')
583
632
  throw new TypeError('updateToken needs a token');
584
633
  if (this.state === 'closed')
585
634
  throw new RpcError('not-connected', 'this session is closed; create a new client');
635
+ const request = ++this.tokenRequests;
636
+ const revision = this.serverTokenRevision;
637
+ const keep = (expiresAt) => {
638
+ if (revision === this.serverTokenRevision && request > this.tokenRequestHeld) {
639
+ this.tokenRequestHeld = request;
640
+ this.setToken(token, expiresAt);
641
+ }
642
+ return { expiresAt: this.tokenExpiry };
643
+ };
586
644
  if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
587
645
  try {
588
646
  const res = (await this.rpc('refreshToken', { token }));
589
- const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
590
- this.setToken(token, at);
591
- return { expiresAt: this.tokenExpiry };
647
+ return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
592
648
  }
593
649
  catch (err) {
594
650
  // An older server has no refreshToken, and a socket that dropped
@@ -598,8 +654,7 @@ export class StreamClient {
598
654
  throw err;
599
655
  }
600
656
  }
601
- this.setToken(token, null);
602
- return { expiresAt: this.tokenExpiry };
657
+ return keep(null);
603
658
  }
604
659
  /** The token reconnects use from now on, with its expiry (read from the token when not given). */
605
660
  setToken(token, expiresAt) {
@@ -660,10 +715,20 @@ export class StreamClient {
660
715
  this.pending.delete(id);
661
716
  reject(new RpcError('timeout', `${method} timed out`));
662
717
  }, RPC_TIMEOUT_MS);
663
- this.pending.set(id, { resolve, reject, timer });
718
+ this.pending.set(id, { resolve, reject, timer, ws: this.ws });
664
719
  this.ws.send(JSON.stringify({ id, method, params }));
665
720
  });
666
721
  }
722
+ /** Fail what was sent on `ws` and is still waiting. */
723
+ failPending(ws, err) {
724
+ for (const [id, p] of this.pending) {
725
+ if (p.ws !== ws)
726
+ continue;
727
+ clearTimeout(p.timer);
728
+ this.pending.delete(id);
729
+ p.reject(err);
730
+ }
731
+ }
667
732
  failAllPending(err) {
668
733
  for (const p of this.pending.values()) {
669
734
  clearTimeout(p.timer);
@@ -708,23 +773,21 @@ export class StreamClient {
708
773
  break;
709
774
  case 'producerClosed': {
710
775
  const { producerId, identity } = data;
776
+ this.subscriptions.delete(producerId);
711
777
  this.preferredLayers.delete(producerId);
712
778
  this.receiveQualities.delete(producerId);
713
779
  this.remoteVideoLayers.delete(producerId);
714
780
  this.skippedVideos.delete(producerId);
715
781
  const consumer = this.consumers.get(producerId);
716
- if (!consumer)
717
- break; // never consumed it, or already cleaned up
718
- this.videoSlots.delete(producerId);
719
- consumer.close();
782
+ consumer?.close();
720
783
  this.consumers.delete(producerId);
721
784
  this.remoteSources.delete(producerId);
722
785
  this.remoteInfo.delete(producerId);
723
- this.emit('trackEnded', { producerId, identity });
786
+ if (consumer)
787
+ this.emit('trackEnded', { producerId, identity });
724
788
  // A video slot opened under the fan-out limit: the oldest video left
725
789
  // waiting takes it.
726
- if (consumer.kind === 'video')
727
- void this.consumeSkipped().catch((err) => this.emit('error', err));
790
+ this.releaseVideoSlot(producerId);
728
791
  break;
729
792
  }
730
793
  case 'producerPaused': {
@@ -822,15 +885,19 @@ export class StreamClient {
822
885
  // fresh one periodically so a reconnect at any point still works --
823
886
  // without this, any disconnect after the token's lifetime is terminal.
824
887
  const e = data;
825
- if (e.token)
888
+ if (e.token) {
889
+ this.serverTokenRevision += 1;
826
890
  this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
891
+ }
827
892
  break;
828
893
  }
829
894
  case 'mustReconnect': {
830
895
  const e = data;
831
896
  // A promotion hands us a fresh token carrying the new role.
832
- if (e.token)
897
+ if (e.token) {
898
+ this.serverTokenRevision += 1;
833
899
  this.setToken(e.token, null);
900
+ }
834
901
  this.emit('mustReconnect', e);
835
902
  if (e.retry) {
836
903
  this.teardownMedia();
@@ -1594,8 +1661,11 @@ export class StreamClient {
1594
1661
  await this.stopScreen('stopped');
1595
1662
  this.emit('error', err);
1596
1663
  });
1597
- if (this.music)
1598
- await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err));
1664
+ if (this.music) {
1665
+ const music = this.music;
1666
+ const operation = this.musicOperation;
1667
+ await this.musicTask(() => this.produceMusic(music, generation, operation)).catch((err) => this.emit('error', err));
1668
+ }
1599
1669
  if (generation !== this.publishGeneration)
1600
1670
  return;
1601
1671
  let stream = this.localStream;
@@ -1829,28 +1899,43 @@ export class StreamClient {
1829
1899
  if (!this.mediaPermissions.audio)
1830
1900
  throw new RpcError('media-disabled', 'audio is switched off for this project');
1831
1901
  const generation = this.publishGeneration;
1832
- if (this.music)
1833
- await this.stopMusic();
1834
- const music = new MusicSource(input, options);
1835
- try {
1836
- await this.produceMusic(music, generation);
1837
- // Checked again here: an unpublish() after the producer was kept has
1838
- // already closed it, and the music must not be remembered for a
1839
- // reconnect to publish again.
1840
- if (generation !== this.publishGeneration)
1902
+ // Taken now, in call order: a music publish still under way is over, and
1903
+ // a stopMusic() from here on ends this one.
1904
+ const operation = ++this.musicOperation;
1905
+ await this.musicTask(async () => {
1906
+ if (generation !== this.publishGeneration || operation !== this.musicOperation)
1841
1907
  throw unpublishedWhile('music');
1842
- }
1843
- catch (err) {
1844
- music.close();
1845
- throw err;
1846
- }
1847
- this.music = music;
1848
- this.wasPublishing = true;
1849
- this.emitLocalMedia();
1908
+ if (this.music)
1909
+ await this.closeMusic();
1910
+ const music = new MusicSource(input, options);
1911
+ try {
1912
+ await this.produceMusic(music, generation, operation);
1913
+ // Checked again here: an unpublish() or stopMusic() after the
1914
+ // producer was kept has already closed it, and the music must not be
1915
+ // remembered for a reconnect to publish again.
1916
+ if (generation !== this.publishGeneration || operation !== this.musicOperation)
1917
+ throw unpublishedWhile('music');
1918
+ }
1919
+ catch (err) {
1920
+ music.close();
1921
+ throw err;
1922
+ }
1923
+ this.music = music;
1924
+ this.wasPublishing = true;
1925
+ this.emitLocalMedia();
1926
+ });
1850
1927
  }
1851
- async produceMusic(music, generation) {
1928
+ musicTask(task) {
1929
+ const run = this.musicQueue.then(task, task);
1930
+ this.musicQueue = run.catch(() => undefined);
1931
+ return run;
1932
+ }
1933
+ async produceMusic(music, generation, operation) {
1934
+ // unpublish(), stopMusic() or a newer publishMusic() can run at any wait
1935
+ // below, and wins.
1936
+ const current = () => generation === this.publishGeneration && operation === this.musicOperation;
1852
1937
  const transport = await this.ensureSendTransport();
1853
- if (generation !== this.publishGeneration)
1938
+ if (!current())
1854
1939
  throw unpublishedWhile('music');
1855
1940
  const producer = await transport.produce({
1856
1941
  track: music.track,
@@ -1860,7 +1945,14 @@ export class StreamClient {
1860
1945
  codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
1861
1946
  appData: { source: 'music' },
1862
1947
  });
1863
- await this.keepProducer(producer, generation, 'music');
1948
+ if (!current()) {
1949
+ producer.close();
1950
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1951
+ throw unpublishedWhile('music');
1952
+ }
1953
+ // Kept and named the music in one step, with no wait between: stopMusic()
1954
+ // finds the music producer by that name.
1955
+ this.producers.set(producer.id, producer);
1864
1956
  this.musicProducerId = producer.id;
1865
1957
  // Music switched off before a reconnect comes back paused here.
1866
1958
  await this.pauseOnServer(producer);
@@ -1889,8 +1981,17 @@ export class StreamClient {
1889
1981
  await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
1890
1982
  this.emitLocalMedia();
1891
1983
  }
1892
- /** Stop publishing music. The element or stream it came from is left as it was. */
1984
+ /**
1985
+ * Stop publishing music. The element or stream it came from is left as it
1986
+ * was. A publishMusic() still under way is stopped too: it rejects with
1987
+ * `unpublished` and publishes nothing.
1988
+ */
1893
1989
  async stopMusic() {
1990
+ this.musicOperation += 1;
1991
+ await this.closeMusic();
1992
+ }
1993
+ /** Close the music being published, here and at the server. */
1994
+ async closeMusic() {
1894
1995
  const id = this.musicProducerId;
1895
1996
  this.musicProducerId = null;
1896
1997
  this.music?.close();
@@ -1910,9 +2011,11 @@ export class StreamClient {
1910
2011
  });
1911
2012
  }
1912
2013
  async consume(remote) {
2014
+ if (this.closedByUs)
2015
+ return;
1913
2016
  if (!this.device)
1914
2017
  throw new RpcError('not-connected', 'not connected');
1915
- if (this.consumers.has(remote.producerId))
2018
+ if (this.subscriptions.has(remote.producerId))
1916
2019
  return;
1917
2020
  // Your own camera is already local; pulling it back off the SFU wastes a
1918
2021
  // consumer and shows you a delayed copy of yourself.
@@ -1932,35 +2035,69 @@ export class StreamClient {
1932
2035
  }
1933
2036
  this.videoSlots.add(remote.producerId);
1934
2037
  }
2038
+ const operation = {};
2039
+ this.subscriptions.set(remote.producerId, operation);
1935
2040
  try {
1936
- await this.consumeNow(remote);
2041
+ await this.consumeNow(remote, operation);
1937
2042
  }
1938
2043
  catch (err) {
1939
- if (!this.consumers.has(remote.producerId))
1940
- this.videoSlots.delete(remote.producerId);
2044
+ // A closed producer or replaced session has already released its slot.
2045
+ // Its late failure must neither release a newer slot nor report a ghost error.
2046
+ if (this.subscriptions.get(remote.producerId) !== operation)
2047
+ return;
2048
+ this.subscriptions.delete(remote.producerId);
2049
+ this.consumers.get(remote.producerId)?.close();
2050
+ this.consumers.delete(remote.producerId);
2051
+ this.remoteVideoLayers.delete(remote.producerId);
2052
+ this.remoteSources.delete(remote.producerId);
2053
+ this.remoteInfo.delete(remote.producerId);
2054
+ this.releaseVideoSlot(remote.producerId);
1941
2055
  throw err;
1942
2056
  }
1943
2057
  }
2058
+ /**
2059
+ * Free a video slot, and let the oldest video waiting take it -- whatever
2060
+ * freed it: an ended video, or a subscription that failed. Not while
2061
+ * reconnecting: a failure caused by the socket dropping would otherwise
2062
+ * start every waiting video on a dead connection, each failing with
2063
+ * not-connected; the reconnect's join lists them all again anyway.
2064
+ */
2065
+ releaseVideoSlot(producerId) {
2066
+ if (this.videoSlots.delete(producerId) && !this.closedByUs && this.state !== 'reconnecting')
2067
+ this.consumeSkipped();
2068
+ }
1944
2069
  /** The oldest videos left waiting at the fan-out limit take the slots that opened. */
1945
- async consumeSkipped() {
2070
+ consumeSkipped() {
1946
2071
  for (const [producerId, remote] of this.skippedVideos) {
1947
2072
  const limit = this.mediaPermissions.maxVideoSubscriptions;
1948
2073
  if (limit !== null && this.videoSlots.size >= limit)
1949
2074
  return;
1950
2075
  this.skippedVideos.delete(producerId);
1951
- await this.consume(remote);
2076
+ // consume reserves synchronously, before its first await. Consecutive
2077
+ // releases cannot admit the same producer or exceed the limit.
2078
+ void this.consume(remote).catch((err) => this.emit('error', err));
1952
2079
  }
1953
2080
  }
1954
- async consumeNow(remote) {
2081
+ async consumeNow(remote, operation) {
1955
2082
  if (!this.device)
1956
2083
  throw new RpcError('not-connected', 'not connected');
2084
+ const device = this.device;
2085
+ const current = (made) => {
2086
+ if (!this.closedByUs && this.subscriptions.get(remote.producerId) === operation)
2087
+ return;
2088
+ made?.close();
2089
+ throw new RpcError('not-connected', 'subscription ended during setup');
2090
+ };
1957
2091
  const transport = await this.ensureRecvTransport();
2092
+ current();
1958
2093
  const params = (await this.rpc('consume', {
1959
2094
  transportId: transport.id,
1960
2095
  producerId: remote.producerId,
1961
- rtpCapabilities: this.device.rtpCapabilities,
2096
+ rtpCapabilities: device.rtpCapabilities,
1962
2097
  }));
2098
+ current();
1963
2099
  const consumer = await transport.consume(params);
2100
+ current(consumer);
1964
2101
  this.consumers.set(remote.producerId, consumer);
1965
2102
  this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
1966
2103
  // Layers asked for before a reconnect: the new consumer gets them before
@@ -1980,9 +2117,11 @@ export class StreamClient {
1980
2117
  if (layers) {
1981
2118
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
1982
2119
  }
2120
+ current(consumer);
1983
2121
  // The server created it paused. Resume only now that our end exists,
1984
2122
  // otherwise the first frames arrive before there is anything to decode them.
1985
2123
  await this.rpc('resumeConsumer', { consumerId: consumer.id });
2124
+ current(consumer);
1986
2125
  const kind = consumer.kind;
1987
2126
  const source = remote.source ?? defaultSource(kind);
1988
2127
  this.remoteSources.set(remote.producerId, source);