@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/dist/index.d.cts CHANGED
@@ -102,6 +102,12 @@ type SignalMessage = {
102
102
  turn_username?: string;
103
103
  turn_credential?: string;
104
104
  read_token?: string;
105
+ } | {
106
+ type: "placement_redirect";
107
+ gateway_url: string;
108
+ } | {
109
+ type: "unavailable";
110
+ retry_after_ms?: number;
105
111
  };
106
112
  /**
107
113
  * Callbacks emitted by the Publisher during its lifecycle.
@@ -165,7 +171,12 @@ interface GatewayReadyInfo {
165
171
  * Options for publishing a media stream.
166
172
  */
167
173
  interface PublisherOptions {
168
- /** Gateway WebSocket URLs returned by POST /api/streams `gateway_urls`. All are raced simultaneously. */
174
+ /**
175
+ * Gateway WebSocket URLs returned by POST /api/streams `gateway_urls`. All are
176
+ * opened at once; the region whose `accepted` returns first is selected on
177
+ * network path, and only that region places the stream. The rest are held as
178
+ * standbys the publisher fails over to.
179
+ */
169
180
  gatewayURLs: string[];
170
181
  /** The short-lived join token from POST /api/streams. */
171
182
  token: string;
@@ -187,11 +198,26 @@ interface PublisherOptions {
187
198
  */
188
199
  turnTransportPolicy?: TurnTransportPolicy;
189
200
  /**
190
- * Overall deadline for the initial accepted/proceed/ready gateway race.
191
- * Unaccepted sockets are replaced after three seconds within this deadline.
192
- * Defaults to 20 seconds.
201
+ * Overall deadline for the initial accepted/proceed/ready gateway race,
202
+ * spanning selection, placement, and any failovers. Sockets that have not sent
203
+ * `accepted` are replaced after three seconds within this deadline. Defaults to
204
+ * 20 seconds.
193
205
  */
194
206
  gatewayHandshakeTimeoutMs?: number;
207
+ /**
208
+ * How long the selected region has to return `ready` before the publisher
209
+ * abandons it and fails over to the next-fastest acknowledgement. This is a
210
+ * backstop for a region whose socket stays open but silent (a hung placement);
211
+ * a socket that closes or errors fails over immediately regardless. It is
212
+ * deliberately generous — placement includes the selected region's own
213
+ * control-plane round-trips, and a short deadline would abandon exactly the
214
+ * user-close-but-control-plane-distant regions this race is meant to prefer.
215
+ * Defaults to 8 seconds and is capped at 20 seconds: the gateway reaps a
216
+ * standby socket that has been accepted but not yet told to proceed, so the
217
+ * failover window must stay below that deadline or standbys would disappear
218
+ * before the browser fails over to them.
219
+ */
220
+ gatewayFailoverTimeoutMs?: number;
195
221
  /** Deadline after the initial offer for WebRTC to reach connected. Defaults to 30 seconds. */
196
222
  peerConnectionTimeoutMs?: number;
197
223
  /**
package/dist/index.d.ts CHANGED
@@ -102,6 +102,12 @@ type SignalMessage = {
102
102
  turn_username?: string;
103
103
  turn_credential?: string;
104
104
  read_token?: string;
105
+ } | {
106
+ type: "placement_redirect";
107
+ gateway_url: string;
108
+ } | {
109
+ type: "unavailable";
110
+ retry_after_ms?: number;
105
111
  };
106
112
  /**
107
113
  * Callbacks emitted by the Publisher during its lifecycle.
@@ -165,7 +171,12 @@ interface GatewayReadyInfo {
165
171
  * Options for publishing a media stream.
166
172
  */
167
173
  interface PublisherOptions {
168
- /** Gateway WebSocket URLs returned by POST /api/streams `gateway_urls`. All are raced simultaneously. */
174
+ /**
175
+ * Gateway WebSocket URLs returned by POST /api/streams `gateway_urls`. All are
176
+ * opened at once; the region whose `accepted` returns first is selected on
177
+ * network path, and only that region places the stream. The rest are held as
178
+ * standbys the publisher fails over to.
179
+ */
169
180
  gatewayURLs: string[];
170
181
  /** The short-lived join token from POST /api/streams. */
171
182
  token: string;
@@ -187,11 +198,26 @@ interface PublisherOptions {
187
198
  */
188
199
  turnTransportPolicy?: TurnTransportPolicy;
189
200
  /**
190
- * Overall deadline for the initial accepted/proceed/ready gateway race.
191
- * Unaccepted sockets are replaced after three seconds within this deadline.
192
- * Defaults to 20 seconds.
201
+ * Overall deadline for the initial accepted/proceed/ready gateway race,
202
+ * spanning selection, placement, and any failovers. Sockets that have not sent
203
+ * `accepted` are replaced after three seconds within this deadline. Defaults to
204
+ * 20 seconds.
193
205
  */
194
206
  gatewayHandshakeTimeoutMs?: number;
207
+ /**
208
+ * How long the selected region has to return `ready` before the publisher
209
+ * abandons it and fails over to the next-fastest acknowledgement. This is a
210
+ * backstop for a region whose socket stays open but silent (a hung placement);
211
+ * a socket that closes or errors fails over immediately regardless. It is
212
+ * deliberately generous — placement includes the selected region's own
213
+ * control-plane round-trips, and a short deadline would abandon exactly the
214
+ * user-close-but-control-plane-distant regions this race is meant to prefer.
215
+ * Defaults to 8 seconds and is capped at 20 seconds: the gateway reaps a
216
+ * standby socket that has been accepted but not yet told to proceed, so the
217
+ * failover window must stay below that deadline or standbys would disappear
218
+ * before the browser fails over to them.
219
+ */
220
+ gatewayFailoverTimeoutMs?: number;
195
221
  /** Deadline after the initial offer for WebRTC to reach connected. Defaults to 30 seconds. */
196
222
  peerConnectionTimeoutMs?: number;
197
223
  /**
package/dist/index.js CHANGED
@@ -69,6 +69,12 @@ var defaultSignalingReconnectTimeoutMs = 2e4;
69
69
  var defaultGatewayHandshakeTimeoutMs = 2e4;
70
70
  var defaultPeerConnectionTimeoutMs = 3e4;
71
71
  var initialGatewayAttemptTimeoutMs = 3e3;
72
+ var defaultGatewayFailoverTimeoutMs = 8e3;
73
+ var maxGatewayFailoverTimeoutMs = 2e4;
74
+ var defaultGatewayRetryBackoffMs = 3e3;
75
+ var minGatewayRetryBackoffMs = 250;
76
+ var maxGatewayRetryBackoffMs = 5e3;
77
+ var maxPlacementRedirects = 2;
72
78
  var signalingResumeAttemptTimeoutMs = 3e3;
73
79
  var signalingResumeMaxBackoffMs = 3e3;
74
80
  var senderRestartPauseMs = 100;
@@ -522,6 +528,16 @@ var Publisher = class {
522
528
  // -------------------------------------------------------------------------
523
529
  // Private helpers
524
530
  // -------------------------------------------------------------------------
531
+ // raceGateways opens every candidate gateway at once, then decides in two
532
+ // separate moments. SELECTION: the first socket to deliver `accepted` (a cheap,
533
+ // control-plane-free acknowledgement) is chosen on network path; the browser
534
+ // sends `proceed` on that one only and keeps the rest as standbys. PLACEMENT:
535
+ // the selected region does its control-plane work and returns `ready`. If the
536
+ // selection dies (socket close/error → immediately) or stalls past the failover
537
+ // deadline (a hung-but-open socket), the browser abandons it — closing the
538
+ // socket cancels that region's placement server-side — and selects the
539
+ // next-fastest standby. A `placement_redirect` points the browser at the region
540
+ // that already holds the stream so a mistimed failover self-heals.
525
541
  raceGateways(signal) {
526
542
  return new Promise((resolve, reject) => {
527
543
  const { gatewayURLs, token } = this.opts;
@@ -531,43 +547,149 @@ var Publisher = class {
531
547
  }
532
548
  const sockets = [];
533
549
  const attemptTimers = /* @__PURE__ */ new Map();
550
+ const reopenTimers = /* @__PURE__ */ new Set();
551
+ const standbys = [];
552
+ let selected = null;
553
+ let failoverTimer = null;
554
+ let redirects = 0;
534
555
  let settled = false;
535
556
  let timeoutTimer = null;
557
+ const failoverMs = Math.min(
558
+ maxGatewayFailoverTimeoutMs,
559
+ Math.max(0, this.opts.gatewayFailoverTimeoutMs ?? defaultGatewayFailoverTimeoutMs)
560
+ );
536
561
  const clearTimeoutTimer = () => {
537
562
  if (timeoutTimer !== null) clearTimeout(timeoutTimer);
538
563
  timeoutTimer = null;
539
564
  };
565
+ const clearFailoverTimer = () => {
566
+ if (failoverTimer !== null) clearTimeout(failoverTimer);
567
+ failoverTimer = null;
568
+ };
569
+ const clearReopenTimers = () => {
570
+ for (const timer of reopenTimers) clearTimeout(timer);
571
+ reopenTimers.clear();
572
+ };
540
573
  const clearAttemptTimer = (socket) => {
541
574
  const timer = attemptTimers.get(socket);
542
575
  if (timer !== void 0) clearTimeout(timer);
543
576
  attemptTimers.delete(socket);
544
577
  };
578
+ const detach = (socket) => {
579
+ clearAttemptTimer(socket);
580
+ socket.onmessage = null;
581
+ socket.onerror = null;
582
+ socket.onclose = null;
583
+ };
584
+ const dropStandby = (socket) => {
585
+ const i = standbys.indexOf(socket);
586
+ if (i !== -1) standbys.splice(i, 1);
587
+ };
545
588
  const closeAll = (except) => {
546
589
  for (const s of sockets) {
547
- clearAttemptTimer(s);
548
590
  if (s !== except) {
549
- s.onmessage = null;
550
- s.onerror = null;
551
- s.onclose = null;
591
+ detach(s);
552
592
  s.close();
553
593
  }
554
594
  }
555
595
  };
556
- const checkAllFailed = () => {
557
- if (settled) return;
596
+ const win = (ws, readyInfo, gatewayURL) => {
597
+ settled = true;
598
+ clearTimeoutTimer();
599
+ clearFailoverTimer();
600
+ clearReopenTimers();
601
+ signal.removeEventListener("abort", abort);
602
+ closeAll(ws);
603
+ resolve({ ws, readyInfo, gatewayURL });
604
+ };
605
+ const fail = (err) => {
606
+ settled = true;
607
+ clearTimeoutTimer();
608
+ clearFailoverTimer();
609
+ clearReopenTimers();
610
+ signal.removeEventListener("abort", abort);
611
+ closeAll();
612
+ reject(err);
613
+ };
614
+ const checkExhausted = () => {
615
+ if (settled || selected !== null || standbys.length > 0 || reopenTimers.size > 0) return;
558
616
  if (sockets.every((s) => s.readyState === WebSocket.CLOSED || s.readyState === WebSocket.CLOSING)) {
559
- settled = true;
560
- clearTimeoutTimer();
561
- signal.removeEventListener("abort", abort);
562
- reject(new Error("all gateways failed to connect"));
617
+ fail(new Error("all gateways failed to connect"));
563
618
  }
564
619
  };
565
- const abort = () => {
620
+ const select = (ws) => {
621
+ selected = ws;
622
+ dropStandby(ws);
623
+ try {
624
+ ws.send(JSON.stringify({ type: "proceed" }));
625
+ } catch {
626
+ socketDown(ws);
627
+ return;
628
+ }
629
+ clearFailoverTimer();
630
+ failoverTimer = setTimeout(() => failover(ws), failoverMs);
631
+ };
632
+ const failover = (deadSocket) => {
633
+ if (settled || deadSocket !== selected) return;
634
+ clearFailoverTimer();
635
+ detach(deadSocket);
636
+ deadSocket.close();
637
+ selected = null;
638
+ const next = standbys.shift();
639
+ if (next) {
640
+ select(next);
641
+ } else {
642
+ checkExhausted();
643
+ }
644
+ };
645
+ const redirect = (gatewayURL) => {
566
646
  if (settled) return;
567
- settled = true;
568
- clearTimeoutTimer();
647
+ if (redirects >= maxPlacementRedirects) {
648
+ fail(new Error("too many placement redirects"));
649
+ return;
650
+ }
651
+ redirects++;
652
+ clearFailoverTimer();
569
653
  closeAll();
570
- reject(new PublisherStoppedError("publisher stopped"));
654
+ standbys.length = 0;
655
+ selected = null;
656
+ try {
657
+ openGateway(gatewayURL);
658
+ } catch (err) {
659
+ fail(err instanceof Error ? err : new Error(String(err)));
660
+ }
661
+ };
662
+ const retryUnavailable = (ws, gatewayURL, retryAfterMs) => {
663
+ detach(ws);
664
+ ws.close();
665
+ const delay = Math.min(
666
+ maxGatewayRetryBackoffMs,
667
+ Math.max(minGatewayRetryBackoffMs, retryAfterMs ?? defaultGatewayRetryBackoffMs)
668
+ );
669
+ const timer = setTimeout(() => {
670
+ reopenTimers.delete(timer);
671
+ if (settled) return;
672
+ try {
673
+ openGateway(gatewayURL);
674
+ } catch (err) {
675
+ fail(err instanceof Error ? err : new Error(String(err)));
676
+ }
677
+ }, delay);
678
+ reopenTimers.add(timer);
679
+ };
680
+ const socketDown = (ws) => {
681
+ if (settled) return;
682
+ clearAttemptTimer(ws);
683
+ dropStandby(ws);
684
+ if (ws === selected) {
685
+ failover(ws);
686
+ } else {
687
+ checkExhausted();
688
+ }
689
+ };
690
+ const abort = () => {
691
+ if (settled) return;
692
+ fail(new PublisherStoppedError("publisher stopped"));
571
693
  };
572
694
  if (signal.aborted) {
573
695
  abort();
@@ -580,10 +702,7 @@ var Publisher = class {
580
702
  );
581
703
  timeoutTimer = setTimeout(() => {
582
704
  if (settled) return;
583
- settled = true;
584
- signal.removeEventListener("abort", abort);
585
- closeAll();
586
- reject(new Error(`gateway handshake timed out after ${timeoutMs}ms`));
705
+ fail(new Error(`gateway handshake timed out after ${timeoutMs}ms`));
587
706
  }, timeoutMs);
588
707
  const openGateway = (gatewayURL) => {
589
708
  if (settled) return;
@@ -595,58 +714,51 @@ var Publisher = class {
595
714
  const attemptTimer = setTimeout(() => {
596
715
  attemptTimers.delete(ws);
597
716
  if (settled || accepted) return;
598
- ws.onmessage = null;
599
- ws.onerror = null;
600
- ws.onclose = null;
717
+ detach(ws);
601
718
  ws.close();
602
719
  try {
603
720
  openGateway(gatewayURL);
604
721
  } catch (err) {
605
- settled = true;
606
- clearTimeoutTimer();
607
- signal.removeEventListener("abort", abort);
608
- closeAll();
609
- reject(err);
722
+ fail(err instanceof Error ? err : new Error(String(err)));
610
723
  }
611
724
  }, initialGatewayAttemptTimeoutMs);
612
725
  attemptTimers.set(ws, attemptTimer);
613
726
  ws.onmessage = (ev) => {
614
727
  if (settled) return;
728
+ let msg;
615
729
  try {
616
- const msg = JSON.parse(ev.data);
617
- if (!accepted && msg.type === "accepted") {
618
- accepted = true;
619
- clearAttemptTimer(ws);
620
- ws.send(JSON.stringify({ type: "proceed" }));
621
- } else if (accepted && msg.type === "ready") {
622
- settled = true;
623
- clearTimeoutTimer();
624
- signal.removeEventListener("abort", abort);
625
- closeAll(ws);
626
- resolve({ ws, readyInfo: msg, gatewayURL });
627
- }
730
+ msg = JSON.parse(ev.data);
628
731
  } catch {
732
+ return;
733
+ }
734
+ if (!accepted) {
735
+ if (msg.type === "unavailable") {
736
+ retryUnavailable(ws, gatewayURL, msg.retry_after_ms);
737
+ return;
738
+ }
739
+ if (msg.type !== "accepted") return;
740
+ accepted = true;
741
+ clearAttemptTimer(ws);
742
+ if (selected === null) select(ws);
743
+ else standbys.push(ws);
744
+ return;
745
+ }
746
+ if (ws !== selected) return;
747
+ if (msg.type === "ready") {
748
+ win(ws, msg, gatewayURL);
749
+ } else if (msg.type === "placement_redirect" && msg.gateway_url) {
750
+ redirect(msg.gateway_url);
629
751
  }
630
752
  };
631
- ws.onerror = () => {
632
- clearAttemptTimer(ws);
633
- checkAllFailed();
634
- };
635
- ws.onclose = () => {
636
- clearAttemptTimer(ws);
637
- checkAllFailed();
638
- };
753
+ ws.onerror = () => socketDown(ws);
754
+ ws.onclose = () => socketDown(ws);
639
755
  };
640
756
  try {
641
757
  for (const gatewayURL of gatewayURLs) {
642
758
  openGateway(gatewayURL);
643
759
  }
644
760
  } catch (err) {
645
- settled = true;
646
- clearTimeoutTimer();
647
- signal.removeEventListener("abort", abort);
648
- closeAll();
649
- reject(err);
761
+ fail(err instanceof Error ? err : new Error(String(err)));
650
762
  }
651
763
  });
652
764
  }