@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/esm/index.js
CHANGED
|
@@ -55,6 +55,8 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
|
|
|
55
55
|
* otherwise starts low and takes many seconds to climb to the top layer.
|
|
56
56
|
*/
|
|
57
57
|
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
58
|
+
/** Why a connect() gave up: close() ran while it was under way, and close wins. */
|
|
59
|
+
const closedWhileConnecting = () => new RpcError('not-connected', 'the session was closed while connecting');
|
|
58
60
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
59
61
|
const unpublishedWhile = (what) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
60
62
|
/**
|
|
@@ -108,6 +110,16 @@ export class StreamClient {
|
|
|
108
110
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
109
111
|
music = null;
|
|
110
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();
|
|
111
123
|
screenStream = null;
|
|
112
124
|
screenMode = 'detail';
|
|
113
125
|
screenAudioEnabled = true;
|
|
@@ -124,13 +136,23 @@ export class StreamClient {
|
|
|
124
136
|
frameRateWarmupSamples = 2;
|
|
125
137
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
126
138
|
remoteSources = new Map();
|
|
139
|
+
/** Identity of each pending/active subscription; cleared on close/reconnect. */
|
|
140
|
+
subscriptions = new Map();
|
|
127
141
|
/**
|
|
128
142
|
* The layers asked for each remote video, by producerId. A reconnect
|
|
129
143
|
* consumes everything again, and the new consumer would otherwise start at
|
|
130
144
|
* the full picture.
|
|
131
145
|
*/
|
|
132
146
|
preferredLayers = new Map();
|
|
147
|
+
/** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
|
|
148
|
+
endRoomWaiters = new Set();
|
|
133
149
|
closedByUs = false;
|
|
150
|
+
/**
|
|
151
|
+
* Bumped when the session ends for good: close(), or finish(). A connect()
|
|
152
|
+
* under way when it did -- the app's, or a reconnect's -- sees the change
|
|
153
|
+
* after its next await and stops, so a closed session never comes back.
|
|
154
|
+
*/
|
|
155
|
+
lifecycle = 0;
|
|
134
156
|
reconnecting = false;
|
|
135
157
|
reconnectAttempt = 0;
|
|
136
158
|
/** Set once a connect() has joined and set up. Only such a session is reconnected. */
|
|
@@ -154,6 +176,17 @@ export class StreamClient {
|
|
|
154
176
|
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
155
177
|
tokenExpiry;
|
|
156
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;
|
|
157
190
|
/** Whose each received track is and whether it is switched off, by producerId. */
|
|
158
191
|
remoteInfo = new Map();
|
|
159
192
|
/** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
|
|
@@ -211,9 +244,21 @@ export class StreamClient {
|
|
|
211
244
|
/** Over for good: the state, then the event that says why. */
|
|
212
245
|
finish(reason) {
|
|
213
246
|
this.closedByUs = true;
|
|
247
|
+
this.lifecycle += 1;
|
|
214
248
|
this.setState('closed');
|
|
215
249
|
this.emit('closed', reason);
|
|
216
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
|
+
}
|
|
217
262
|
stopTimers() {
|
|
218
263
|
if (this.tokenTimer)
|
|
219
264
|
clearTimeout(this.tokenTimer);
|
|
@@ -235,21 +280,33 @@ export class StreamClient {
|
|
|
235
280
|
}
|
|
236
281
|
// ------------------------------------------------------------- connection
|
|
237
282
|
async connect() {
|
|
283
|
+
const lifecycle = this.lifecycle;
|
|
284
|
+
const closed = () => lifecycle !== this.lifecycle;
|
|
238
285
|
let result;
|
|
239
286
|
if (this.state === 'idle' || this.state === 'closed')
|
|
240
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;
|
|
241
291
|
try {
|
|
242
|
-
|
|
292
|
+
const opening = this.openSocket();
|
|
293
|
+
socket = this.ws;
|
|
294
|
+
await opening;
|
|
243
295
|
result = (await this.rpc('join', { token: this.token }));
|
|
244
296
|
this.joinResult = result;
|
|
245
297
|
this.device = this.createDevice();
|
|
246
298
|
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
299
|
+
// close() ran while the Device loaded: going on would set the state
|
|
300
|
+
// back to connected, and emit joined, after closed.
|
|
301
|
+
if (closed())
|
|
302
|
+
throw closedWhileConnecting();
|
|
247
303
|
}
|
|
248
304
|
catch (err) {
|
|
249
305
|
// A session that never got going: the app has the error. Left open,
|
|
250
306
|
// the socket would be closed by the server's join deadline instead, and
|
|
251
307
|
// that close would start reconnecting a session that never began.
|
|
252
|
-
|
|
308
|
+
const superseded = this.ws !== null && this.ws !== socket;
|
|
309
|
+
if (!this.joinedOnce && !superseded) {
|
|
253
310
|
this.detachSocket();
|
|
254
311
|
if (this.state === 'connecting')
|
|
255
312
|
this.setState('idle');
|
|
@@ -269,6 +326,8 @@ export class StreamClient {
|
|
|
269
326
|
// decides what may return; reconnecting never turns a blocked device on.
|
|
270
327
|
if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
|
|
271
328
|
await this.unpublish();
|
|
329
|
+
if (closed())
|
|
330
|
+
throw closedWhileConnecting();
|
|
272
331
|
}
|
|
273
332
|
const own = result.participants.find((p) => p.identity === result.identity);
|
|
274
333
|
if (own?.muted)
|
|
@@ -290,15 +349,22 @@ export class StreamClient {
|
|
|
290
349
|
if (!present.has(producerId))
|
|
291
350
|
this.remoteVideoLayers.delete(producerId);
|
|
292
351
|
// Subscribe to everything already publishing before we announce readiness.
|
|
352
|
+
// A close() part-way stops it: what is left would only fail, after closed.
|
|
293
353
|
for (const p of result.producers) {
|
|
294
|
-
|
|
354
|
+
if (closed())
|
|
355
|
+
throw closedWhileConnecting();
|
|
356
|
+
await this.consume(p).catch((err) => { if (!closed())
|
|
357
|
+
this.emit('error', err); });
|
|
295
358
|
}
|
|
296
359
|
// Restore our own broadcast. This is what makes an origin failover
|
|
297
360
|
// survivable for the host: it reconnects onto the new origin and the
|
|
298
361
|
// stream comes back on its own.
|
|
299
362
|
if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
|
|
300
|
-
await this.republish().catch((err) =>
|
|
363
|
+
await this.republish().catch((err) => { if (!closed())
|
|
364
|
+
this.emit('error', err); });
|
|
301
365
|
}
|
|
366
|
+
if (closed())
|
|
367
|
+
throw closedWhileConnecting();
|
|
302
368
|
this.syncNetworkWatch();
|
|
303
369
|
return result;
|
|
304
370
|
}
|
|
@@ -344,17 +410,16 @@ export class StreamClient {
|
|
|
344
410
|
// Another session for this identity is live. Reconnecting would
|
|
345
411
|
// displace it, and it would displace us right back, forever.
|
|
346
412
|
this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
|
|
347
|
-
// The
|
|
348
|
-
// videoLimited sampling must not, or it reports after `closed`.
|
|
413
|
+
// The videoLimited sampling stops first, or it reports after `closed`.
|
|
349
414
|
this.limitWatch.reset();
|
|
350
|
-
this.
|
|
415
|
+
this.end('replaced');
|
|
351
416
|
return;
|
|
352
417
|
}
|
|
353
418
|
if (ev.code === CLOSE_ENDED) {
|
|
354
419
|
// Normally roomClosed has already ended the session; this covers a
|
|
355
420
|
// close that arrived without it.
|
|
356
421
|
this.limitWatch.reset();
|
|
357
|
-
this.
|
|
422
|
+
this.end('room-closed');
|
|
358
423
|
return;
|
|
359
424
|
}
|
|
360
425
|
// Before a first successful join the app is still waiting on
|
|
@@ -367,17 +432,33 @@ export class StreamClient {
|
|
|
367
432
|
});
|
|
368
433
|
});
|
|
369
434
|
}
|
|
370
|
-
detachSocket() {
|
|
435
|
+
detachSocket(reason = 'superseded', why = 'the connection was replaced') {
|
|
371
436
|
const ws = this.ws;
|
|
372
437
|
this.ws = null;
|
|
373
|
-
if (ws
|
|
438
|
+
if (!ws)
|
|
439
|
+
return;
|
|
440
|
+
if (ws.readyState <= WebSocket.OPEN) {
|
|
374
441
|
try {
|
|
375
|
-
ws.close(1000,
|
|
442
|
+
ws.close(1000, reason);
|
|
376
443
|
}
|
|
377
444
|
catch {
|
|
378
445
|
/* already closing */
|
|
379
446
|
}
|
|
380
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));
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* A reconnect that ends the session lets go of its socket. A refused join
|
|
455
|
+
* leaves that socket open (connect() keeps it once a session has joined),
|
|
456
|
+
* and the session would otherwise report closed with it still attached and
|
|
457
|
+
* still sending.
|
|
458
|
+
*/
|
|
459
|
+
releaseSocket() {
|
|
460
|
+
this.detachSocket('gave up', 'the session has ended');
|
|
461
|
+
this.failAllPending(new RpcError('not-connected', 'the session has ended'));
|
|
381
462
|
}
|
|
382
463
|
/**
|
|
383
464
|
* Reconnection is mandatory, not optional: nodes get drained, relays die and
|
|
@@ -396,7 +477,8 @@ export class StreamClient {
|
|
|
396
477
|
this.reconnectAttempt += 1;
|
|
397
478
|
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
398
479
|
this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
399
|
-
this.
|
|
480
|
+
this.releaseSocket();
|
|
481
|
+
this.end('gave-up');
|
|
400
482
|
return;
|
|
401
483
|
}
|
|
402
484
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
@@ -414,12 +496,17 @@ export class StreamClient {
|
|
|
414
496
|
return;
|
|
415
497
|
}
|
|
416
498
|
catch (err) {
|
|
499
|
+
// Ended while this attempt was under way -- by close(), or by the
|
|
500
|
+
// server -- and that has been reported: nothing follows closed.
|
|
501
|
+
if (this.closedByUs)
|
|
502
|
+
return;
|
|
417
503
|
this.emit('error', err);
|
|
418
504
|
// Stop on failures a retry cannot resolve (the registry's terminal
|
|
419
505
|
// codes). Hammering a room that has ended produces one error per
|
|
420
506
|
// attempt forever and drowns out whatever actually went wrong.
|
|
421
507
|
if (err instanceof RpcError && isTerminalError(err.code)) {
|
|
422
|
-
this.
|
|
508
|
+
this.releaseSocket();
|
|
509
|
+
this.end('gave-up');
|
|
423
510
|
return;
|
|
424
511
|
}
|
|
425
512
|
}
|
|
@@ -435,6 +522,7 @@ export class StreamClient {
|
|
|
435
522
|
// others have already reported themselves and set closedByUs.
|
|
436
523
|
const wasOpen = !this.closedByUs;
|
|
437
524
|
this.closedByUs = true;
|
|
525
|
+
this.lifecycle += 1;
|
|
438
526
|
this.publishGeneration += 1;
|
|
439
527
|
this.wasPublishing = false;
|
|
440
528
|
this.teardownMedia();
|
|
@@ -454,11 +542,16 @@ export class StreamClient {
|
|
|
454
542
|
const ws = this.ws;
|
|
455
543
|
this.ws = null;
|
|
456
544
|
ws?.close(1000, 'client closed');
|
|
545
|
+
// Detached first, so the socket's own close handler no longer fails what
|
|
546
|
+
// was waiting on it: done here, or a join or a request in flight would
|
|
547
|
+
// wait out its 15 s timeout.
|
|
548
|
+
this.failAllPending(new RpcError('not-connected', 'the session was closed'));
|
|
457
549
|
this.setState('closed');
|
|
458
550
|
if (wasOpen)
|
|
459
551
|
this.emit('closed', 'left');
|
|
460
552
|
}
|
|
461
553
|
teardownMedia() {
|
|
554
|
+
this.subscriptions.clear();
|
|
462
555
|
for (const c of this.consumers.values())
|
|
463
556
|
c.close();
|
|
464
557
|
this.consumers.clear();
|
|
@@ -529,19 +622,29 @@ export class StreamClient {
|
|
|
529
622
|
* `tokenExpiring`. On a live connection the server checks it like a join
|
|
530
623
|
* token (`bad-token`, or `token-mismatch` for another room or identity; the
|
|
531
624
|
* session then keeps its token); otherwise it is kept for the next join,
|
|
532
|
-
* which checks it there. Resolves to when
|
|
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.
|
|
533
629
|
*/
|
|
534
630
|
async updateToken(token) {
|
|
535
631
|
if (typeof token !== 'string' || token.trim() === '')
|
|
536
632
|
throw new TypeError('updateToken needs a token');
|
|
537
633
|
if (this.state === 'closed')
|
|
538
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
|
+
};
|
|
539
644
|
if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
|
|
540
645
|
try {
|
|
541
646
|
const res = (await this.rpc('refreshToken', { token }));
|
|
542
|
-
|
|
543
|
-
this.setToken(token, at);
|
|
544
|
-
return { expiresAt: this.tokenExpiry };
|
|
647
|
+
return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
|
|
545
648
|
}
|
|
546
649
|
catch (err) {
|
|
547
650
|
// An older server has no refreshToken, and a socket that dropped
|
|
@@ -551,8 +654,7 @@ export class StreamClient {
|
|
|
551
654
|
throw err;
|
|
552
655
|
}
|
|
553
656
|
}
|
|
554
|
-
|
|
555
|
-
return { expiresAt: this.tokenExpiry };
|
|
657
|
+
return keep(null);
|
|
556
658
|
}
|
|
557
659
|
/** The token reconnects use from now on, with its expiry (read from the token when not given). */
|
|
558
660
|
setToken(token, expiresAt) {
|
|
@@ -613,10 +715,20 @@ export class StreamClient {
|
|
|
613
715
|
this.pending.delete(id);
|
|
614
716
|
reject(new RpcError('timeout', `${method} timed out`));
|
|
615
717
|
}, RPC_TIMEOUT_MS);
|
|
616
|
-
this.pending.set(id, { resolve, reject, timer });
|
|
718
|
+
this.pending.set(id, { resolve, reject, timer, ws: this.ws });
|
|
617
719
|
this.ws.send(JSON.stringify({ id, method, params }));
|
|
618
720
|
});
|
|
619
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
|
+
}
|
|
620
732
|
failAllPending(err) {
|
|
621
733
|
for (const p of this.pending.values()) {
|
|
622
734
|
clearTimeout(p.timer);
|
|
@@ -632,6 +744,10 @@ export class StreamClient {
|
|
|
632
744
|
catch {
|
|
633
745
|
return;
|
|
634
746
|
}
|
|
747
|
+
// Valid JSON that is not a message object ("null" would throw below) is
|
|
748
|
+
// ignored like any other frame that is not a message.
|
|
749
|
+
if (typeof msg !== 'object' || msg === null)
|
|
750
|
+
return;
|
|
635
751
|
if (typeof msg.id === 'number') {
|
|
636
752
|
const p = this.pending.get(msg.id);
|
|
637
753
|
if (!p)
|
|
@@ -657,23 +773,21 @@ export class StreamClient {
|
|
|
657
773
|
break;
|
|
658
774
|
case 'producerClosed': {
|
|
659
775
|
const { producerId, identity } = data;
|
|
776
|
+
this.subscriptions.delete(producerId);
|
|
660
777
|
this.preferredLayers.delete(producerId);
|
|
661
778
|
this.receiveQualities.delete(producerId);
|
|
662
779
|
this.remoteVideoLayers.delete(producerId);
|
|
663
780
|
this.skippedVideos.delete(producerId);
|
|
664
781
|
const consumer = this.consumers.get(producerId);
|
|
665
|
-
|
|
666
|
-
break; // never consumed it, or already cleaned up
|
|
667
|
-
this.videoSlots.delete(producerId);
|
|
668
|
-
consumer.close();
|
|
782
|
+
consumer?.close();
|
|
669
783
|
this.consumers.delete(producerId);
|
|
670
784
|
this.remoteSources.delete(producerId);
|
|
671
785
|
this.remoteInfo.delete(producerId);
|
|
672
|
-
|
|
786
|
+
if (consumer)
|
|
787
|
+
this.emit('trackEnded', { producerId, identity });
|
|
673
788
|
// A video slot opened under the fan-out limit: the oldest video left
|
|
674
789
|
// waiting takes it.
|
|
675
|
-
|
|
676
|
-
void this.consumeSkipped().catch((err) => this.emit('error', err));
|
|
790
|
+
this.releaseVideoSlot(producerId);
|
|
677
791
|
break;
|
|
678
792
|
}
|
|
679
793
|
case 'producerPaused': {
|
|
@@ -771,15 +885,19 @@ export class StreamClient {
|
|
|
771
885
|
// fresh one periodically so a reconnect at any point still works --
|
|
772
886
|
// without this, any disconnect after the token's lifetime is terminal.
|
|
773
887
|
const e = data;
|
|
774
|
-
if (e.token)
|
|
888
|
+
if (e.token) {
|
|
889
|
+
this.serverTokenRevision += 1;
|
|
775
890
|
this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
|
|
891
|
+
}
|
|
776
892
|
break;
|
|
777
893
|
}
|
|
778
894
|
case 'mustReconnect': {
|
|
779
895
|
const e = data;
|
|
780
896
|
// A promotion hands us a fresh token carrying the new role.
|
|
781
|
-
if (e.token)
|
|
897
|
+
if (e.token) {
|
|
898
|
+
this.serverTokenRevision += 1;
|
|
782
899
|
this.setToken(e.token, null);
|
|
900
|
+
}
|
|
783
901
|
this.emit('mustReconnect', e);
|
|
784
902
|
if (e.retry) {
|
|
785
903
|
this.teardownMedia();
|
|
@@ -805,6 +923,11 @@ export class StreamClient {
|
|
|
805
923
|
this.closedByUs = true;
|
|
806
924
|
this.emit('roomClosed', d);
|
|
807
925
|
this.finish('room-closed');
|
|
926
|
+
// Before close() fails what is pending: an endRoom() this event
|
|
927
|
+
// answers succeeded.
|
|
928
|
+
for (const ended of this.endRoomWaiters)
|
|
929
|
+
ended();
|
|
930
|
+
this.endRoomWaiters.clear();
|
|
808
931
|
this.close();
|
|
809
932
|
break;
|
|
810
933
|
case 'kicked':
|
|
@@ -1538,8 +1661,11 @@ export class StreamClient {
|
|
|
1538
1661
|
await this.stopScreen('stopped');
|
|
1539
1662
|
this.emit('error', err);
|
|
1540
1663
|
});
|
|
1541
|
-
if (this.music)
|
|
1542
|
-
|
|
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
|
+
}
|
|
1543
1669
|
if (generation !== this.publishGeneration)
|
|
1544
1670
|
return;
|
|
1545
1671
|
let stream = this.localStream;
|
|
@@ -1773,28 +1899,43 @@ export class StreamClient {
|
|
|
1773
1899
|
if (!this.mediaPermissions.audio)
|
|
1774
1900
|
throw new RpcError('media-disabled', 'audio is switched off for this project');
|
|
1775
1901
|
const generation = this.publishGeneration;
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
const
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
// Checked again here: an unpublish() after the producer was kept has
|
|
1782
|
-
// already closed it, and the music must not be remembered for a
|
|
1783
|
-
// reconnect to publish again.
|
|
1784
|
-
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)
|
|
1785
1907
|
throw unpublishedWhile('music');
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
music
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
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
|
+
});
|
|
1794
1927
|
}
|
|
1795
|
-
|
|
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;
|
|
1796
1937
|
const transport = await this.ensureSendTransport();
|
|
1797
|
-
if (
|
|
1938
|
+
if (!current())
|
|
1798
1939
|
throw unpublishedWhile('music');
|
|
1799
1940
|
const producer = await transport.produce({
|
|
1800
1941
|
track: music.track,
|
|
@@ -1804,7 +1945,14 @@ export class StreamClient {
|
|
|
1804
1945
|
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
|
|
1805
1946
|
appData: { source: 'music' },
|
|
1806
1947
|
});
|
|
1807
|
-
|
|
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);
|
|
1808
1956
|
this.musicProducerId = producer.id;
|
|
1809
1957
|
// Music switched off before a reconnect comes back paused here.
|
|
1810
1958
|
await this.pauseOnServer(producer);
|
|
@@ -1833,8 +1981,17 @@ export class StreamClient {
|
|
|
1833
1981
|
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
1834
1982
|
this.emitLocalMedia();
|
|
1835
1983
|
}
|
|
1836
|
-
/**
|
|
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
|
+
*/
|
|
1837
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() {
|
|
1838
1995
|
const id = this.musicProducerId;
|
|
1839
1996
|
this.musicProducerId = null;
|
|
1840
1997
|
this.music?.close();
|
|
@@ -1854,9 +2011,11 @@ export class StreamClient {
|
|
|
1854
2011
|
});
|
|
1855
2012
|
}
|
|
1856
2013
|
async consume(remote) {
|
|
2014
|
+
if (this.closedByUs)
|
|
2015
|
+
return;
|
|
1857
2016
|
if (!this.device)
|
|
1858
2017
|
throw new RpcError('not-connected', 'not connected');
|
|
1859
|
-
if (this.
|
|
2018
|
+
if (this.subscriptions.has(remote.producerId))
|
|
1860
2019
|
return;
|
|
1861
2020
|
// Your own camera is already local; pulling it back off the SFU wastes a
|
|
1862
2021
|
// consumer and shows you a delayed copy of yourself.
|
|
@@ -1876,35 +2035,69 @@ export class StreamClient {
|
|
|
1876
2035
|
}
|
|
1877
2036
|
this.videoSlots.add(remote.producerId);
|
|
1878
2037
|
}
|
|
2038
|
+
const operation = {};
|
|
2039
|
+
this.subscriptions.set(remote.producerId, operation);
|
|
1879
2040
|
try {
|
|
1880
|
-
await this.consumeNow(remote);
|
|
2041
|
+
await this.consumeNow(remote, operation);
|
|
1881
2042
|
}
|
|
1882
2043
|
catch (err) {
|
|
1883
|
-
|
|
1884
|
-
|
|
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);
|
|
1885
2055
|
throw err;
|
|
1886
2056
|
}
|
|
1887
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
|
+
}
|
|
1888
2069
|
/** The oldest videos left waiting at the fan-out limit take the slots that opened. */
|
|
1889
|
-
|
|
2070
|
+
consumeSkipped() {
|
|
1890
2071
|
for (const [producerId, remote] of this.skippedVideos) {
|
|
1891
2072
|
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
1892
2073
|
if (limit !== null && this.videoSlots.size >= limit)
|
|
1893
2074
|
return;
|
|
1894
2075
|
this.skippedVideos.delete(producerId);
|
|
1895
|
-
await
|
|
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));
|
|
1896
2079
|
}
|
|
1897
2080
|
}
|
|
1898
|
-
async consumeNow(remote) {
|
|
2081
|
+
async consumeNow(remote, operation) {
|
|
1899
2082
|
if (!this.device)
|
|
1900
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
|
+
};
|
|
1901
2091
|
const transport = await this.ensureRecvTransport();
|
|
2092
|
+
current();
|
|
1902
2093
|
const params = (await this.rpc('consume', {
|
|
1903
2094
|
transportId: transport.id,
|
|
1904
2095
|
producerId: remote.producerId,
|
|
1905
|
-
rtpCapabilities:
|
|
2096
|
+
rtpCapabilities: device.rtpCapabilities,
|
|
1906
2097
|
}));
|
|
2098
|
+
current();
|
|
1907
2099
|
const consumer = await transport.consume(params);
|
|
2100
|
+
current(consumer);
|
|
1908
2101
|
this.consumers.set(remote.producerId, consumer);
|
|
1909
2102
|
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
|
|
1910
2103
|
// Layers asked for before a reconnect: the new consumer gets them before
|
|
@@ -1924,9 +2117,11 @@ export class StreamClient {
|
|
|
1924
2117
|
if (layers) {
|
|
1925
2118
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
|
|
1926
2119
|
}
|
|
2120
|
+
current(consumer);
|
|
1927
2121
|
// The server created it paused. Resume only now that our end exists,
|
|
1928
2122
|
// otherwise the first frames arrive before there is anything to decode them.
|
|
1929
2123
|
await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
2124
|
+
current(consumer);
|
|
1930
2125
|
const kind = consumer.kind;
|
|
1931
2126
|
const source = remote.source ?? defaultSource(kind);
|
|
1932
2127
|
this.remoteSources.set(remote.producerId, source);
|
|
@@ -2073,7 +2268,20 @@ export class StreamClient {
|
|
|
2073
2268
|
}
|
|
2074
2269
|
promote(identity, role) { return this.rpc('promote', { identity, role }); }
|
|
2075
2270
|
kick(identity, reason) { return this.rpc('kick', { identity, reason }); }
|
|
2076
|
-
|
|
2271
|
+
/**
|
|
2272
|
+
* End the room for everyone. The server tells the room it has closed
|
|
2273
|
+
* before it replies, and that roomClosed closes this session, so the reply
|
|
2274
|
+
* may never be read: roomClosed is then the answer, and endRoom() resolves
|
|
2275
|
+
* as the reply would have, with `{ ok: true }`. It still fails when the
|
|
2276
|
+
* server refuses, or the connection drops with neither.
|
|
2277
|
+
*/
|
|
2278
|
+
endRoom() {
|
|
2279
|
+
return new Promise((resolve, reject) => {
|
|
2280
|
+
const ended = () => resolve({ ok: true });
|
|
2281
|
+
this.endRoomWaiters.add(ended);
|
|
2282
|
+
this.rpc('endRoom').then(resolve, reject).finally(() => this.endRoomWaiters.delete(ended));
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2077
2285
|
getRoomState() { return this.rpc('getRoomState'); }
|
|
2078
2286
|
// ------------------------------------------------------- slots and metadata
|
|
2079
2287
|
//
|