@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 +22 -7
- package/dist/index.cjs +163 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -4
- package/dist/index.d.ts +30 -4
- package/dist/index.js +163 -51
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
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 })` |
|
|
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.
|
|
229
|
-
2. **
|
|
230
|
-
3. **
|
|
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
|
|
579
|
-
s.onerror = null;
|
|
580
|
-
s.onclose = null;
|
|
620
|
+
detach(s);
|
|
581
621
|
s.close();
|
|
582
622
|
}
|
|
583
623
|
}
|
|
584
624
|
};
|
|
585
|
-
const
|
|
586
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
597
|
-
|
|
676
|
+
if (redirects >= maxPlacementRedirects) {
|
|
677
|
+
fail(new Error("too many placement redirects"));
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
redirects++;
|
|
681
|
+
clearFailoverTimer();
|
|
598
682
|
closeAll();
|
|
599
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|