@blotoutio/providers-evo-search-sdk 1.64.2 → 1.65.0

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/core.js CHANGED
@@ -635,6 +635,95 @@ var ProvidersEvoSearchSdk = (function () {
635
635
  notifySubscribers(registry.subscribers, 'notifyReady');
636
636
  };
637
637
 
638
+ // Resilience wrapper for /search requests. A transient network failure or a
639
+ // hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
640
+ // a rejection), retry transient failures with backoff, and never retry a
641
+ // caller-superseded (aborted) request. Shared by the widget and the headless
642
+ // hook. See callers: search-widget.ts (performAutocomplete) and
643
+ // hooks/use-evo-search.ts.
644
+ const DEFAULTS = {
645
+ timeoutMs: 7000,
646
+ retries: 2,
647
+ backoffMs: [400, 1200],
648
+ };
649
+ // Deterministic HTTP errors (4xx other than 429) will never succeed on
650
+ // retry — only retry timeouts, network errors, 429, and 5xx. Errors without
651
+ // an attached `status` (network/timeout failures) are always retryable.
652
+ const isRetryable = (error) => {
653
+ const status = error === null || error === void 0 ? void 0 : error.status;
654
+ return status == null || status === 429 || status >= 500;
655
+ };
656
+ const delay = (ms, signal) => new Promise((resolve, reject) => {
657
+ if (signal.aborted)
658
+ return reject(new DOMException('Aborted', 'AbortError'));
659
+ const t = setTimeout(resolve, ms);
660
+ signal.addEventListener('abort', () => {
661
+ clearTimeout(t);
662
+ reject(new DOMException('Aborted', 'AbortError'));
663
+ }, { once: true });
664
+ });
665
+ /**
666
+ * Run `attempt` with a timeout, retrying transient failures. `callerSignal`
667
+ * supersedes everything: if it aborts, we stop immediately without retrying
668
+ * (the query changed / the widget cleaned up). A timeout or network error is
669
+ * retried up to `retries` times; anything left over is rethrown.
670
+ */
671
+ async function withSearchResilience(attempt, callerSignal, options = {}) {
672
+ var _a, _b;
673
+ const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
674
+ let lastError;
675
+ for (let i = 0; i <= retries; i++) {
676
+ if (callerSignal.aborted)
677
+ throw new DOMException('Aborted', 'AbortError');
678
+ const local = new AbortController();
679
+ const onCallerAbort = () => local.abort();
680
+ callerSignal.addEventListener('abort', onCallerAbort, { once: true });
681
+ // Race the attempt against an explicit timeout that REJECTS. Aborting the
682
+ // signal alone isn't enough — an attempt that ignores its signal would
683
+ // otherwise hang forever and never retry. Reject with a TimeoutError (not
684
+ // an AbortError) so callers surface it instead of treating it as a
685
+ // caller-cancellation.
686
+ let timer;
687
+ const timeout = new Promise((_, reject) => {
688
+ timer = setTimeout(() => {
689
+ local.abort(); // still cancel the in-flight request to free the socket
690
+ reject(new DOMException('Search request timed out', 'TimeoutError'));
691
+ }, timeoutMs);
692
+ });
693
+ try {
694
+ return await Promise.race([attempt(local.signal), timeout]);
695
+ }
696
+ catch (error) {
697
+ lastError = error;
698
+ // The caller superseded this request — do not retry, propagate the abort.
699
+ if (callerSignal.aborted)
700
+ throw error;
701
+ // Otherwise it was a timeout or a transient network error: retry if budget remains.
702
+ if (i < retries && isRetryable(error)) {
703
+ const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
704
+ // Rejects with an AbortError if the caller aborts mid-backoff —
705
+ // propagate that cancellation instead of the transient `error` above.
706
+ await delay(wait, callerSignal);
707
+ continue;
708
+ }
709
+ throw error;
710
+ }
711
+ finally {
712
+ clearTimeout(timer);
713
+ callerSignal.removeEventListener('abort', onCallerAbort);
714
+ }
715
+ }
716
+ throw lastError;
717
+ }
718
+
719
+ // Client-side deadlines for the Ask SSE path. `/search` already gets these via
720
+ // withSearchResilience; Ask is the streaming counterpart. The request timeout
721
+ // matches the resilience wrapper's per-attempt budget; the idle timeout catches
722
+ // a stream that stalls mid-flight without a terminal frame and resets on every
723
+ // chunk. Both surface a TimeoutError (not an AbortError) so submitAsk routes
724
+ // them to the search fallback instead of treating them as caller-cancellation.
725
+ const ASK_REQUEST_TIMEOUT_MS = 7000;
726
+ const ASK_SSE_IDLE_TIMEOUT_MS = 10000;
638
727
  // Always defer to what the storefront itself is rendering — same source
639
728
  // shop-gpt reads from. Two widgets on the same page would otherwise drift:
640
729
  // shop-gpt picks Shopify, evo-search would pick its own override and show
@@ -662,15 +751,28 @@ var ProvidersEvoSearchSdk = (function () {
662
751
  if (!sendTag) {
663
752
  throw new Error('sendTag missing');
664
753
  }
754
+ const getChannel = () => { var _a; return (((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless'); };
665
755
  const getHeaders = () => {
666
- var _a;
667
756
  const headers = new Headers({ EdgeTagUserId: userId });
668
757
  if (sessionId) {
669
758
  headers.set('EdgeTagSessionId', sessionId);
670
759
  }
671
- headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
760
+ headers.set('X-EvoSearch-Channel', getChannel());
672
761
  return headers;
673
762
  };
763
+ // Identity for the public read endpoints travels as query params instead of
764
+ // headers so the GET stays a CORS-simple request — no OPTIONS preflight,
765
+ // one connection attempt per keystroke instead of two (a hung transport
766
+ // path then wedges half as often, and withSearchResilience's retry isn't
767
+ // preceded by a second preflight). /user/* keeps the header form: the
768
+ // worker's cookie-HMAC auth binds to it, and those routes preflight anyway.
769
+ const setIdentityParams = (url) => {
770
+ url.searchParams.set('uid', userId);
771
+ if (sessionId) {
772
+ url.searchParams.set('sid', sessionId);
773
+ }
774
+ url.searchParams.set('channel', getChannel());
775
+ };
674
776
  // Parsing `baseURL` once at API construction avoids re-parsing the host on
675
777
  // every keystroke when `getURL` runs inside the autocomplete hot path.
676
778
  const baseOrigin = new URL(baseURL).origin;
@@ -693,10 +795,14 @@ var ProvidersEvoSearchSdk = (function () {
693
795
  for (const [key, value] of Object.entries(params !== null && params !== void 0 ? params : {})) {
694
796
  url.searchParams.set(key, value);
695
797
  }
798
+ const credentialed = needsCredentials(path);
799
+ if (!credentialed) {
800
+ setIdentityParams(url);
801
+ }
696
802
  const response = await fetchImpl(url, {
697
803
  method: 'GET',
698
- headers: getHeaders(),
699
- credentials: needsCredentials(path) ? 'include' : 'omit',
804
+ ...(credentialed ? { headers: getHeaders() } : {}),
805
+ credentials: credentialed ? 'include' : 'omit',
700
806
  signal,
701
807
  });
702
808
  if (!response.ok) {
@@ -716,17 +822,38 @@ var ProvidersEvoSearchSdk = (function () {
716
822
  logger.info('EvoSearch API: Fetching ask config');
717
823
  return getJSON('/config', 'Config', undefined, signal);
718
824
  };
719
- // Fire-and-forget isolate warmer: tells the worker to prime its search
720
- // index off the request path so the first keystroke doesn't pay the cold
721
- // prime. Errors are irrelevant — autocomplete just falls back to priming
722
- // inline like before.
825
+ // Isolate warmer doubling as connection establisher: primes the worker's
826
+ // search index off the request path AND opens the h2/h3 connection before
827
+ // the first real keystroke needs it. Runs under the resilience wrapper so a
828
+ // black-holed connection attempt (hung QUIC/IPv6 path) is aborted and
829
+ // retried — the retry is what lets the browser fall back to a working
830
+ // transport while the shopper is still reaching for the search box. Any
831
+ // HTTP response counts as success: even an error status proves the
832
+ // connection is established. Never rejects; concurrent triggers (open,
833
+ // focus, first keystroke, keep-warm tick) coalesce into one attempt.
834
+ let warmInFlight = null;
835
+ let warmAbort = null;
723
836
  const warm = () => {
724
- fetchImpl(getURL('/warm'), {
725
- method: 'GET',
726
- headers: getHeaders(),
727
- credentials: 'omit',
728
- }).catch(() => undefined);
837
+ if (warmInFlight) {
838
+ return warmInFlight;
839
+ }
840
+ const url = getURL('/warm');
841
+ setIdentityParams(url);
842
+ warmAbort = new AbortController();
843
+ warmInFlight = withSearchResilience((signal) => fetchImpl(url, { method: 'GET', credentials: 'omit', signal }), warmAbort.signal)
844
+ .then(() => undefined)
845
+ .catch(() => undefined)
846
+ .finally(() => {
847
+ warmInFlight = null;
848
+ warmAbort = null;
849
+ });
850
+ return warmInFlight;
729
851
  };
852
+ // Closing the widget must kill the chain: a black-holed attempt would
853
+ // otherwise keep re-establishing connections for the wrapper's full budget
854
+ // (~23s) after the overlay is gone — exactly the churn warm() exists to
855
+ // avoid. The next warm() mints a fresh controller.
856
+ const cancelWarm = () => warmAbort === null || warmAbort === void 0 ? void 0 : warmAbort.abort();
730
857
  const recents = (signal) => {
731
858
  logger.info('EvoSearch API: Fetching recent searches');
732
859
  return getJSON('/user/recents', 'Recents', undefined, signal);
@@ -767,50 +894,107 @@ var ProvidersEvoSearchSdk = (function () {
767
894
  // reader, the for-await loop drops out, and the iterator returns.
768
895
  async function* askStream(query, signal) {
769
896
  const url = getURL('/ask');
770
- const response = await fetchImpl(url, {
771
- method: 'POST',
772
- headers: (() => {
773
- const h = getHeaders();
774
- h.set('Content-Type', 'application/json');
775
- return h;
776
- })(),
777
- body: JSON.stringify({ query }),
778
- credentials: 'omit',
779
- signal,
780
- });
781
- if (!response.ok || !response.body) {
782
- throw new Error(`Ask failed - ${response.status}`);
897
+ // One controller drives teardown for the whole stream: the caller's signal,
898
+ // the initial-request deadline, and the SSE idle timeout all abort through
899
+ // it, so a stall closes the fetch instead of hanging. `timedOut` lets us
900
+ // rethrow a TimeoutError rather than the AbortError the abort would raise.
901
+ const controller = new AbortController();
902
+ const onCallerAbort = () => controller.abort();
903
+ signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', onCallerAbort, { once: true });
904
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
905
+ controller.abort();
783
906
  }
784
- const reader = response.body
785
- .pipeThrough(new TextDecoderStream())
786
- .getReader();
787
- let buffer = '';
907
+ let timedOut = false;
908
+ const initialTimer = setTimeout(() => {
909
+ timedOut = true;
910
+ controller.abort();
911
+ }, ASK_REQUEST_TIMEOUT_MS);
912
+ // Outer try guarantees the caller-signal listener is removed on EVERY exit
913
+ // path. It's `{ once: true }`, so a caller abort auto-removes it — but the
914
+ // initial-timeout, network-error, and non-ok paths never fire the abort
915
+ // event, so without this they'd leak a listener on a long-lived shared
916
+ // signal (one per Ask query).
788
917
  try {
789
- while (true) {
790
- const { done, value } = await reader.read();
791
- if (done) {
792
- break;
918
+ let response;
919
+ try {
920
+ response = await fetchImpl(url, {
921
+ method: 'POST',
922
+ headers: (() => {
923
+ const h = getHeaders();
924
+ h.set('Content-Type', 'application/json');
925
+ return h;
926
+ })(),
927
+ body: JSON.stringify({ query }),
928
+ credentials: 'omit',
929
+ signal: controller.signal,
930
+ });
931
+ }
932
+ catch (error) {
933
+ if (timedOut) {
934
+ throw new DOMException('Ask request timed out', 'TimeoutError');
793
935
  }
794
- buffer += value;
795
- let boundary = buffer.indexOf('\n\n');
796
- while (boundary !== -1) {
797
- const frame = buffer.slice(0, boundary);
798
- buffer = buffer.slice(boundary + 2);
799
- const event = parseSseFrame(frame);
800
- if (event) {
801
- yield event;
936
+ throw error;
937
+ }
938
+ finally {
939
+ // The idle timeout takes over once the response resolves.
940
+ clearTimeout(initialTimer);
941
+ }
942
+ if (!response.ok || !response.body) {
943
+ throw new Error(`Ask failed - ${response.status}`);
944
+ }
945
+ const reader = response.body
946
+ .pipeThrough(new TextDecoderStream())
947
+ .getReader();
948
+ let buffer = '';
949
+ try {
950
+ while (true) {
951
+ // Idle timeout, reset every chunk: a stream that stalls mid-flight
952
+ // (no terminal frame) rejects here instead of freezing the skeleton.
953
+ let idleTimer;
954
+ const idle = new Promise((_, reject) => {
955
+ idleTimer = setTimeout(() => {
956
+ timedOut = true;
957
+ controller.abort();
958
+ reject(new DOMException('Ask stream stalled', 'TimeoutError'));
959
+ }, ASK_SSE_IDLE_TIMEOUT_MS);
960
+ });
961
+ let result;
962
+ try {
963
+ result = await Promise.race([reader.read(), idle]);
964
+ }
965
+ finally {
966
+ clearTimeout(idleTimer);
967
+ }
968
+ const { done, value } = result;
969
+ if (done) {
970
+ break;
971
+ }
972
+ buffer += value;
973
+ let boundary = buffer.indexOf('\n\n');
974
+ while (boundary !== -1) {
975
+ const frame = buffer.slice(0, boundary);
976
+ buffer = buffer.slice(boundary + 2);
977
+ const event = parseSseFrame(frame);
978
+ if (event) {
979
+ yield event;
980
+ }
981
+ boundary = buffer.indexOf('\n\n');
802
982
  }
803
- boundary = buffer.indexOf('\n\n');
983
+ }
984
+ }
985
+ finally {
986
+ try {
987
+ // cancel (not releaseLock) — safe with an outstanding read and tears
988
+ // down the upstream fetch on a caller-abort or idle timeout.
989
+ await reader.cancel();
990
+ }
991
+ catch {
992
+ // ignore — already closed
804
993
  }
805
994
  }
806
995
  }
807
996
  finally {
808
- try {
809
- reader.releaseLock();
810
- }
811
- catch {
812
- // ignore
813
- }
997
+ signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onCallerAbort);
814
998
  }
815
999
  }
816
1000
  const sendEvent = (action, currency, actionData, clickData, beacon) => {
@@ -862,6 +1046,7 @@ var ProvidersEvoSearchSdk = (function () {
862
1046
  trending,
863
1047
  config,
864
1048
  warm,
1049
+ cancelWarm,
865
1050
  user: {
866
1051
  recents,
867
1052
  deleteRecent,