@furious.luke/argus-js 0.5.4 → 0.5.5

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/README.md CHANGED
@@ -158,7 +158,10 @@ const publisher = new Publisher({
158
158
  void audio.play();
159
159
  },
160
160
  onAssistantText({ utteranceId, text }) {
161
- renderCaption(utteranceId, text);
161
+ appendCaption(utteranceId, text); // APPEND to the bubble keyed by utteranceId
162
+ },
163
+ onAssistantTextFinished({ utteranceId }) {
164
+ finalizeCaption(utteranceId); // this utterance's caption is complete
162
165
  },
163
166
  onUserTextResult(result) {
164
167
  console.log(result.messageId, result.accepted);
@@ -175,16 +178,26 @@ publisher.sendUserText(crypto.randomUUID(), "Stop and explain that again");
175
178
  attached but silent between utterances. `sendUserText` is admitted only while
176
179
  the customer server owns a live control-token notify subscription.
177
180
 
181
+ One assistant reply arrives as **many** `onAssistantText` chunks paced to the
182
+ audio, all sharing the **same `utteranceId`**. Group by that id and **append**
183
+ each chunk to the one caption element — creating a new element per callback is
184
+ what scatters a single reply across the UI. Chunks travel a reliable, ordered
185
+ data channel, so appending in arrival order reconstructs the text (there is no
186
+ sequence number). Treat `onAssistantTextFinished` (same `utteranceId`, emitted on
187
+ that same ordered channel after the last chunk) as the completion boundary —
188
+ not the `utterance_finished` lifecycle event, which can overtake the last chunk.
189
+
178
190
  ### `new Publisher(options)`
179
191
 
180
192
  | Option | Type | Description |
181
193
  | --- | --- | --- |
182
- | `gatewayURLs` | `string[]` | **Required.** The `gateway_urls` from the join response. All are raced simultaneously; the first to return `ready` wins. |
194
+ | `gatewayURLs` | `string[]` | **Required.** The `gateway_urls` from the join response. All are opened at once; the region whose acknowledgement (`accepted`) returns first is selected on network path, and only that region places the stream. The rest are held as standbys to fail over to. |
183
195
  | `token` | `string` | **Required.** The short-lived join token from the join response. |
184
196
  | `iceServers` | `RTCIceServer[]` | Optional extra ICE servers (e.g. your own STUN). TURN is supplied automatically by the winning gateway. |
185
197
  | `iceTransportPolicy` | `RTCIceTransportPolicy` | Passed to the underlying `RTCPeerConnection`. Defaults to `"all"`. Set `"relay"` to force media through TURN only (verifies the relay path end to end). |
186
198
  | `turnTransportPolicy` | `"all" \| "udp" \| "tls"` | Restricts gateway TURN URLs. Defaults to `"all"`; use `"tls"` with relay-only ICE to verify TURN over TLS. Startup fails if the required transport was not advertised. |
187
- | `gatewayHandshakeTimeoutMs` | `number` | Overall deadline for the initial gateway race and `accepted` `ready` handshake. Unaccepted sockets are replaced after 3 seconds so a blackholed TCP flow cannot consume the full deadline. Defaults to 20 seconds. |
199
+ | `gatewayHandshakeTimeoutMs` | `number` | Overall deadline for the whole gateway race selection, placement, and any failovers. Unaccepted sockets are replaced after 3 seconds so a blackholed TCP flow cannot consume the full deadline. Defaults to 20 seconds. |
200
+ | `gatewayFailoverTimeoutMs` | `number` | How long the selected region has to return `ready` before the publisher fails over to the next-fastest standby. A socket that closes or errors fails over immediately regardless; this is the backstop for a region that goes silent. Kept generous so a slow-but-good region is not abandoned for one that is merely closer to the control plane. Defaults to 8 seconds, capped at 20. |
188
201
  | `peerConnectionTimeoutMs` | `number` | Deadline after the initial offer for WebRTC to reach `connected`. Defaults to 30 seconds. |
189
202
  | `signalingReconnectTimeoutMs` | `number` | How long to retry a dropped signaling socket against the selected regional gateway. Defaults to 20 seconds. |
190
203
  | `preferredVideoCodecs` | `string[]` | Preferred video codecs, most-preferred first, as RTP MIME types (e.g. `"video/VP9"`, `"video/H264"`). Each published video track offers these ahead of the rest, so the browser sends the first one the media server also accepts. Defaults to `["video/VP9"]`. Pass `[]` to leave the browser's native order untouched. Codecs the browser lacks (or `setCodecPreferences` support, e.g. older Safari) are ignored — negotiation always falls back cleanly. |
@@ -219,15 +232,17 @@ the customer server owns a live control-token notify subscription.
219
232
  | `onRecoveryStateChange(event)` | Argus detected stalled media and the publisher started, escalated, completed, or failed automatic recovery. |
220
233
  | `onRecoveryRequired(event)` | Automatic recovery could not restore media, or capture ended and the host must ask the user for a new screen share. |
221
234
  | `onSpeechTrack(track, streams)` | The inbound `speech` track arrived after `enableSpeech()` — attach it to an `<audio>` element to play text-to-speech. |
222
- | `onAssistantText({ utteranceId, text })` | Assistant text arrived; it is paced with synthesized speech when speech is enabled and delivered immediately in text-only mode. |
235
+ | `onAssistantText({ utteranceId, text })` | One caption chunk arrived; paced with synthesized speech when speech is enabled, immediate in text-only mode. An utterance emits many chunks sharing one `utteranceId` — append them to the bubble keyed by that id rather than rendering each separately. |
236
+ | `onAssistantTextFinished({ utteranceId })` | The utterance's caption stream is complete. Emitted on the same ordered channel after its last `onAssistantText` chunk; finalize the visible caption here rather than on `utterance_finished`. |
223
237
  | `onUserTextResult({ messageId, accepted, reason })` | The server accepted or rejected a `sendUserText` message. |
224
238
  | `onError(error)` | A fatal error occurred (signaling error, WebRTC connection failure/timeout, or signaling resume timed out). |
225
239
 
226
240
  ## How `start()` works
227
241
 
228
- 1. **Gateway race.** Every URL in `gatewayURLs` is opened at once with the token in the query string. The first to complete the two-phase handshake (`accepted` `proceed` `ready`) wins; the rest are closed. This picks the lowest-latency region without a separate probe.
229
- 2. **TURN + read token.** The winning gateway's `ready` message carries per-session TURN credentials (merged into the ICE configuration) and the read token exposed as `frameReadToken`.
230
- 3. **WebRTC.** A peer connection and the text data channel are created. Any
242
+ 1. **Gateway race.** Every URL in `gatewayURLs` is opened at once with the token in the query string. Selection and placement are two separate steps: the region whose `accepted` returns *first* is selected — purely on network path, without waiting on any placement work — and the browser sends `proceed` to that one only, holding the rest open as standbys. This picks the lowest-latency region without a separate probe, and keeps a region's distance to the control plane out of the choice.
243
+ 2. **Placement, with failover.** The selected region does its placement work and returns `ready`. If its socket closes or errors, the browser fails over to the next-fastest standby immediately; if it just goes silent, it fails over after `gatewayFailoverTimeoutMs`. A region that answers `unavailable` (transiently unable to serve) is retried after a backoff rather than counted out. A region that reports the stream is already bound elsewhere sends a `placement_redirect`, and the browser reconnects to the region that holds it — so a mistimed failover self-heals.
244
+ 3. **TURN + read token.** The selected gateway's `ready` message carries per-session TURN credentials (merged into the ICE configuration) and the read token exposed as `frameReadToken`.
245
+ 4. **WebRTC.** A peer connection and the text data channel are created. Any
231
246
  initial media track is added and labelled, then an offer is sent. Remote ICE
232
247
  candidates that arrive before the SDP answer are buffered and flushed once
233
248
  the answer is applied. Locally gathered candidates remain queued until their
package/dist/index.cjs CHANGED
@@ -98,6 +98,12 @@ var defaultSignalingReconnectTimeoutMs = 2e4;
98
98
  var defaultGatewayHandshakeTimeoutMs = 2e4;
99
99
  var defaultPeerConnectionTimeoutMs = 3e4;
100
100
  var initialGatewayAttemptTimeoutMs = 3e3;
101
+ var defaultGatewayFailoverTimeoutMs = 8e3;
102
+ var maxGatewayFailoverTimeoutMs = 2e4;
103
+ var defaultGatewayRetryBackoffMs = 3e3;
104
+ var minGatewayRetryBackoffMs = 250;
105
+ var maxGatewayRetryBackoffMs = 5e3;
106
+ var maxPlacementRedirects = 2;
101
107
  var signalingResumeAttemptTimeoutMs = 3e3;
102
108
  var signalingResumeMaxBackoffMs = 3e3;
103
109
  var senderRestartPauseMs = 100;
@@ -551,6 +557,16 @@ var Publisher = class {
551
557
  // -------------------------------------------------------------------------
552
558
  // Private helpers
553
559
  // -------------------------------------------------------------------------
560
+ // raceGateways opens every candidate gateway at once, then decides in two
561
+ // separate moments. SELECTION: the first socket to deliver `accepted` (a cheap,
562
+ // control-plane-free acknowledgement) is chosen on network path; the browser
563
+ // sends `proceed` on that one only and keeps the rest as standbys. PLACEMENT:
564
+ // the selected region does its control-plane work and returns `ready`. If the
565
+ // selection dies (socket close/error → immediately) or stalls past the failover
566
+ // deadline (a hung-but-open socket), the browser abandons it — closing the
567
+ // socket cancels that region's placement server-side — and selects the
568
+ // next-fastest standby. A `placement_redirect` points the browser at the region
569
+ // that already holds the stream so a mistimed failover self-heals.
554
570
  raceGateways(signal) {
555
571
  return new Promise((resolve, reject) => {
556
572
  const { gatewayURLs, token } = this.opts;
@@ -560,43 +576,149 @@ var Publisher = class {
560
576
  }
561
577
  const sockets = [];
562
578
  const attemptTimers = /* @__PURE__ */ new Map();
579
+ const reopenTimers = /* @__PURE__ */ new Set();
580
+ const standbys = [];
581
+ let selected = null;
582
+ let failoverTimer = null;
583
+ let redirects = 0;
563
584
  let settled = false;
564
585
  let timeoutTimer = null;
586
+ const failoverMs = Math.min(
587
+ maxGatewayFailoverTimeoutMs,
588
+ Math.max(0, this.opts.gatewayFailoverTimeoutMs ?? defaultGatewayFailoverTimeoutMs)
589
+ );
565
590
  const clearTimeoutTimer = () => {
566
591
  if (timeoutTimer !== null) clearTimeout(timeoutTimer);
567
592
  timeoutTimer = null;
568
593
  };
594
+ const clearFailoverTimer = () => {
595
+ if (failoverTimer !== null) clearTimeout(failoverTimer);
596
+ failoverTimer = null;
597
+ };
598
+ const clearReopenTimers = () => {
599
+ for (const timer of reopenTimers) clearTimeout(timer);
600
+ reopenTimers.clear();
601
+ };
569
602
  const clearAttemptTimer = (socket) => {
570
603
  const timer = attemptTimers.get(socket);
571
604
  if (timer !== void 0) clearTimeout(timer);
572
605
  attemptTimers.delete(socket);
573
606
  };
607
+ const detach = (socket) => {
608
+ clearAttemptTimer(socket);
609
+ socket.onmessage = null;
610
+ socket.onerror = null;
611
+ socket.onclose = null;
612
+ };
613
+ const dropStandby = (socket) => {
614
+ const i = standbys.indexOf(socket);
615
+ if (i !== -1) standbys.splice(i, 1);
616
+ };
574
617
  const closeAll = (except) => {
575
618
  for (const s of sockets) {
576
- clearAttemptTimer(s);
577
619
  if (s !== except) {
578
- s.onmessage = null;
579
- s.onerror = null;
580
- s.onclose = null;
620
+ detach(s);
581
621
  s.close();
582
622
  }
583
623
  }
584
624
  };
585
- const checkAllFailed = () => {
586
- if (settled) return;
625
+ const win = (ws, readyInfo, gatewayURL) => {
626
+ settled = true;
627
+ clearTimeoutTimer();
628
+ clearFailoverTimer();
629
+ clearReopenTimers();
630
+ signal.removeEventListener("abort", abort);
631
+ closeAll(ws);
632
+ resolve({ ws, readyInfo, gatewayURL });
633
+ };
634
+ const fail = (err) => {
635
+ settled = true;
636
+ clearTimeoutTimer();
637
+ clearFailoverTimer();
638
+ clearReopenTimers();
639
+ signal.removeEventListener("abort", abort);
640
+ closeAll();
641
+ reject(err);
642
+ };
643
+ const checkExhausted = () => {
644
+ if (settled || selected !== null || standbys.length > 0 || reopenTimers.size > 0) return;
587
645
  if (sockets.every((s) => s.readyState === WebSocket.CLOSED || s.readyState === WebSocket.CLOSING)) {
588
- settled = true;
589
- clearTimeoutTimer();
590
- signal.removeEventListener("abort", abort);
591
- reject(new Error("all gateways failed to connect"));
646
+ fail(new Error("all gateways failed to connect"));
592
647
  }
593
648
  };
594
- const abort = () => {
649
+ const select = (ws) => {
650
+ selected = ws;
651
+ dropStandby(ws);
652
+ try {
653
+ ws.send(JSON.stringify({ type: "proceed" }));
654
+ } catch {
655
+ socketDown(ws);
656
+ return;
657
+ }
658
+ clearFailoverTimer();
659
+ failoverTimer = setTimeout(() => failover(ws), failoverMs);
660
+ };
661
+ const failover = (deadSocket) => {
662
+ if (settled || deadSocket !== selected) return;
663
+ clearFailoverTimer();
664
+ detach(deadSocket);
665
+ deadSocket.close();
666
+ selected = null;
667
+ const next = standbys.shift();
668
+ if (next) {
669
+ select(next);
670
+ } else {
671
+ checkExhausted();
672
+ }
673
+ };
674
+ const redirect = (gatewayURL) => {
595
675
  if (settled) return;
596
- settled = true;
597
- clearTimeoutTimer();
676
+ if (redirects >= maxPlacementRedirects) {
677
+ fail(new Error("too many placement redirects"));
678
+ return;
679
+ }
680
+ redirects++;
681
+ clearFailoverTimer();
598
682
  closeAll();
599
- reject(new PublisherStoppedError("publisher stopped"));
683
+ standbys.length = 0;
684
+ selected = null;
685
+ try {
686
+ openGateway(gatewayURL);
687
+ } catch (err) {
688
+ fail(err instanceof Error ? err : new Error(String(err)));
689
+ }
690
+ };
691
+ const retryUnavailable = (ws, gatewayURL, retryAfterMs) => {
692
+ detach(ws);
693
+ ws.close();
694
+ const delay = Math.min(
695
+ maxGatewayRetryBackoffMs,
696
+ Math.max(minGatewayRetryBackoffMs, retryAfterMs ?? defaultGatewayRetryBackoffMs)
697
+ );
698
+ const timer = setTimeout(() => {
699
+ reopenTimers.delete(timer);
700
+ if (settled) return;
701
+ try {
702
+ openGateway(gatewayURL);
703
+ } catch (err) {
704
+ fail(err instanceof Error ? err : new Error(String(err)));
705
+ }
706
+ }, delay);
707
+ reopenTimers.add(timer);
708
+ };
709
+ const socketDown = (ws) => {
710
+ if (settled) return;
711
+ clearAttemptTimer(ws);
712
+ dropStandby(ws);
713
+ if (ws === selected) {
714
+ failover(ws);
715
+ } else {
716
+ checkExhausted();
717
+ }
718
+ };
719
+ const abort = () => {
720
+ if (settled) return;
721
+ fail(new PublisherStoppedError("publisher stopped"));
600
722
  };
601
723
  if (signal.aborted) {
602
724
  abort();
@@ -609,10 +731,7 @@ var Publisher = class {
609
731
  );
610
732
  timeoutTimer = setTimeout(() => {
611
733
  if (settled) return;
612
- settled = true;
613
- signal.removeEventListener("abort", abort);
614
- closeAll();
615
- reject(new Error(`gateway handshake timed out after ${timeoutMs}ms`));
734
+ fail(new Error(`gateway handshake timed out after ${timeoutMs}ms`));
616
735
  }, timeoutMs);
617
736
  const openGateway = (gatewayURL) => {
618
737
  if (settled) return;
@@ -624,58 +743,51 @@ var Publisher = class {
624
743
  const attemptTimer = setTimeout(() => {
625
744
  attemptTimers.delete(ws);
626
745
  if (settled || accepted) return;
627
- ws.onmessage = null;
628
- ws.onerror = null;
629
- ws.onclose = null;
746
+ detach(ws);
630
747
  ws.close();
631
748
  try {
632
749
  openGateway(gatewayURL);
633
750
  } catch (err) {
634
- settled = true;
635
- clearTimeoutTimer();
636
- signal.removeEventListener("abort", abort);
637
- closeAll();
638
- reject(err);
751
+ fail(err instanceof Error ? err : new Error(String(err)));
639
752
  }
640
753
  }, initialGatewayAttemptTimeoutMs);
641
754
  attemptTimers.set(ws, attemptTimer);
642
755
  ws.onmessage = (ev) => {
643
756
  if (settled) return;
757
+ let msg;
644
758
  try {
645
- const msg = JSON.parse(ev.data);
646
- if (!accepted && msg.type === "accepted") {
647
- accepted = true;
648
- clearAttemptTimer(ws);
649
- ws.send(JSON.stringify({ type: "proceed" }));
650
- } else if (accepted && msg.type === "ready") {
651
- settled = true;
652
- clearTimeoutTimer();
653
- signal.removeEventListener("abort", abort);
654
- closeAll(ws);
655
- resolve({ ws, readyInfo: msg, gatewayURL });
656
- }
759
+ msg = JSON.parse(ev.data);
657
760
  } catch {
761
+ return;
762
+ }
763
+ if (!accepted) {
764
+ if (msg.type === "unavailable") {
765
+ retryUnavailable(ws, gatewayURL, msg.retry_after_ms);
766
+ return;
767
+ }
768
+ if (msg.type !== "accepted") return;
769
+ accepted = true;
770
+ clearAttemptTimer(ws);
771
+ if (selected === null) select(ws);
772
+ else standbys.push(ws);
773
+ return;
774
+ }
775
+ if (ws !== selected) return;
776
+ if (msg.type === "ready") {
777
+ win(ws, msg, gatewayURL);
778
+ } else if (msg.type === "placement_redirect" && msg.gateway_url) {
779
+ redirect(msg.gateway_url);
658
780
  }
659
781
  };
660
- ws.onerror = () => {
661
- clearAttemptTimer(ws);
662
- checkAllFailed();
663
- };
664
- ws.onclose = () => {
665
- clearAttemptTimer(ws);
666
- checkAllFailed();
667
- };
782
+ ws.onerror = () => socketDown(ws);
783
+ ws.onclose = () => socketDown(ws);
668
784
  };
669
785
  try {
670
786
  for (const gatewayURL of gatewayURLs) {
671
787
  openGateway(gatewayURL);
672
788
  }
673
789
  } catch (err) {
674
- settled = true;
675
- clearTimeoutTimer();
676
- signal.removeEventListener("abort", abort);
677
- closeAll();
678
- reject(err);
790
+ fail(err instanceof Error ? err : new Error(String(err)));
679
791
  }
680
792
  });
681
793
  }