@voltkit-rtc/client 0.7.2 → 0.7.3
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 +32 -0
- package/README.md +11 -2
- package/dist/cjs/index.d.ts +11 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +103 -10
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/recording.d.ts +14 -0
- package/dist/cjs/recording.d.ts.map +1 -1
- package/dist/cjs/recording.js +51 -5
- package/dist/cjs/recording.js.map +1 -1
- package/dist/esm/index.d.ts +11 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +103 -10
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/recording.d.ts +14 -0
- package/dist/esm/recording.d.ts.map +1 -1
- package/dist/esm/recording.js +51 -5
- package/dist/esm/recording.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +95 -9
- package/src/recording.ts +59 -21
package/src/index.ts
CHANGED
|
@@ -446,6 +446,16 @@ const VIDEO_START_BITRATE_KBPS = 2000;
|
|
|
446
446
|
const closedWhileConnecting = () => new RpcError('not-connected', 'the session was closed while connecting');
|
|
447
447
|
|
|
448
448
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
449
|
+
/**
|
|
450
|
+
* The constraints that capture again what two publishes captured: each kind
|
|
451
|
+
* as the later publish asked for it, or as the earlier one did if the later
|
|
452
|
+
* one did not ask for it.
|
|
453
|
+
*/
|
|
454
|
+
function mergeConstraints(earlier: MediaStreamConstraints | null, later: MediaStreamConstraints): MediaStreamConstraints {
|
|
455
|
+
if (!earlier) return later;
|
|
456
|
+
return { ...earlier, ...later, audio: later.audio || earlier.audio, video: later.video || earlier.video };
|
|
457
|
+
}
|
|
458
|
+
|
|
449
459
|
const unpublishedWhile = (what: string) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
450
460
|
|
|
451
461
|
/**
|
|
@@ -492,6 +502,15 @@ export class StreamClient {
|
|
|
492
502
|
// broadcaster that reconnects rejoins silently, which is worse than being
|
|
493
503
|
// disconnected: the room looks healthy and shows nothing.
|
|
494
504
|
private wasPublishing = false;
|
|
505
|
+
/** The deadline of the socket being opened (openSocket). */
|
|
506
|
+
private openDeadline: ReturnType<typeof setTimeout> | undefined;
|
|
507
|
+
/**
|
|
508
|
+
* This client's own moderation per kind: whether a moderator holds it off,
|
|
509
|
+
* and a revision every decision bumps. A switch-on that was waiting for the
|
|
510
|
+
* server when a decision arrived does not act on its reply (V072-06).
|
|
511
|
+
*/
|
|
512
|
+
private forcedOff = { audio: false, video: false };
|
|
513
|
+
private moderationRevision = { audio: 0, video: 0 };
|
|
495
514
|
private publishConstraints: MediaStreamConstraints | null = null;
|
|
496
515
|
private publishOptions: PublishOptions = {};
|
|
497
516
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
@@ -701,7 +720,10 @@ export class StreamClient {
|
|
|
701
720
|
// the socket would be closed by the server's join deadline instead, and
|
|
702
721
|
// that close would start reconnecting a session that never began.
|
|
703
722
|
const superseded = this.ws !== null && this.ws !== socket;
|
|
704
|
-
|
|
723
|
+
// A reconnect attempt that failed (its join timed out, say) lets its
|
|
724
|
+
// socket go now too, so nothing it says later reaches the session while
|
|
725
|
+
// the next attempt waits its backoff (V072-05).
|
|
726
|
+
if ((!this.joinedOnce || this.reconnecting) && !superseded) {
|
|
705
727
|
this.detachSocket();
|
|
706
728
|
if (this.state === 'connecting') this.setState('idle');
|
|
707
729
|
}
|
|
@@ -724,6 +746,8 @@ export class StreamClient {
|
|
|
724
746
|
if (closed()) throw closedWhileConnecting();
|
|
725
747
|
}
|
|
726
748
|
const own = result.participants.find((p) => p.identity === result.identity);
|
|
749
|
+
this.moderate('audio', Boolean(own?.muted), true);
|
|
750
|
+
this.moderate('video', Boolean(own?.videoBlocked), true);
|
|
727
751
|
if (own?.muted) this.applyLocalModeration('audio');
|
|
728
752
|
if (own?.videoBlocked) this.applyLocalModeration('video');
|
|
729
753
|
this.emit('joined', result);
|
|
@@ -779,11 +803,22 @@ export class StreamClient {
|
|
|
779
803
|
const ws = new WebSocket(socketUrl(this.wsUrl, this.token));
|
|
780
804
|
this.ws = ws;
|
|
781
805
|
|
|
806
|
+
// Bounded like every request (V072-05): a socket that does not open in
|
|
807
|
+
// time fails the attempt with `timeout` and is closed, instead of
|
|
808
|
+
// holding connect() -- or a reconnect attempt -- indefinitely.
|
|
809
|
+
this.openDeadline = setTimeout(() => {
|
|
810
|
+
reject(new RpcError('timeout', `the connection did not open in ${RPC_TIMEOUT_MS / 1000} s`));
|
|
811
|
+
if (this.ws === ws) this.detachSocket('open timed out', 'the connection did not open in time');
|
|
812
|
+
}, RPC_TIMEOUT_MS);
|
|
782
813
|
const onOpen = () => {
|
|
814
|
+
clearTimeout(this.openDeadline);
|
|
783
815
|
this.emit('connected');
|
|
784
816
|
resolve();
|
|
785
817
|
};
|
|
786
|
-
const onError = () =>
|
|
818
|
+
const onError = () => {
|
|
819
|
+
clearTimeout(this.openDeadline);
|
|
820
|
+
reject(new RpcError('connect-failed', 'websocket failed to open'));
|
|
821
|
+
};
|
|
787
822
|
|
|
788
823
|
ws.addEventListener('open', onOpen, { once: true });
|
|
789
824
|
ws.addEventListener('error', onError, { once: true });
|
|
@@ -826,6 +861,7 @@ export class StreamClient {
|
|
|
826
861
|
}
|
|
827
862
|
|
|
828
863
|
private detachSocket(reason = 'superseded', why = 'the connection was replaced'): void {
|
|
864
|
+
clearTimeout(this.openDeadline);
|
|
829
865
|
const ws = this.ws;
|
|
830
866
|
this.ws = null;
|
|
831
867
|
if (!ws) return;
|
|
@@ -1203,6 +1239,7 @@ export class StreamClient {
|
|
|
1203
1239
|
case 'participantMuted': {
|
|
1204
1240
|
const wire = data as { identity: string; kind?: 'audio' | 'video' | ''; muted?: boolean; paused?: boolean };
|
|
1205
1241
|
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
1242
|
+
if (e.identity && e.identity === this.joinResult?.identity) this.moderate(e.kind, e.paused, true);
|
|
1206
1243
|
this.updateRoster((list) => list.map((p) =>
|
|
1207
1244
|
p.identity !== e.identity ? p : { ...p,
|
|
1208
1245
|
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
@@ -1249,6 +1286,7 @@ export class StreamClient {
|
|
|
1249
1286
|
// in-app music: it travels as audio). Reflect it locally; a release
|
|
1250
1287
|
// changes nothing here -- switching back on is the user's choice.
|
|
1251
1288
|
const e = data as { by: string; kind?: 'audio' | 'video' | ''; paused: boolean };
|
|
1289
|
+
this.moderate(e.kind, Boolean(e.paused));
|
|
1252
1290
|
if (e.paused) {
|
|
1253
1291
|
this.applyLocalModeration(e.kind);
|
|
1254
1292
|
this.emitLocalMedia();
|
|
@@ -1453,9 +1491,26 @@ export class StreamClient {
|
|
|
1453
1491
|
// unpublish() ran first: this publish is over before it began, and must
|
|
1454
1492
|
// leave nothing behind that a reconnect would publish again.
|
|
1455
1493
|
if (generation !== this.publishGeneration) throw unpublishedWhile('stream');
|
|
1456
|
-
|
|
1494
|
+
// A second publish adds to the first (the microphone, then the camera):
|
|
1495
|
+
// one local stream carries every live track, so unpublish() and close()
|
|
1496
|
+
// stop them all and a reconnect publishes each again. Replacing it
|
|
1497
|
+
// orphaned the first stream's tracks, still capturing (V072-01).
|
|
1498
|
+
const previous = this.localStream;
|
|
1499
|
+
const merged = previous !== null && previous !== stream && previous.getTracks().some((t) => t.readyState === 'live');
|
|
1500
|
+
if (merged) {
|
|
1501
|
+
const ids = new Set(stream.getTracks().map((t) => t.id));
|
|
1502
|
+
// The new stream's tracks first: of one kind, the newer track is the one
|
|
1503
|
+
// published (as before), and the older one is still stopped with it.
|
|
1504
|
+
this.localStream = new MediaStream([
|
|
1505
|
+
...stream.getTracks(),
|
|
1506
|
+
...previous.getTracks().filter((t) => t.readyState === 'live' && !ids.has(t.id)),
|
|
1507
|
+
]);
|
|
1508
|
+
this.emit('localStreamChanged', this.localStream);
|
|
1509
|
+
} else {
|
|
1510
|
+
this.localStream = stream;
|
|
1511
|
+
}
|
|
1457
1512
|
this.wasPublishing = true;
|
|
1458
|
-
if (constraints) this.publishConstraints = constraints;
|
|
1513
|
+
if (constraints) this.publishConstraints = merged ? mergeConstraints(this.publishConstraints, constraints) : constraints;
|
|
1459
1514
|
if (options) {
|
|
1460
1515
|
this.publishOptions = { ...options };
|
|
1461
1516
|
this.frameRateFallback = false;
|
|
@@ -2544,16 +2599,47 @@ export class StreamClient {
|
|
|
2544
2599
|
}
|
|
2545
2600
|
|
|
2546
2601
|
private async setKindEnabled(kind: 'audio' | 'video', enabled: boolean): Promise<void> {
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2602
|
+
const generation = this.publishGeneration;
|
|
2603
|
+
// Unpublished or closed while waiting: nothing is left to switch, and the
|
|
2604
|
+
// call resolves, as it does with nothing published (V072-03).
|
|
2605
|
+
const ended = () => generation !== this.publishGeneration || this.closedByUs;
|
|
2606
|
+
// The music has its own switch. Muting the microphone must not stop the song.
|
|
2607
|
+
const targets = [...this.producers.values()].filter((p) => p.kind === kind && p.appData.source === defaultSource(kind));
|
|
2608
|
+
for (const producer of targets) {
|
|
2609
|
+
if (ended()) return;
|
|
2610
|
+
if (this.producers.get(producer.id) !== producer || producer.closed) continue;
|
|
2611
|
+
const revision = this.moderationRevision[kind];
|
|
2550
2612
|
if (!enabled) producer.pause();
|
|
2551
|
-
|
|
2552
|
-
|
|
2613
|
+
try {
|
|
2614
|
+
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
2615
|
+
} catch (err) {
|
|
2616
|
+
if (ended()) return;
|
|
2617
|
+
throw err;
|
|
2618
|
+
}
|
|
2619
|
+
if (ended()) return;
|
|
2620
|
+
if (!enabled || this.producers.get(producer.id) !== producer || producer.closed) continue;
|
|
2621
|
+
if (revision !== this.moderationRevision[kind] || this.forcedOff[kind]) {
|
|
2622
|
+
// A moderator decided while this waited: the older request does not
|
|
2623
|
+
// undo it. It stays off here, and the server is told so too, since
|
|
2624
|
+
// which of the two it applied last cannot be known from here.
|
|
2625
|
+
void this.rpc('pauseProducer', { producerId: producer.id, paused: true }).catch(() => undefined);
|
|
2626
|
+
this.emitLocalMedia();
|
|
2627
|
+
throw new RpcError('force-muted', `a moderator changed your ${kind} while this request waited; it stays off`);
|
|
2628
|
+
}
|
|
2629
|
+
producer.resume();
|
|
2553
2630
|
}
|
|
2554
2631
|
this.emitLocalMedia();
|
|
2555
2632
|
}
|
|
2556
2633
|
|
|
2634
|
+
/** Record a moderation decision ('' = both kinds). A roster snapshot counts only where it changes what is known. */
|
|
2635
|
+
private moderate(kind: 'audio' | 'video' | '' | undefined, paused: boolean, snapshot = false): void {
|
|
2636
|
+
for (const k of kind ? [kind] : (['audio', 'video'] as const)) {
|
|
2637
|
+
if (snapshot && this.forcedOff[k] === paused) continue;
|
|
2638
|
+
this.forcedOff[k] = paused;
|
|
2639
|
+
this.moderationRevision[k] += 1;
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2557
2643
|
/** Stop publishing entirely and release the local devices. */
|
|
2558
2644
|
async unpublish(): Promise<void> {
|
|
2559
2645
|
void this.stopRecording().catch(error => this.emit('error', error as Error));
|
package/src/recording.ts
CHANGED
|
@@ -53,25 +53,26 @@ export class RecordingError extends Error {
|
|
|
53
53
|
}
|
|
54
54
|
const delay = (ms: number) =>
|
|
55
55
|
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
56
|
+
/** One POST to the recording API. */
|
|
57
|
+
function recordingFetch(base: string, path: string, body: unknown, token?: string): Promise<Response> {
|
|
58
|
+
return fetch(`${base.replace(/\/$/, "")}/recording/upload${path}`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
credentials: "omit",
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
64
|
+
},
|
|
65
|
+
body: JSON.stringify(body),
|
|
66
|
+
signal: AbortSignal.timeout(path.endsWith("/complete") ? 180000 : 15000),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
56
69
|
export async function recordingRequest<T>(
|
|
57
70
|
base: string,
|
|
58
71
|
path: string,
|
|
59
72
|
body: unknown,
|
|
60
73
|
token?: string,
|
|
61
74
|
): Promise<T> {
|
|
62
|
-
const response = await
|
|
63
|
-
`${base.replace(/\/$/, "")}/recording/upload${path}`,
|
|
64
|
-
{
|
|
65
|
-
method: "POST",
|
|
66
|
-
credentials: "omit",
|
|
67
|
-
headers: {
|
|
68
|
-
"Content-Type": "application/json",
|
|
69
|
-
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
70
|
-
},
|
|
71
|
-
body: JSON.stringify(body),
|
|
72
|
-
signal: AbortSignal.timeout(path.endsWith("/complete") ? 180000 : 15000),
|
|
73
|
-
},
|
|
74
|
-
);
|
|
75
|
+
const response = await recordingFetch(base, path, body, token);
|
|
75
76
|
const result = (await response.json()) as {
|
|
76
77
|
success: boolean;
|
|
77
78
|
data?: T;
|
|
@@ -304,16 +305,53 @@ export class RecordingUploader {
|
|
|
304
305
|
}
|
|
305
306
|
this.sealed = true;
|
|
306
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* How long complete() waits before asking `/complete` again when the
|
|
310
|
+
* answer was lost or unclear: one wait per extra attempt. Exposed for tests.
|
|
311
|
+
* @internal
|
|
312
|
+
*/
|
|
313
|
+
static completeRetryDelaysMs = [1000, 3000];
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Finalise. An accepted `/complete` whose reply cannot be read rejects
|
|
317
|
+
* saying the recording completed. A lost or unclear answer (no reply, an
|
|
318
|
+
* unreadable one, a 5xx, 408/409/425/429) is asked again -- the service's
|
|
319
|
+
* completion is idempotent -- and if it is still unclear, rejects saying the
|
|
320
|
+
* recording may already be saved. Neither is aborted; only a readable
|
|
321
|
+
* refusal is final (V072-04).
|
|
322
|
+
*/
|
|
307
323
|
async complete(): Promise<RecordingFile> {
|
|
308
324
|
await this.seal();
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
325
|
+
const path = `/sessions/${this.session.recording.id}/complete`;
|
|
326
|
+
for (let attempt = 0; ; attempt++) {
|
|
327
|
+
type Reply = { success?: unknown; data?: unknown; error?: { message?: string } };
|
|
328
|
+
let status = 0;
|
|
329
|
+
let body: Reply | null = null;
|
|
330
|
+
try {
|
|
331
|
+
const response = await recordingFetch(this.options.apiUrl, path, {}, this.session.token);
|
|
332
|
+
status = response.status;
|
|
333
|
+
const parsed: unknown = await response.json();
|
|
334
|
+
if (parsed && typeof parsed === "object") body = parsed as Reply;
|
|
335
|
+
} catch {
|
|
336
|
+
// Lost, cut short or unreadable: the service may have saved it.
|
|
337
|
+
}
|
|
338
|
+
if (body && status > 0 && status < 400 && body.success === true) {
|
|
339
|
+
const file = body.data as RecordingFile | null | undefined;
|
|
340
|
+
if (!file || typeof file !== "object" || typeof file.id !== "string") {
|
|
341
|
+
throw new RecordingError("The recording was completed but its reply could not be read", status);
|
|
342
|
+
}
|
|
343
|
+
this.emit("ready");
|
|
344
|
+
return file;
|
|
345
|
+
}
|
|
346
|
+
if (body && status > 0 && status < 500 && ![408, 409, 425, 429].includes(status)) {
|
|
347
|
+
throw new RecordingError(body.error?.message ?? "Recording request failed", status);
|
|
348
|
+
}
|
|
349
|
+
const delays = RecordingUploader.completeRetryDelaysMs;
|
|
350
|
+
if (attempt >= delays.length) {
|
|
351
|
+
throw new RecordingError("The service did not confirm the recording, which may already be saved; it was not aborted", status);
|
|
352
|
+
}
|
|
353
|
+
await delay(delays[attempt]);
|
|
354
|
+
}
|
|
317
355
|
}
|
|
318
356
|
async abort(): Promise<void> {
|
|
319
357
|
this.canceled = true;
|