@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/dist/cjs/index.js
CHANGED
|
@@ -86,6 +86,8 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
|
|
|
86
86
|
* otherwise starts low and takes many seconds to climb to the top layer.
|
|
87
87
|
*/
|
|
88
88
|
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
89
|
+
/** Why a connect() gave up: close() ran while it was under way, and close wins. */
|
|
90
|
+
const closedWhileConnecting = () => new errors_js_1.RpcError('not-connected', 'the session was closed while connecting');
|
|
89
91
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
90
92
|
const unpublishedWhile = (what) => new errors_js_1.RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
91
93
|
/**
|
|
@@ -139,6 +141,16 @@ class StreamClient {
|
|
|
139
141
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
140
142
|
music = null;
|
|
141
143
|
musicProducerId = null;
|
|
144
|
+
/**
|
|
145
|
+
* Bumped by every publishMusic() and stopMusic(). A music producer still
|
|
146
|
+
* being made when it changes belongs to a publish that is over: it is
|
|
147
|
+
* closed, not kept. publishGeneration alone missed stopMusic(), and the
|
|
148
|
+
* music went out after stopMusic() had returned.
|
|
149
|
+
*/
|
|
150
|
+
musicOperation = 0;
|
|
151
|
+
// Music publishes run one at a time: the server takes one producer per
|
|
152
|
+
// source, so the next is produced only once the last one is settled.
|
|
153
|
+
musicQueue = Promise.resolve();
|
|
142
154
|
screenStream = null;
|
|
143
155
|
screenMode = 'detail';
|
|
144
156
|
screenAudioEnabled = true;
|
|
@@ -155,13 +167,23 @@ class StreamClient {
|
|
|
155
167
|
frameRateWarmupSamples = 2;
|
|
156
168
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
157
169
|
remoteSources = new Map();
|
|
170
|
+
/** Identity of each pending/active subscription; cleared on close/reconnect. */
|
|
171
|
+
subscriptions = new Map();
|
|
158
172
|
/**
|
|
159
173
|
* The layers asked for each remote video, by producerId. A reconnect
|
|
160
174
|
* consumes everything again, and the new consumer would otherwise start at
|
|
161
175
|
* the full picture.
|
|
162
176
|
*/
|
|
163
177
|
preferredLayers = new Map();
|
|
178
|
+
/** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
|
|
179
|
+
endRoomWaiters = new Set();
|
|
164
180
|
closedByUs = false;
|
|
181
|
+
/**
|
|
182
|
+
* Bumped when the session ends for good: close(), or finish(). A connect()
|
|
183
|
+
* under way when it did -- the app's, or a reconnect's -- sees the change
|
|
184
|
+
* after its next await and stops, so a closed session never comes back.
|
|
185
|
+
*/
|
|
186
|
+
lifecycle = 0;
|
|
165
187
|
reconnecting = false;
|
|
166
188
|
reconnectAttempt = 0;
|
|
167
189
|
/** Set once a connect() has joined and set up. Only such a session is reconnected. */
|
|
@@ -185,6 +207,17 @@ class StreamClient {
|
|
|
185
207
|
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
186
208
|
tokenExpiry;
|
|
187
209
|
tokenTimer = null;
|
|
210
|
+
/**
|
|
211
|
+
* Bumped whenever the server hands this client a token (its own refresh, a
|
|
212
|
+
* promotion's). An updateToken() that started before then keeps the token
|
|
213
|
+
* it was given only if nothing newer arrived while it waited: its reply,
|
|
214
|
+
* or its fallback once the socket closed, used to replace a promotion's
|
|
215
|
+
* token, and the reconnect joined with the old one.
|
|
216
|
+
*/
|
|
217
|
+
serverTokenRevision = 0;
|
|
218
|
+
/** updateToken() calls in call order, and the newest one whose token is held: a later call wins over an earlier one answered after it. */
|
|
219
|
+
tokenRequests = 0;
|
|
220
|
+
tokenRequestHeld = 0;
|
|
188
221
|
/** Whose each received track is and whether it is switched off, by producerId. */
|
|
189
222
|
remoteInfo = new Map();
|
|
190
223
|
/** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
|
|
@@ -242,9 +275,21 @@ class StreamClient {
|
|
|
242
275
|
/** Over for good: the state, then the event that says why. */
|
|
243
276
|
finish(reason) {
|
|
244
277
|
this.closedByUs = true;
|
|
278
|
+
this.lifecycle += 1;
|
|
245
279
|
this.setState('closed');
|
|
246
280
|
this.emit('closed', reason);
|
|
247
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Over for good without the app asking: finish, then release everything
|
|
284
|
+
* local as close() does -- camera, microphone, screen, music, recording.
|
|
285
|
+
* kicked and roomClosed always did; a replaced socket, a close without
|
|
286
|
+
* roomClosed and a reconnect that gave up left them capturing (and the
|
|
287
|
+
* recording uploading) after `closed`, until the app called close().
|
|
288
|
+
*/
|
|
289
|
+
end(reason) {
|
|
290
|
+
this.finish(reason);
|
|
291
|
+
this.close();
|
|
292
|
+
}
|
|
248
293
|
stopTimers() {
|
|
249
294
|
if (this.tokenTimer)
|
|
250
295
|
clearTimeout(this.tokenTimer);
|
|
@@ -266,21 +311,33 @@ class StreamClient {
|
|
|
266
311
|
}
|
|
267
312
|
// ------------------------------------------------------------- connection
|
|
268
313
|
async connect() {
|
|
314
|
+
const lifecycle = this.lifecycle;
|
|
315
|
+
const closed = () => lifecycle !== this.lifecycle;
|
|
269
316
|
let result;
|
|
270
317
|
if (this.state === 'idle' || this.state === 'closed')
|
|
271
318
|
this.setState('connecting');
|
|
319
|
+
// The socket this call opens. A later connect() replaces it, and this
|
|
320
|
+
// call's failure must then leave the newer socket, and the state, alone.
|
|
321
|
+
let socket = null;
|
|
272
322
|
try {
|
|
273
|
-
|
|
323
|
+
const opening = this.openSocket();
|
|
324
|
+
socket = this.ws;
|
|
325
|
+
await opening;
|
|
274
326
|
result = (await this.rpc('join', { token: this.token }));
|
|
275
327
|
this.joinResult = result;
|
|
276
328
|
this.device = this.createDevice();
|
|
277
329
|
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
330
|
+
// close() ran while the Device loaded: going on would set the state
|
|
331
|
+
// back to connected, and emit joined, after closed.
|
|
332
|
+
if (closed())
|
|
333
|
+
throw closedWhileConnecting();
|
|
278
334
|
}
|
|
279
335
|
catch (err) {
|
|
280
336
|
// A session that never got going: the app has the error. Left open,
|
|
281
337
|
// the socket would be closed by the server's join deadline instead, and
|
|
282
338
|
// that close would start reconnecting a session that never began.
|
|
283
|
-
|
|
339
|
+
const superseded = this.ws !== null && this.ws !== socket;
|
|
340
|
+
if (!this.joinedOnce && !superseded) {
|
|
284
341
|
this.detachSocket();
|
|
285
342
|
if (this.state === 'connecting')
|
|
286
343
|
this.setState('idle');
|
|
@@ -300,6 +357,8 @@ class StreamClient {
|
|
|
300
357
|
// decides what may return; reconnecting never turns a blocked device on.
|
|
301
358
|
if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
|
|
302
359
|
await this.unpublish();
|
|
360
|
+
if (closed())
|
|
361
|
+
throw closedWhileConnecting();
|
|
303
362
|
}
|
|
304
363
|
const own = result.participants.find((p) => p.identity === result.identity);
|
|
305
364
|
if (own?.muted)
|
|
@@ -321,15 +380,22 @@ class StreamClient {
|
|
|
321
380
|
if (!present.has(producerId))
|
|
322
381
|
this.remoteVideoLayers.delete(producerId);
|
|
323
382
|
// Subscribe to everything already publishing before we announce readiness.
|
|
383
|
+
// A close() part-way stops it: what is left would only fail, after closed.
|
|
324
384
|
for (const p of result.producers) {
|
|
325
|
-
|
|
385
|
+
if (closed())
|
|
386
|
+
throw closedWhileConnecting();
|
|
387
|
+
await this.consume(p).catch((err) => { if (!closed())
|
|
388
|
+
this.emit('error', err); });
|
|
326
389
|
}
|
|
327
390
|
// Restore our own broadcast. This is what makes an origin failover
|
|
328
391
|
// survivable for the host: it reconnects onto the new origin and the
|
|
329
392
|
// stream comes back on its own.
|
|
330
393
|
if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
|
|
331
|
-
await this.republish().catch((err) =>
|
|
394
|
+
await this.republish().catch((err) => { if (!closed())
|
|
395
|
+
this.emit('error', err); });
|
|
332
396
|
}
|
|
397
|
+
if (closed())
|
|
398
|
+
throw closedWhileConnecting();
|
|
333
399
|
this.syncNetworkWatch();
|
|
334
400
|
return result;
|
|
335
401
|
}
|
|
@@ -375,17 +441,16 @@ class StreamClient {
|
|
|
375
441
|
// Another session for this identity is live. Reconnecting would
|
|
376
442
|
// displace it, and it would displace us right back, forever.
|
|
377
443
|
this.emit('error', new errors_js_1.RpcError('replaced', 'this identity connected from somewhere else'));
|
|
378
|
-
// The
|
|
379
|
-
// videoLimited sampling must not, or it reports after `closed`.
|
|
444
|
+
// The videoLimited sampling stops first, or it reports after `closed`.
|
|
380
445
|
this.limitWatch.reset();
|
|
381
|
-
this.
|
|
446
|
+
this.end('replaced');
|
|
382
447
|
return;
|
|
383
448
|
}
|
|
384
449
|
if (ev.code === CLOSE_ENDED) {
|
|
385
450
|
// Normally roomClosed has already ended the session; this covers a
|
|
386
451
|
// close that arrived without it.
|
|
387
452
|
this.limitWatch.reset();
|
|
388
|
-
this.
|
|
453
|
+
this.end('room-closed');
|
|
389
454
|
return;
|
|
390
455
|
}
|
|
391
456
|
// Before a first successful join the app is still waiting on
|
|
@@ -398,17 +463,33 @@ class StreamClient {
|
|
|
398
463
|
});
|
|
399
464
|
});
|
|
400
465
|
}
|
|
401
|
-
detachSocket() {
|
|
466
|
+
detachSocket(reason = 'superseded', why = 'the connection was replaced') {
|
|
402
467
|
const ws = this.ws;
|
|
403
468
|
this.ws = null;
|
|
404
|
-
if (ws
|
|
469
|
+
if (!ws)
|
|
470
|
+
return;
|
|
471
|
+
if (ws.readyState <= WebSocket.OPEN) {
|
|
405
472
|
try {
|
|
406
|
-
ws.close(1000,
|
|
473
|
+
ws.close(1000, reason);
|
|
407
474
|
}
|
|
408
475
|
catch {
|
|
409
476
|
/* already closing */
|
|
410
477
|
}
|
|
411
478
|
}
|
|
479
|
+
// Its replies are no longer read, so what waits on it would only wait out
|
|
480
|
+
// its 15 s timeout: it fails now, as on a close. Never replayed -- the
|
|
481
|
+
// server may already have acted on it.
|
|
482
|
+
this.failPending(ws, new errors_js_1.RpcError('not-connected', why));
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* A reconnect that ends the session lets go of its socket. A refused join
|
|
486
|
+
* leaves that socket open (connect() keeps it once a session has joined),
|
|
487
|
+
* and the session would otherwise report closed with it still attached and
|
|
488
|
+
* still sending.
|
|
489
|
+
*/
|
|
490
|
+
releaseSocket() {
|
|
491
|
+
this.detachSocket('gave up', 'the session has ended');
|
|
492
|
+
this.failAllPending(new errors_js_1.RpcError('not-connected', 'the session has ended'));
|
|
412
493
|
}
|
|
413
494
|
/**
|
|
414
495
|
* Reconnection is mandatory, not optional: nodes get drained, relays die and
|
|
@@ -427,7 +508,8 @@ class StreamClient {
|
|
|
427
508
|
this.reconnectAttempt += 1;
|
|
428
509
|
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
429
510
|
this.emit('error', new errors_js_1.RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
430
|
-
this.
|
|
511
|
+
this.releaseSocket();
|
|
512
|
+
this.end('gave-up');
|
|
431
513
|
return;
|
|
432
514
|
}
|
|
433
515
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
@@ -445,12 +527,17 @@ class StreamClient {
|
|
|
445
527
|
return;
|
|
446
528
|
}
|
|
447
529
|
catch (err) {
|
|
530
|
+
// Ended while this attempt was under way -- by close(), or by the
|
|
531
|
+
// server -- and that has been reported: nothing follows closed.
|
|
532
|
+
if (this.closedByUs)
|
|
533
|
+
return;
|
|
448
534
|
this.emit('error', err);
|
|
449
535
|
// Stop on failures a retry cannot resolve (the registry's terminal
|
|
450
536
|
// codes). Hammering a room that has ended produces one error per
|
|
451
537
|
// attempt forever and drowns out whatever actually went wrong.
|
|
452
538
|
if (err instanceof errors_js_1.RpcError && (0, errors_js_1.isTerminalError)(err.code)) {
|
|
453
|
-
this.
|
|
539
|
+
this.releaseSocket();
|
|
540
|
+
this.end('gave-up');
|
|
454
541
|
return;
|
|
455
542
|
}
|
|
456
543
|
}
|
|
@@ -466,6 +553,7 @@ class StreamClient {
|
|
|
466
553
|
// others have already reported themselves and set closedByUs.
|
|
467
554
|
const wasOpen = !this.closedByUs;
|
|
468
555
|
this.closedByUs = true;
|
|
556
|
+
this.lifecycle += 1;
|
|
469
557
|
this.publishGeneration += 1;
|
|
470
558
|
this.wasPublishing = false;
|
|
471
559
|
this.teardownMedia();
|
|
@@ -485,11 +573,16 @@ class StreamClient {
|
|
|
485
573
|
const ws = this.ws;
|
|
486
574
|
this.ws = null;
|
|
487
575
|
ws?.close(1000, 'client closed');
|
|
576
|
+
// Detached first, so the socket's own close handler no longer fails what
|
|
577
|
+
// was waiting on it: done here, or a join or a request in flight would
|
|
578
|
+
// wait out its 15 s timeout.
|
|
579
|
+
this.failAllPending(new errors_js_1.RpcError('not-connected', 'the session was closed'));
|
|
488
580
|
this.setState('closed');
|
|
489
581
|
if (wasOpen)
|
|
490
582
|
this.emit('closed', 'left');
|
|
491
583
|
}
|
|
492
584
|
teardownMedia() {
|
|
585
|
+
this.subscriptions.clear();
|
|
493
586
|
for (const c of this.consumers.values())
|
|
494
587
|
c.close();
|
|
495
588
|
this.consumers.clear();
|
|
@@ -560,19 +653,29 @@ class StreamClient {
|
|
|
560
653
|
* `tokenExpiring`. On a live connection the server checks it like a join
|
|
561
654
|
* token (`bad-token`, or `token-mismatch` for another room or identity; the
|
|
562
655
|
* session then keeps its token); otherwise it is kept for the next join,
|
|
563
|
-
* which checks it there. Resolves to when
|
|
656
|
+
* which checks it there. Resolves to when the token now held expires (unix
|
|
657
|
+
* ms), when known. A newer token that arrives while this waits is kept
|
|
658
|
+
* instead of this one: the server's (its own refresh, or a promotion's,
|
|
659
|
+
* which carries the new role) or a later updateToken()'s.
|
|
564
660
|
*/
|
|
565
661
|
async updateToken(token) {
|
|
566
662
|
if (typeof token !== 'string' || token.trim() === '')
|
|
567
663
|
throw new TypeError('updateToken needs a token');
|
|
568
664
|
if (this.state === 'closed')
|
|
569
665
|
throw new errors_js_1.RpcError('not-connected', 'this session is closed; create a new client');
|
|
666
|
+
const request = ++this.tokenRequests;
|
|
667
|
+
const revision = this.serverTokenRevision;
|
|
668
|
+
const keep = (expiresAt) => {
|
|
669
|
+
if (revision === this.serverTokenRevision && request > this.tokenRequestHeld) {
|
|
670
|
+
this.tokenRequestHeld = request;
|
|
671
|
+
this.setToken(token, expiresAt);
|
|
672
|
+
}
|
|
673
|
+
return { expiresAt: this.tokenExpiry };
|
|
674
|
+
};
|
|
570
675
|
if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
|
|
571
676
|
try {
|
|
572
677
|
const res = (await this.rpc('refreshToken', { token }));
|
|
573
|
-
|
|
574
|
-
this.setToken(token, at);
|
|
575
|
-
return { expiresAt: this.tokenExpiry };
|
|
678
|
+
return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
|
|
576
679
|
}
|
|
577
680
|
catch (err) {
|
|
578
681
|
// An older server has no refreshToken, and a socket that dropped
|
|
@@ -582,8 +685,7 @@ class StreamClient {
|
|
|
582
685
|
throw err;
|
|
583
686
|
}
|
|
584
687
|
}
|
|
585
|
-
|
|
586
|
-
return { expiresAt: this.tokenExpiry };
|
|
688
|
+
return keep(null);
|
|
587
689
|
}
|
|
588
690
|
/** The token reconnects use from now on, with its expiry (read from the token when not given). */
|
|
589
691
|
setToken(token, expiresAt) {
|
|
@@ -644,10 +746,20 @@ class StreamClient {
|
|
|
644
746
|
this.pending.delete(id);
|
|
645
747
|
reject(new errors_js_1.RpcError('timeout', `${method} timed out`));
|
|
646
748
|
}, RPC_TIMEOUT_MS);
|
|
647
|
-
this.pending.set(id, { resolve, reject, timer });
|
|
749
|
+
this.pending.set(id, { resolve, reject, timer, ws: this.ws });
|
|
648
750
|
this.ws.send(JSON.stringify({ id, method, params }));
|
|
649
751
|
});
|
|
650
752
|
}
|
|
753
|
+
/** Fail what was sent on `ws` and is still waiting. */
|
|
754
|
+
failPending(ws, err) {
|
|
755
|
+
for (const [id, p] of this.pending) {
|
|
756
|
+
if (p.ws !== ws)
|
|
757
|
+
continue;
|
|
758
|
+
clearTimeout(p.timer);
|
|
759
|
+
this.pending.delete(id);
|
|
760
|
+
p.reject(err);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
651
763
|
failAllPending(err) {
|
|
652
764
|
for (const p of this.pending.values()) {
|
|
653
765
|
clearTimeout(p.timer);
|
|
@@ -663,6 +775,10 @@ class StreamClient {
|
|
|
663
775
|
catch {
|
|
664
776
|
return;
|
|
665
777
|
}
|
|
778
|
+
// Valid JSON that is not a message object ("null" would throw below) is
|
|
779
|
+
// ignored like any other frame that is not a message.
|
|
780
|
+
if (typeof msg !== 'object' || msg === null)
|
|
781
|
+
return;
|
|
666
782
|
if (typeof msg.id === 'number') {
|
|
667
783
|
const p = this.pending.get(msg.id);
|
|
668
784
|
if (!p)
|
|
@@ -688,23 +804,21 @@ class StreamClient {
|
|
|
688
804
|
break;
|
|
689
805
|
case 'producerClosed': {
|
|
690
806
|
const { producerId, identity } = data;
|
|
807
|
+
this.subscriptions.delete(producerId);
|
|
691
808
|
this.preferredLayers.delete(producerId);
|
|
692
809
|
this.receiveQualities.delete(producerId);
|
|
693
810
|
this.remoteVideoLayers.delete(producerId);
|
|
694
811
|
this.skippedVideos.delete(producerId);
|
|
695
812
|
const consumer = this.consumers.get(producerId);
|
|
696
|
-
|
|
697
|
-
break; // never consumed it, or already cleaned up
|
|
698
|
-
this.videoSlots.delete(producerId);
|
|
699
|
-
consumer.close();
|
|
813
|
+
consumer?.close();
|
|
700
814
|
this.consumers.delete(producerId);
|
|
701
815
|
this.remoteSources.delete(producerId);
|
|
702
816
|
this.remoteInfo.delete(producerId);
|
|
703
|
-
|
|
817
|
+
if (consumer)
|
|
818
|
+
this.emit('trackEnded', { producerId, identity });
|
|
704
819
|
// A video slot opened under the fan-out limit: the oldest video left
|
|
705
820
|
// waiting takes it.
|
|
706
|
-
|
|
707
|
-
void this.consumeSkipped().catch((err) => this.emit('error', err));
|
|
821
|
+
this.releaseVideoSlot(producerId);
|
|
708
822
|
break;
|
|
709
823
|
}
|
|
710
824
|
case 'producerPaused': {
|
|
@@ -802,15 +916,19 @@ class StreamClient {
|
|
|
802
916
|
// fresh one periodically so a reconnect at any point still works --
|
|
803
917
|
// without this, any disconnect after the token's lifetime is terminal.
|
|
804
918
|
const e = data;
|
|
805
|
-
if (e.token)
|
|
919
|
+
if (e.token) {
|
|
920
|
+
this.serverTokenRevision += 1;
|
|
806
921
|
this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
|
|
922
|
+
}
|
|
807
923
|
break;
|
|
808
924
|
}
|
|
809
925
|
case 'mustReconnect': {
|
|
810
926
|
const e = data;
|
|
811
927
|
// A promotion hands us a fresh token carrying the new role.
|
|
812
|
-
if (e.token)
|
|
928
|
+
if (e.token) {
|
|
929
|
+
this.serverTokenRevision += 1;
|
|
813
930
|
this.setToken(e.token, null);
|
|
931
|
+
}
|
|
814
932
|
this.emit('mustReconnect', e);
|
|
815
933
|
if (e.retry) {
|
|
816
934
|
this.teardownMedia();
|
|
@@ -836,6 +954,11 @@ class StreamClient {
|
|
|
836
954
|
this.closedByUs = true;
|
|
837
955
|
this.emit('roomClosed', d);
|
|
838
956
|
this.finish('room-closed');
|
|
957
|
+
// Before close() fails what is pending: an endRoom() this event
|
|
958
|
+
// answers succeeded.
|
|
959
|
+
for (const ended of this.endRoomWaiters)
|
|
960
|
+
ended();
|
|
961
|
+
this.endRoomWaiters.clear();
|
|
839
962
|
this.close();
|
|
840
963
|
break;
|
|
841
964
|
case 'kicked':
|
|
@@ -1569,8 +1692,11 @@ class StreamClient {
|
|
|
1569
1692
|
await this.stopScreen('stopped');
|
|
1570
1693
|
this.emit('error', err);
|
|
1571
1694
|
});
|
|
1572
|
-
if (this.music)
|
|
1573
|
-
|
|
1695
|
+
if (this.music) {
|
|
1696
|
+
const music = this.music;
|
|
1697
|
+
const operation = this.musicOperation;
|
|
1698
|
+
await this.musicTask(() => this.produceMusic(music, generation, operation)).catch((err) => this.emit('error', err));
|
|
1699
|
+
}
|
|
1574
1700
|
if (generation !== this.publishGeneration)
|
|
1575
1701
|
return;
|
|
1576
1702
|
let stream = this.localStream;
|
|
@@ -1804,28 +1930,43 @@ class StreamClient {
|
|
|
1804
1930
|
if (!this.mediaPermissions.audio)
|
|
1805
1931
|
throw new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project');
|
|
1806
1932
|
const generation = this.publishGeneration;
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
const
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
// Checked again here: an unpublish() after the producer was kept has
|
|
1813
|
-
// already closed it, and the music must not be remembered for a
|
|
1814
|
-
// reconnect to publish again.
|
|
1815
|
-
if (generation !== this.publishGeneration)
|
|
1933
|
+
// Taken now, in call order: a music publish still under way is over, and
|
|
1934
|
+
// a stopMusic() from here on ends this one.
|
|
1935
|
+
const operation = ++this.musicOperation;
|
|
1936
|
+
await this.musicTask(async () => {
|
|
1937
|
+
if (generation !== this.publishGeneration || operation !== this.musicOperation)
|
|
1816
1938
|
throw unpublishedWhile('music');
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
music.
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1939
|
+
if (this.music)
|
|
1940
|
+
await this.closeMusic();
|
|
1941
|
+
const music = new music_js_1.MusicSource(input, options);
|
|
1942
|
+
try {
|
|
1943
|
+
await this.produceMusic(music, generation, operation);
|
|
1944
|
+
// Checked again here: an unpublish() or stopMusic() after the
|
|
1945
|
+
// producer was kept has already closed it, and the music must not be
|
|
1946
|
+
// remembered for a reconnect to publish again.
|
|
1947
|
+
if (generation !== this.publishGeneration || operation !== this.musicOperation)
|
|
1948
|
+
throw unpublishedWhile('music');
|
|
1949
|
+
}
|
|
1950
|
+
catch (err) {
|
|
1951
|
+
music.close();
|
|
1952
|
+
throw err;
|
|
1953
|
+
}
|
|
1954
|
+
this.music = music;
|
|
1955
|
+
this.wasPublishing = true;
|
|
1956
|
+
this.emitLocalMedia();
|
|
1957
|
+
});
|
|
1825
1958
|
}
|
|
1826
|
-
|
|
1959
|
+
musicTask(task) {
|
|
1960
|
+
const run = this.musicQueue.then(task, task);
|
|
1961
|
+
this.musicQueue = run.catch(() => undefined);
|
|
1962
|
+
return run;
|
|
1963
|
+
}
|
|
1964
|
+
async produceMusic(music, generation, operation) {
|
|
1965
|
+
// unpublish(), stopMusic() or a newer publishMusic() can run at any wait
|
|
1966
|
+
// below, and wins.
|
|
1967
|
+
const current = () => generation === this.publishGeneration && operation === this.musicOperation;
|
|
1827
1968
|
const transport = await this.ensureSendTransport();
|
|
1828
|
-
if (
|
|
1969
|
+
if (!current())
|
|
1829
1970
|
throw unpublishedWhile('music');
|
|
1830
1971
|
const producer = await transport.produce({
|
|
1831
1972
|
track: music.track,
|
|
@@ -1835,7 +1976,14 @@ class StreamClient {
|
|
|
1835
1976
|
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
|
|
1836
1977
|
appData: { source: 'music' },
|
|
1837
1978
|
});
|
|
1838
|
-
|
|
1979
|
+
if (!current()) {
|
|
1980
|
+
producer.close();
|
|
1981
|
+
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1982
|
+
throw unpublishedWhile('music');
|
|
1983
|
+
}
|
|
1984
|
+
// Kept and named the music in one step, with no wait between: stopMusic()
|
|
1985
|
+
// finds the music producer by that name.
|
|
1986
|
+
this.producers.set(producer.id, producer);
|
|
1839
1987
|
this.musicProducerId = producer.id;
|
|
1840
1988
|
// Music switched off before a reconnect comes back paused here.
|
|
1841
1989
|
await this.pauseOnServer(producer);
|
|
@@ -1864,8 +2012,17 @@ class StreamClient {
|
|
|
1864
2012
|
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
1865
2013
|
this.emitLocalMedia();
|
|
1866
2014
|
}
|
|
1867
|
-
/**
|
|
2015
|
+
/**
|
|
2016
|
+
* Stop publishing music. The element or stream it came from is left as it
|
|
2017
|
+
* was. A publishMusic() still under way is stopped too: it rejects with
|
|
2018
|
+
* `unpublished` and publishes nothing.
|
|
2019
|
+
*/
|
|
1868
2020
|
async stopMusic() {
|
|
2021
|
+
this.musicOperation += 1;
|
|
2022
|
+
await this.closeMusic();
|
|
2023
|
+
}
|
|
2024
|
+
/** Close the music being published, here and at the server. */
|
|
2025
|
+
async closeMusic() {
|
|
1869
2026
|
const id = this.musicProducerId;
|
|
1870
2027
|
this.musicProducerId = null;
|
|
1871
2028
|
this.music?.close();
|
|
@@ -1885,9 +2042,11 @@ class StreamClient {
|
|
|
1885
2042
|
});
|
|
1886
2043
|
}
|
|
1887
2044
|
async consume(remote) {
|
|
2045
|
+
if (this.closedByUs)
|
|
2046
|
+
return;
|
|
1888
2047
|
if (!this.device)
|
|
1889
2048
|
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
1890
|
-
if (this.
|
|
2049
|
+
if (this.subscriptions.has(remote.producerId))
|
|
1891
2050
|
return;
|
|
1892
2051
|
// Your own camera is already local; pulling it back off the SFU wastes a
|
|
1893
2052
|
// consumer and shows you a delayed copy of yourself.
|
|
@@ -1907,35 +2066,69 @@ class StreamClient {
|
|
|
1907
2066
|
}
|
|
1908
2067
|
this.videoSlots.add(remote.producerId);
|
|
1909
2068
|
}
|
|
2069
|
+
const operation = {};
|
|
2070
|
+
this.subscriptions.set(remote.producerId, operation);
|
|
1910
2071
|
try {
|
|
1911
|
-
await this.consumeNow(remote);
|
|
2072
|
+
await this.consumeNow(remote, operation);
|
|
1912
2073
|
}
|
|
1913
2074
|
catch (err) {
|
|
1914
|
-
|
|
1915
|
-
|
|
2075
|
+
// A closed producer or replaced session has already released its slot.
|
|
2076
|
+
// Its late failure must neither release a newer slot nor report a ghost error.
|
|
2077
|
+
if (this.subscriptions.get(remote.producerId) !== operation)
|
|
2078
|
+
return;
|
|
2079
|
+
this.subscriptions.delete(remote.producerId);
|
|
2080
|
+
this.consumers.get(remote.producerId)?.close();
|
|
2081
|
+
this.consumers.delete(remote.producerId);
|
|
2082
|
+
this.remoteVideoLayers.delete(remote.producerId);
|
|
2083
|
+
this.remoteSources.delete(remote.producerId);
|
|
2084
|
+
this.remoteInfo.delete(remote.producerId);
|
|
2085
|
+
this.releaseVideoSlot(remote.producerId);
|
|
1916
2086
|
throw err;
|
|
1917
2087
|
}
|
|
1918
2088
|
}
|
|
2089
|
+
/**
|
|
2090
|
+
* Free a video slot, and let the oldest video waiting take it -- whatever
|
|
2091
|
+
* freed it: an ended video, or a subscription that failed. Not while
|
|
2092
|
+
* reconnecting: a failure caused by the socket dropping would otherwise
|
|
2093
|
+
* start every waiting video on a dead connection, each failing with
|
|
2094
|
+
* not-connected; the reconnect's join lists them all again anyway.
|
|
2095
|
+
*/
|
|
2096
|
+
releaseVideoSlot(producerId) {
|
|
2097
|
+
if (this.videoSlots.delete(producerId) && !this.closedByUs && this.state !== 'reconnecting')
|
|
2098
|
+
this.consumeSkipped();
|
|
2099
|
+
}
|
|
1919
2100
|
/** The oldest videos left waiting at the fan-out limit take the slots that opened. */
|
|
1920
|
-
|
|
2101
|
+
consumeSkipped() {
|
|
1921
2102
|
for (const [producerId, remote] of this.skippedVideos) {
|
|
1922
2103
|
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
1923
2104
|
if (limit !== null && this.videoSlots.size >= limit)
|
|
1924
2105
|
return;
|
|
1925
2106
|
this.skippedVideos.delete(producerId);
|
|
1926
|
-
await
|
|
2107
|
+
// consume reserves synchronously, before its first await. Consecutive
|
|
2108
|
+
// releases cannot admit the same producer or exceed the limit.
|
|
2109
|
+
void this.consume(remote).catch((err) => this.emit('error', err));
|
|
1927
2110
|
}
|
|
1928
2111
|
}
|
|
1929
|
-
async consumeNow(remote) {
|
|
2112
|
+
async consumeNow(remote, operation) {
|
|
1930
2113
|
if (!this.device)
|
|
1931
2114
|
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
2115
|
+
const device = this.device;
|
|
2116
|
+
const current = (made) => {
|
|
2117
|
+
if (!this.closedByUs && this.subscriptions.get(remote.producerId) === operation)
|
|
2118
|
+
return;
|
|
2119
|
+
made?.close();
|
|
2120
|
+
throw new errors_js_1.RpcError('not-connected', 'subscription ended during setup');
|
|
2121
|
+
};
|
|
1932
2122
|
const transport = await this.ensureRecvTransport();
|
|
2123
|
+
current();
|
|
1933
2124
|
const params = (await this.rpc('consume', {
|
|
1934
2125
|
transportId: transport.id,
|
|
1935
2126
|
producerId: remote.producerId,
|
|
1936
|
-
rtpCapabilities:
|
|
2127
|
+
rtpCapabilities: device.rtpCapabilities,
|
|
1937
2128
|
}));
|
|
2129
|
+
current();
|
|
1938
2130
|
const consumer = await transport.consume(params);
|
|
2131
|
+
current(consumer);
|
|
1939
2132
|
this.consumers.set(remote.producerId, consumer);
|
|
1940
2133
|
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
|
|
1941
2134
|
// Layers asked for before a reconnect: the new consumer gets them before
|
|
@@ -1955,9 +2148,11 @@ class StreamClient {
|
|
|
1955
2148
|
if (layers) {
|
|
1956
2149
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
|
|
1957
2150
|
}
|
|
2151
|
+
current(consumer);
|
|
1958
2152
|
// The server created it paused. Resume only now that our end exists,
|
|
1959
2153
|
// otherwise the first frames arrive before there is anything to decode them.
|
|
1960
2154
|
await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
2155
|
+
current(consumer);
|
|
1961
2156
|
const kind = consumer.kind;
|
|
1962
2157
|
const source = remote.source ?? defaultSource(kind);
|
|
1963
2158
|
this.remoteSources.set(remote.producerId, source);
|
|
@@ -2104,7 +2299,20 @@ class StreamClient {
|
|
|
2104
2299
|
}
|
|
2105
2300
|
promote(identity, role) { return this.rpc('promote', { identity, role }); }
|
|
2106
2301
|
kick(identity, reason) { return this.rpc('kick', { identity, reason }); }
|
|
2107
|
-
|
|
2302
|
+
/**
|
|
2303
|
+
* End the room for everyone. The server tells the room it has closed
|
|
2304
|
+
* before it replies, and that roomClosed closes this session, so the reply
|
|
2305
|
+
* may never be read: roomClosed is then the answer, and endRoom() resolves
|
|
2306
|
+
* as the reply would have, with `{ ok: true }`. It still fails when the
|
|
2307
|
+
* server refuses, or the connection drops with neither.
|
|
2308
|
+
*/
|
|
2309
|
+
endRoom() {
|
|
2310
|
+
return new Promise((resolve, reject) => {
|
|
2311
|
+
const ended = () => resolve({ ok: true });
|
|
2312
|
+
this.endRoomWaiters.add(ended);
|
|
2313
|
+
this.rpc('endRoom').then(resolve, reject).finally(() => this.endRoomWaiters.delete(ended));
|
|
2314
|
+
});
|
|
2315
|
+
}
|
|
2108
2316
|
getRoomState() { return this.rpc('getRoomState'); }
|
|
2109
2317
|
// ------------------------------------------------------- slots and metadata
|
|
2110
2318
|
//
|