@blotoutio/providers-evo-search-sdk 1.65.1 → 1.65.2

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
@@ -96,6 +96,8 @@ When you want to render search results with your own React components instead of
96
96
 
97
97
  The EdgeTag pixel must still be loaded on your storefront for the SDK to register and the React hook to receive an API.
98
98
 
99
+ `api.search()` retries transient failures and then rejects with a `TimeoutError` after ~23 seconds rather than hanging, so handle the rejected promise. Pass an `AbortSignal` when a newer query can supersede an older one — it is the only way to cancel pending retries, and the React hook already does this for you.
100
+
99
101
  ## /load endpoint cache
100
102
 
101
103
  CDN responses for `/load` cache for ~25 minutes (`max-age=1500`). Toggling `Disable default UI` in the admin form takes up to that long to propagate across edge nodes — bake the delay into release planning.
package/core.cjs.js CHANGED
@@ -652,24 +652,48 @@ const isRetryable = (error) => {
652
652
  const status = error === null || error === void 0 ? void 0 : error.status;
653
653
  return status == null || status === 429 || status >= 500;
654
654
  };
655
- const delay = (ms, signal) => new Promise((resolve, reject) => {
656
- if (signal.aborted)
657
- return reject(new DOMException('Aborted', 'AbortError'));
658
- const t = setTimeout(resolve, ms);
659
- signal.addEventListener('abort', () => {
660
- clearTimeout(t);
661
- reject(new DOMException('Aborted', 'AbortError'));
662
- }, { once: true });
663
- });
655
+ // `navigator.onLine` is only informative when false; wait for the network rather
656
+ // than spending retries on a route that doesn't exist.
657
+ const isOffline = () => typeof navigator != 'undefined' && navigator.onLine === false;
658
+ // Waits `ms`, or until the network returns, whichever is first.
659
+ const delay = (ms, signal) => {
660
+ // Whichever of timer/online/abort settles first tears down the other two.
661
+ let cleanup = () => undefined;
662
+ const waiting = new Promise((resolve, reject) => {
663
+ var _a;
664
+ if (signal.aborted)
665
+ return reject(new DOMException('Aborted', 'AbortError'));
666
+ const timer = setTimeout(resolve, ms);
667
+ const onOnline = () => resolve();
668
+ const onAbort = () => reject(new DOMException('Aborted', 'AbortError'));
669
+ (_a = globalThis.addEventListener) === null || _a === void 0 ? void 0 : _a.call(globalThis, 'online', onOnline);
670
+ signal.addEventListener('abort', onAbort, { once: true });
671
+ cleanup = () => {
672
+ var _a;
673
+ clearTimeout(timer);
674
+ (_a = globalThis.removeEventListener) === null || _a === void 0 ? void 0 : _a.call(globalThis, 'online', onOnline);
675
+ signal.removeEventListener('abort', onAbort);
676
+ };
677
+ });
678
+ return waiting.finally(() => cleanup());
679
+ };
664
680
  /**
665
681
  * Run `attempt` with a timeout, retrying transient failures. `callerSignal`
666
682
  * supersedes everything: if it aborts, we stop immediately without retrying
667
683
  * (the query changed / the widget cleaned up). A timeout or network error is
668
684
  * retried up to `retries` times; anything left over is rethrown.
685
+ *
686
+ * Every phase draws from one shared deadline — the nominal schedule of all
687
+ * attempts plus all backoffs — so waiting out an offline route borrows from the
688
+ * budget instead of widening it, and the whole call still ends within it.
669
689
  */
670
690
  async function withSearchResilience(attempt, callerSignal, options = {}) {
671
691
  var _a, _b;
672
692
  const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
693
+ const deadline = Date.now() +
694
+ (retries + 1) * timeoutMs +
695
+ backoffMs.reduce((sum, ms) => sum + ms, 0);
696
+ const remaining = () => deadline - Date.now();
673
697
  let lastError;
674
698
  for (let i = 0; i <= retries; i++) {
675
699
  if (callerSignal.aborted)
@@ -687,7 +711,7 @@ async function withSearchResilience(attempt, callerSignal, options = {}) {
687
711
  timer = setTimeout(() => {
688
712
  local.abort(); // still cancel the in-flight request to free the socket
689
713
  reject(new DOMException('Search request timed out', 'TimeoutError'));
690
- }, timeoutMs);
714
+ }, Math.min(timeoutMs, remaining()));
691
715
  });
