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