@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/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
- if (!this.joinedOnce && !superseded) {
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 = () => reject(new RpcError('connect-failed', 'websocket failed to open'));
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
- this.localStream = stream;
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
- for (const producer of this.producers.values()) {
2548
- // The music has its own switch. Muting the microphone must not stop the song.
2549
- if (producer.kind !== kind || producer.appData.source !== defaultSource(kind)) continue;
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
- await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
2552
- if (enabled) producer.resume();
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 fetch(
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 file = await recordingRequest<RecordingFile>(
310
- this.options.apiUrl,
311
- `/sessions/${this.session.recording.id}/complete`,
312
- {},
313
- this.session.token,
314
- );
315
- this.emit("ready");
316
- return file;
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;