692
716
  try {
693
717
  return await Promise.race([attempt(local.signal), timeout]);
@@ -699,7 +723,11 @@ async function withSearchResilience(attempt, callerSignal, options = {}) {
699
723
  throw error;
700
724
  // Otherwise it was a timeout or a transient network error: retry if budget remains.
701
725
  if (i < retries && isRetryable(error)) {
702
- const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
726
+ // Offline: hold for the network instead of burning the retry on a route
727
+ // that doesn't exist — but only with time the call already had.
728
+ const wait = isOffline()
729
+ ? remaining()
730
+ : (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
703
731
  // Rejects with an AbortError if the caller aborts mid-backoff —
704
732
  // propagate that cancellation instead of the transient `error` above.
705
733
  await delay(wait, callerSignal);
@@ -715,14 +743,21 @@ async function withSearchResilience(attempt, callerSignal, options = {}) {
715
743
  throw lastError;
716
744
  }
717
745
 
718
- // Client-side deadlines for the Ask SSE path. `/search` already gets these via
719
- // withSearchResilience; Ask is the streaming counterpart. The request timeout
720
- // matches the resilience wrapper's per-attempt budget; the idle timeout catches
721
- // a stream that stalls mid-flight without a terminal frame and resets on every
722
- // chunk. Both surface a TimeoutError (not an AbortError) so submitAsk routes
723
- // them to the search fallback instead of treating them as caller-cancellation.
746
+ // Ask can't reuse withSearchResilience: a half-consumed stream isn't retryable.
747
+ // Idle timeout resets per chunk; both reject with TimeoutError so submitAsk
748
+ // routes them to the search fallback rather than treating them as cancellation.
724
749
  const ASK_REQUEST_TIMEOUT_MS = 7000;
725
750
  const ASK_SSE_IDLE_TIMEOUT_MS = 10000;
751
+ // Keep-warm cadence for `warm()`. Chrome reaps used-idle sockets after ~5
752
+ // minutes and the edge sooner than that under load; a ping well inside those
753
+ // windows keeps the established h2/h3 connection alive so a keystroke after an
754
+ // idle pause multiplexes onto it instead of paying (and possibly wedging on)
755
+ // fresh connection establishment. Lives here rather than in either caller so
756
+ // the widget's open-scoped timer and init's headless timer can't drift apart.
757
+ const KEEP_WARM_INTERVAL_MS = 60000;
758
+ // Stand-in caller signal for `search()` calls that don't pass one. Module-level
759
+ // and never aborted, so it carries no state between calls.
760
+ const NEVER_ABORTED = new AbortController().signal;
726
761
  // Always defer to what the storefront itself is rendering — same source
727
762
  // shop-gpt reads from. Two widgets on the same page would otherwise drift:
728
763
  // shop-gpt picks Shopify, evo-search would pick its own override and show
@@ -881,9 +916,13 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
881
916
  };
882
917
  const deleteRecent = (value) => sendMutation('/user/recents', 'DeleteRecent', 'DELETE', { value });
883
918
  const clearRecents = () => sendMutation('/user/recents', 'ClearRecents', 'DELETE');
919
+ // Resilience lives here, not at each call site, so no consumer can opt out —
920
+ // the headless channel previously had no deadline and no retry.
884
921
  const search = async (query, limit = 8, signal) => {
885
922
  logger.info(`EvoSearch API: Search request - query length: ${query.length}, limit: ${limit}`);
886
- const data = await getJSON('/search', 'Search', { q: query, limit: String(limit) }, signal);
923
+ const data = await withSearchResilience((attemptSignal) => getJSON('/search', 'Search', { q: query, limit: String(limit) }, attemptSignal),
924
+ // Shared stand-in is safe: the wrapper removes its abort listener per attempt.
925
+ signal !== null && signal !== void 0 ? signal : NEVER_ABORTED);
887
926
  logger.info(`EvoSearch API: Search success - ${data.llmSuggestions.length} suggestions, ${data.products.length} products`);
888
927
  return data;
889
928
  };
@@ -1081,6 +1120,36 @@ const parseSseFrame = (frame) => {
1081
1120
  };
1082
1121
 
1083
1122
  const error = (message) => console.error(message);
1123
+ // Headless hosts render their own UI, so nothing else pings /warm for them.
1124
+ let disposeKeepWarm;
1125
+ // A ping on load, on the interval, and on the two events that mean the shopper
1126
+ // is back — tab return, and the network returning after a sleep suspended the
1127
+ // timer. Skipped while hidden: no keystroke is coming and /warm shares its
1128
+ // per-IP bucket with /search.
1129
+ const startHeadlessKeepWarm = (api) => {
1130
+ // Repeated init() would otherwise leave earlier timers and listeners warming
1131
+ // a replaced API forever.
1132
+ disposeKeepWarm === null || disposeKeepWarm === void 0 ? void 0 : disposeKeepWarm();
1133
+ const ping = () => {
1134
+ if (!document.hidden) {
1135
+ void api.warm();
1136
+ }
1137
+ };
1138
+ ping();
1139
+ const timer = setInterval(ping, KEEP_WARM_INTERVAL_MS);
1140
+ document.addEventListener('visibilitychange', ping);
1141
+ window.addEventListener('online', ping);
1142
+ disposeKeepWarm = () => {
1143
+ clearInterval(timer);
1144
+ document.removeEventListener('visibilitychange', ping);
1145
+ window.removeEventListener('online', ping);
1146
+ // Stopping the cadence must also kill an in-flight retry chain: a
1147
+ // black-holed /warm would otherwise keep re-establishing connections for
1148
+ // the wrapper's full budget against a replaced API.
1149
+ api.cancelWarm();
1150
+ disposeKeepWarm = undefined;
1151
+ };
1152
+ };
1084
1153
  const init = (params) => {
1085
1154
  var _a, _b, _c, _d, _e, _f, _g, _h;
1086
1155
  if (typeof window == 'undefined' || typeof document == 'undefined') {
@@ -1089,6 +1158,9 @@ const init = (params) => {
1089
1158
  try {
1090
1159
  (_a = window[registryKey]) !== null && _a !== void 0 ? _a : (window[registryKey] = {});
1091
1160
  logger.info('EvoSearch SDK: Initializing');
1161
+ // A re-init can land on a different channel, or bail out entirely, so drop
1162
+ // any previous cadence before deciding which one this run owns.
1163
+ disposeKeepWarm === null || disposeKeepWarm === void 0 ? void 0 : disposeKeepWarm();
1092
1164
  if (new URLSearchParams(window.location.search).get('evs') === 'preview') {
1093
1165
  try {
1094
1166
  sessionStorage.setItem(previewKeyName, '1');
@@ -1128,8 +1200,12 @@ const init = (params) => {
1128
1200
  options: { destination: params.baseUrl },
1129
1201
  });
1130
1202
  if (!uiImplementation) {
1131
- if ((_f = (_e = params.manifest) === null || _e === void 0 ? void 0 : _e.variables) === null || _f === void 0 ? void 0 : _f.disableDefaultUI) {
1203
+ if (((_f = (_e = params.manifest) === null || _e === void 0 ? void 0 : _e.variables) === null || _f === void 0 ? void 0 : _f.disableDefaultUI) === true) {
1132
1204
  logger.info('EvoSearch SDK: Running in headless mode, no default UI will render');
1205
+ // Deliberately keyed on `disableDefaultUI` rather than bare `headless`:
1206
+ // in the else branch the UI is missing by mistake, so search isn't
1207
+ // usable and there's no connection worth holding open.
1208
+ startHeadlessKeepWarm(evoSearchAPI);
1133
1209
  }
1134
1210
  else {
1135
1211
  error('UI implementation is missing');
package/core.js CHANGED
@@ -653,24 +653,48 @@ var ProvidersEvoSearchSdk = (function () {
653
653
  const status = error === null || error === void 0 ? void 0 : error.status;
654
654
  return status == null || status === 429 || status >= 500;
655
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
- });
656
+ // `navigator.onLine` is only informative when false; wait for the network rather
657
+ // than spending retries on a route that doesn't exist.
658
+ const isOffline = () => typeof navigator != 'undefined' && navigator.onLine === false;
659
+ // Waits `ms`, or until the network returns, whichever is first.
660
+ const delay = (ms, signal) => {
661
+ // Whichever of timer/online/abort settles first tears down the other two.
662
+ let cleanup = () => undefined;
663
+ const waiting = new Promise((resolve, reject) => {
664
+ var _a;
665
+ if (signal.aborted)
666
+ return reject(new DOMException('Aborted', 'AbortError'));
667
+ const timer = setTimeout(resolve, ms);
668
+ const onOnline = () => resolve();
669
+ const onAbort = () => reject(new DOMException('Aborted', 'AbortError'));
670
+ (_a = globalThis.addEventListener) === null || _a === void 0 ? void 0 : _a.call(globalThis, 'online', onOnline);
671
+ signal.addEventListener('abort', onAbort, { once: true });
672
+ cleanup = () => {
673
+ var _a;
674
+ clearTimeout(timer);
675
+ (_a = globalThis.removeEventListener) === null || _a === void 0 ? void 0 : _a.call(globalThis, 'online', onOnline);
676
+ signal.removeEventListener('abort', onAbort);
677
+ };
678
+ });
679
+ return waiting.finally(() => cleanup());
680
+ };
665
681
  /**
666
682
  * Run `attempt` with a timeout, retrying transient failures. `callerSignal`
667
683
  * supersedes everything: if it aborts, we stop immediately without retrying
668
684
  * (the query changed / the widget cleaned up). A timeout or network error is
669
685
  * retried up to `retries` times; anything left over is rethrown.
686
+ *
687
+ * Every phase draws from one shared deadline — the nominal schedule of all
688
+ * attempts plus all backoffs — so waiting out an offline route borrows from the
689
+ * budget instead of widening it, and the whole call still ends within it.
670
690
  */
671
691
  async function withSearchResilience(attempt, callerSignal, options = {}) {
672
692
  var _a, _b;
673
693
  const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
694
+ const deadline = Date.now() +
695
+ (retries + 1) * timeoutMs +
696
+ backoffMs.reduce((sum, ms) => sum + ms, 0);
697
+ const remaining = () => deadline - Date.now();
674
698
  let lastError;
675
699
  for (let i = 0; i <= retries; i++) {
676
700
  if (callerSignal.aborted)
@@ -688,7 +712,7 @@ var ProvidersEvoSearchSdk = (function () {
688
712
  timer = setTimeout(() => {
689
713
  local.abort(); // still cancel the in-flight request to free the socket
690
714
  reject(new DOMException('Search request timed out', 'TimeoutError'));
691
- }, timeoutMs);
715
+ }, Math.min(timeoutMs, remaining()));
692
716
  });
693
717
  try {
694
718
  return await Promise.race([attempt(local.signal), timeout]);
@@ -700,7 +724,11 @@ var ProvidersEvoSearchSdk = (function () {
700
724
  throw error;
701
725
  // Otherwise it was a timeout or a transient network error: retry if budget remains.
702
726
  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;
727
+ // Offline: hold for the network instead of burning the retry on a route
728
+ // that doesn't exist — but only with time the call already had.
729
+ const wait = isOffline()
730
+ ? remaining()
731
+ : (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
704
732
  // Rejects with an AbortError if the caller aborts mid-backoff —
705
733
  // propagate that cancellation instead of the transient `error` above.
706
734
  await delay(wait, callerSignal);
@@ -716,14 +744,21 @@ var ProvidersEvoSearchSdk = (function () {
716
744
  throw lastError;
717
745
  }
718
746
 
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.
747
+ // Ask can't reuse withSearchResilience: a half-consumed stream isn't retryable.
748
+ // Idle timeout resets per chunk; both reject with TimeoutError so submitAsk
749
+ // routes them to the search fallback rather than treating them as cancellation.
725
750
  const ASK_REQUEST_TIMEOUT_MS = 7000;
726
751
  const ASK_SSE_IDLE_TIMEOUT_MS = 10000;
752
+ // Keep-warm cadence for `warm()`. Chrome reaps used-idle sockets after ~5
753
+ // minutes and the edge sooner than that under load; a ping well inside those
754
+ // windows keeps the established h2/h3 connection alive so a keystroke after an
755
+ // idle pause multiplexes onto it instead of paying (and possibly wedging on)
756
+ // fresh connection establishment. Lives here rather than in either caller so
757
+ // the widget's open-scoped timer and init's headless timer can't drift apart.
758
+ const KEEP_WARM_INTERVAL_MS = 60000;
759
+ // Stand-in caller signal for `search()` calls that don't pass one. Module-level
760
+ // and never aborted, so it carries no state between calls.
761
+ const NEVER_ABORTED = new AbortController().signal;
727
762
  // Always defer to what the storefront itself is rendering — same source
728
763
  // shop-gpt reads from. Two widgets on the same page would otherwise drift:
729
764
  // shop-gpt picks Shopify, evo-search would pick its own override and show
@@ -882,9 +917,13 @@ var ProvidersEvoSearchSdk = (function () {
882
917
  };
883
918
  const deleteRecent = (value) => sendMutation('/user/recents', 'DeleteRecent', 'DELETE', { value });
884
919
  const clearRecents = () => sendMutation('/user/recents', 'ClearRecents', 'DELETE');
920
+ // Resilience lives here, not at each call site, so no consumer can opt out —
921
+ // the headless channel previously had no deadline and no retry.
885
922
  const search = async (query, limit = 8, signal) => {
886
923
  logger.info(`EvoSearch API: Search request - query length: ${query.length}, limit: ${limit}`);
887
- const data = await getJSON('/search', 'Search', { q: query, limit: String(limit) }, signal);
924
+ const data = await withSearchResilience((attemptSignal) => getJSON('/search', 'Search', { q: query, limit: String(limit) }, attemptSignal),
925
+ // Shared stand-in is safe: the wrapper removes its abort listener per attempt.
926
+ signal !== null && signal !== void 0 ? signal : NEVER_ABORTED);
888
927
  logger.info(`EvoSearch API: Search success - ${data.llmSuggestions.length} suggestions, ${data.products.length} products`);
889
928
  return data;
890
929
  };
@@ -1082,6 +1121,36 @@ var ProvidersEvoSearchSdk = (function () {
1082
1121
  };
1083
1122
 
1084
1123
  const error = (message) => console.error(message);
1124
+ // Headless hosts render their own UI, so nothing else pings /warm for them.
1125
+ let disposeKeepWarm;
1126
+ // A ping on load, on the interval, and on the two events that mean the shopper
1127
+ // is back — tab return, and the network returning after a sleep suspended the
1128
+ // timer. Skipped while hidden: no keystroke is coming and /warm shares its
1129
+ // per-IP bucket with /search.
1130
+ const startHeadlessKeepWarm = (api) => {
1131
+ // Repeated init() would otherwise leave earlier timers and listeners warming
1132
+ // a replaced API forever.
1133
+ disposeKeepWarm === null || disposeKeepWarm === void 0 ? void 0 : disposeKeepWarm();
1134
+ const ping = () => {
1135
+ if (!document.hidden) {
1136
+ void api.warm();
1137
+ }
1138
+ };
1139
+ ping();
1140
+ const timer = setInterval(ping, KEEP_WARM_INTERVAL_MS);
1141
+ document.addEventListener('visibilitychange', ping);
1142
+ window.addEventListener('online', ping);
1143
+ disposeKeepWarm = () => {
1144
+ clearInterval(timer);
1145
+ document.removeEventListener('visibilitychange', ping);
1146
+ window.removeEventListener('online', ping);
1147
+ // Stopping the cadence must also kill an in-flight retry chain: a
1148
+ // black-holed /warm would otherwise keep re-establishing connections for
1149
+ // the wrapper's full budget against a replaced API.
1150
+ api.cancelWarm();
1151
+ disposeKeepWarm = undefined;
1152
+ };
1153
+ };
1085
1154
  const init = (params) => {
1086
1155
  var _a, _b, _c, _d, _e, _f, _g, _h;
1087
1156
  if (typeof window == 'undefined' || typeof document == 'undefined') {
@@ -1090,6 +1159,9 @@ var ProvidersEvoSearchSdk = (function () {
1090
1159
  try {
1091
1160
  (_a = window[registryKey]) !== null && _a !== void 0 ? _a : (window[registryKey] = {});
1092
1161
  logger.info('EvoSearch SDK: Initializing');
1162
+ // A re-init can land on a different channel, or bail out entirely, so drop
1163
+ // any previous cadence before deciding which one this run owns.
1164
+ disposeKeepWarm === null || disposeKeepWarm === void 0 ? void 0 : disposeKeepWarm();
1093
1165
  if (new URLSearchParams(window.location.search).get('evs') === 'preview') {
1094
1166
  try {
1095
1167
  sessionStorage.setItem(previewKeyName, '1');
@@ -1129,8 +1201,12 @@ var ProvidersEvoSearchSdk = (function () {
1129
1201
  options: { destination: params.baseUrl },
1130
1202
  });
1131
1203
  if (!uiImplementation) {
1132
- if ((_f = (_e = params.manifest) === null || _e === void 0 ? void 0 : _e.variables) === null || _f === void 0 ? void 0 : _f.disableDefaultUI) {
1204
+ if (((_f = (_e = params.manifest) === null || _e === void 0 ? void 0 : _e.variables) === null || _f === void 0 ? void 0 : _f.disableDefaultUI) === true) {
1133
1205
  logger.info('EvoSearch SDK: Running in headless mode, no default UI will render');
1206
+ // Deliberately keyed on `disableDefaultUI` rather than bare `headless`:
1207
+ // in the else branch the UI is missing by mistake, so search isn't
1208
+ // usable and there's no connection worth holding open.
1209
+ startHeadlessKeepWarm(evoSearchAPI);
1134
1210
  }
1135
1211
  else {
1136
1212
  error('UI implementation is missing');
package/core.mjs CHANGED
@@ -650,24 +650,48 @@ const isRetryable = (error) => {
650
650
  const status = error === null || error === void 0 ? void 0 : error.status;
651
651
  return status == null || status === 429 || status >= 500;
652
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
- });
653
+ // `navigator.onLine` is only informative when false; wait for the network rather
654
+ // than spending retries on a route that doesn't exist.
655
+ const isOffline = () => typeof navigator != 'undefined' && navigator.onLine === false;
656
+ // Waits `ms`, or until the network returns, whichever is first.
657
+ const delay = (ms, signal) => {
658
+ // Whichever of timer/online/abort settles first tears down the other two.
659
+ let cleanup = () => undefined;
660
+ const waiting = new Promise((resolve, reject) => {
661
+ var _a;
662
+ if (signal.aborted)
663
+ return reject(new DOMException('Aborted', 'AbortError'));
664
+ const timer = setTimeout(resolve, ms);
665
+ const onOnline = () => resolve();
666
+ const onAbort = () => reject(new DOMException('Aborted', 'AbortError'));
667
+ (_a = globalThis.addEventListener) === null || _a === void 0 ? void 0 : _a.call(globalThis, 'online', onOnline);
668
+ signal.addEventListener('abort', onAbort, { once: true });
669
+ cleanup = () => {
670
+ var _a;
671
+ clearTimeout(timer);
672
+ (_a = globalThis.removeEventListener) === null || _a === void 0 ? void 0 : _a.call(globalThis, 'online', onOnline);
673
+ signal.removeEventListener('abort', onAbort);
674
+ };
675
+ });
676
+ return waiting.finally(() => cleanup());
677
+ };
662
678
  /**
663
679
  * Run `attempt` with a timeout, retrying transient failures. `callerSignal`
664
680
  * supersedes everything: if it aborts, we stop immediately without retrying
665
681
  * (the query changed / the widget cleaned up). A timeout or network error is
666
682
  * retried up to `retries` times; anything left over is rethrown.
683
+ *
684
+ * Every phase draws from one shared deadline — the nominal schedule of all
685
+ * attempts plus all backoffs — so waiting out an offline route borrows from the
686
+ * budget instead of widening it, and the whole call still ends within it.
667
687
  */
668
688
  async function withSearchResilience(attempt, callerSignal, options = {}) {
669
689
  var _a, _b;
670
690
  const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
691
+ const deadline = Date.now() +
692
+ (retries + 1) * timeoutMs +
693
+ backoffMs.reduce((sum, ms) => sum + ms, 0);
694
+ const remaining = () => deadline - Date.now();
671
695
  let lastError;
672
696
  for (let i = 0; i <= retries; i++) {
673
697
  if (callerSignal.aborted)
@@ -685,7 +709,7 @@ async function withSearchResilience(attempt, callerSignal, options = {}) {
685
709
  timer = setTimeout(() => {
686
710
  local.abort(); // still cancel the in-flight request to free the socket
687
711
  reject(new DOMException('Search request timed out', 'TimeoutError'));
688
- }, timeoutMs);
712
+ }, Math.min(timeoutMs, remaining()));
689
713
  });
690
714
  try {
691
715
  return await Promise.race([attempt(local.signal), timeout]);
@@ -697,7 +721,11 @@ async function withSearchResilience(attempt, callerSignal, options = {}) {
697
721
  throw error;
698
722
  // Otherwise it was a timeout or a transient network error: retry if budget remains.
699
723
  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;
724
+ // Offline: hold for the network instead of burning the retry on a route
725
+ // that doesn't exist — but only with time the call already had.
726
+ const wait = isOffline()
727
+ ? remaining()
728
+ : (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
701
729
  // Rejects with an AbortError if the caller aborts mid-backoff —
702
730
  // propagate that cancellation instead of the transient `error` above.
703
731
  await delay(wait, callerSignal);
@@ -713,14 +741,21 @@ async function withSearchResilience(attempt, callerSignal, options = {}) {
713
741
  throw lastError;
714
742
  }
715
743
 
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.
744
+ // Ask can't reuse withSearchResilience: a half-consumed stream isn't retryable.
745
+ // Idle timeout resets per chunk; both reject with TimeoutError so submitAsk
746
+ // routes them to the search fallback rather than treating them as cancellation.
722
747
  const ASK_REQUEST_TIMEOUT_MS = 7000;
723
748
  const ASK_SSE_IDLE_TIMEOUT_MS = 10000;
749
+ // Keep-warm cadence for `warm()`. Chrome reaps used-idle sockets after ~5
750
+ // minutes and the edge sooner than that under load; a ping well inside those
751
+ // windows keeps the established h2/h3 connection alive so a keystroke after an
752
+ // idle pause multiplexes onto it instead of paying (and possibly wedging on)
753
+ // fresh connection establishment. Lives here rather than in either caller so
754
+ // the widget's open-scoped timer and init's headless timer can't drift apart.
755
+ const KEEP_WARM_INTERVAL_MS = 60000;
756
+ // Stand-in caller signal for `search()` calls that don't pass one. Module-level
757
+ // and never aborted, so it carries no state between calls.
758
+ const NEVER_ABORTED = new AbortController().signal;
724
759
  // Always defer to what the storefront itself is rendering — same source
725
760
  // shop-gpt reads from. Two widgets on the same page would otherwise drift:
726
761
  // shop-gpt picks Shopify, evo-search would pick its own override and show
@@ -879,9 +914,13 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
879
914
  };
880
915
  const deleteRecent = (value) => sendMutation('/user/recents', 'DeleteRecent', 'DELETE', { value });
881
916
  const clearRecents = () => sendMutation('/user/recents', 'ClearRecents', 'DELETE');
917
+ // Resilience lives here, not at each call site, so no consumer can opt out —
918
+ // the headless channel previously had no deadline and no retry.
882
919
  const search = async (query, limit = 8, signal) => {
883
920
  logger.info(`EvoSearch API: Search request - query length: ${query.length}, limit: ${limit}`);
884
- const data = await getJSON('/search', 'Search', { q: query, limit: String(limit) }, signal);
921
+ const data = await withSearchResilience((attemptSignal) => getJSON('/search', 'Search', { q: query, limit: String(limit) }, attemptSignal),
922
+ // Shared stand-in is safe: the wrapper removes its abort listener per attempt.
923
+ signal !== null && signal !== void 0 ? signal : NEVER_ABORTED);
885
924
  logger.info(`EvoSearch API: Search success - ${data.llmSuggestions.length} suggestions, ${data.products.length} products`);
886
925
  return data;
887
926
  };
@@ -1079,6 +1118,36 @@ const parseSseFrame = (frame) => {
1079
1118
  };
1080
1119
 
1081
1120
  const error = (message) => console.error(message);
1121
+ // Headless hosts render their own UI, so nothing else pings /warm for them.
1122
+ let disposeKeepWarm;
1123
+ // A ping on load, on the interval, and on the two events that mean the shopper
1124
+ // is back — tab return, and the network returning after a sleep suspended the
1125
+ // timer. Skipped while hidden: no keystroke is coming and /warm shares its
1126
+ // per-IP bucket with /search.
1127
+ const startHeadlessKeepWarm = (api) => {
1128
+ // Repeated init() would otherwise leave earlier timers and listeners warming
1129
+ // a replaced API forever.
1130
+ disposeKeepWarm === null || disposeKeepWarm === void 0 ? void 0 : disposeKeepWarm();
1131
+ const ping = () => {
1132
+ if (!document.hidden) {
1133
+ void api.warm();
1134
+ }
1135
+ };
1136
+ ping();
1137
+ const timer = setInterval(ping, KEEP_WARM_INTERVAL_MS);
1138
+ document.addEventListener('visibilitychange', ping);
1139
+ window.addEventListener('online', ping);
1140
+ disposeKeepWarm = () => {
1141
+ clearInterval(timer);
1142
+ document.removeEventListener('visibilitychange', ping);
1143
+ window.removeEventListener('online', ping);
1144
+ // Stopping the cadence must also kill an in-flight retry chain: a
1145
+ // black-holed /warm would otherwise keep re-establishing connections for
1146
+ // the wrapper's full budget against a replaced API.
1147
+ api.cancelWarm();
1148
+ disposeKeepWarm = undefined;
1149
+ };
1150
+ };
1082
1151
  const init = (params) => {
1083
1152
  var _a, _b, _c, _d, _e, _f, _g, _h;
1084
1153
  if (typeof window == 'undefined' || typeof document == 'undefined') {
@@ -1087,6 +1156,9 @@ const init = (params) => {
1087
1156
  try {
1088
1157
  (_a = window[registryKey]) !== null && _a !== void 0 ? _a : (window[registryKey] = {});
1089
1158
  logger.info('EvoSearch SDK: Initializing');
1159
+ // A re-init can land on a different channel, or bail out entirely, so drop
1160
+ // any previous cadence before deciding which one this run owns.
1161
+ disposeKeepWarm === null || disposeKeepWarm === void 0 ? void 0 : disposeKeepWarm();
1090
1162
  if (new URLSearchParams(window.location.search).get('evs') === 'preview') {
1091
1163
  try {
1092
1164
  sessionStorage.setItem(previewKeyName, '1');
@@ -1126,8 +1198,12 @@ const init = (params) => {
1126
1198
  options: { destination: params.baseUrl },
1127
1199
  });
1128
1200
  if (!uiImplementation) {
1129
- if ((_f = (_e = params.manifest) === null || _e === void 0 ? void 0 : _e.variables) === null || _f === void 0 ? void 0 : _f.disableDefaultUI) {
1201
+ if (((_f = (_e = params.manifest) === null || _e === void 0 ? void 0 : _e.variables) === null || _f === void 0 ? void 0 : _f.disableDefaultUI) === true) {
1130
1202
  logger.info('EvoSearch SDK: Running in headless mode, no default UI will render');
1203
+ // Deliberately keyed on `disableDefaultUI` rather than bare `headless`:
1204
+ // in the else branch the UI is missing by mistake, so search isn't
1205
+ // usable and there's no connection worth holding open.
1206
+ startHeadlessKeepWarm(evoSearchAPI);
1131
1207
  }
1132
1208
  else {
1133
1209
  error('UI implementation is missing');