@blotoutio/providers-evo-search-sdk 1.65.0 → 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/index.js CHANGED
@@ -838,24 +838,48 @@ var ProvidersEvoSearchSdk = (function () {
838
838
  const status = error === null || error === void 0 ? void 0 : error.status;
839
839
  return status == null || status === 429 || status >= 500;
840
840
  };
841
- const delay = (ms, signal) => new Promise((resolve, reject) => {
842
- if (signal.aborted)
843
- return reject(new DOMException('Aborted', 'AbortError'));
844
- const t = setTimeout(resolve, ms);
845
- signal.addEventListener('abort', () => {
846
- clearTimeout(t);
847
- reject(new DOMException('Aborted', 'AbortError'));
848
- }, { once: true });
849
- });
841
+ // `navigator.onLine` is only informative when false; wait for the network rather
842
+ // than spending retries on a route that doesn't exist.
843
+ const isOffline = () => typeof navigator != 'undefined' && navigator.onLine === false;
844
+ // Waits `ms`, or until the network returns, whichever is first.
845
+ const delay = (ms, signal) => {
846
+ // Whichever of timer/online/abort settles first tears down the other two.
847
+ let cleanup = () => undefined;
848
+ const waiting = new Promise((resolve, reject) => {
849
+ var _a;
850
+ if (signal.aborted)
851
+ return reject(new DOMException('Aborted', 'AbortError'));
852
+ const timer = setTimeout(resolve, ms);
853
+ const onOnline = () => resolve();
854
+ const onAbort = () => reject(new DOMException('Aborted', 'AbortError'));
855
+ (_a = globalThis.addEventListener) === null || _a === void 0 ? void 0 : _a.call(globalThis, 'online', onOnline);
856
+ signal.addEventListener('abort', onAbort, { once: true });
857
+ cleanup = () => {
858
+ var _a;
859
+ clearTimeout(timer);
860
+ (_a = globalThis.removeEventListener) === null || _a === void 0 ? void 0 : _a.call(globalThis, 'online', onOnline);
861
+ signal.removeEventListener('abort', onAbort);
862
+ };
863
+ });
864
+ return waiting.finally(() => cleanup());
865
+ };
850
866
  /**
851
867
  * Run `attempt` with a timeout, retrying transient failures. `callerSignal`
852
868
  * supersedes everything: if it aborts, we stop immediately without retrying
853
869
  * (the query changed / the widget cleaned up). A timeout or network error is
854
870
  * retried up to `retries` times; anything left over is rethrown.
871
+ *
872
+ * Every phase draws from one shared deadline — the nominal schedule of all
873
+ * attempts plus all backoffs — so waiting out an offline route borrows from the
874
+ * budget instead of widening it, and the whole call still ends within it.
855
875
  */
856
876
  async function withSearchResilience(attempt, callerSignal, options = {}) {
857
877
  var _a, _b;
858
878
  const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
879
+ const deadline = Date.now() +
880
+ (retries + 1) * timeoutMs +
881
+ backoffMs.reduce((sum, ms) => sum + ms, 0);
882
+ const remaining = () => deadline - Date.now();
859
883
  let lastError;
860
884
  for (let i = 0; i <= retries; i++) {
861
885
  if (callerSignal.aborted)
@@ -873,7 +897,7 @@ var ProvidersEvoSearchSdk = (function () {
873
897
  timer = setTimeout(() => {
874
898
  local.abort(); // still cancel the in-flight request to free the socket
875
899
  reject(new DOMException('Search request timed out', 'TimeoutError'));
876
- }, timeoutMs);
900
+ }, Math.min(timeoutMs, remaining()));
877
901
  });
878
902
  try {
879
903
  return await Promise.race([attempt(local.signal), timeout]);
@@ -885,7 +909,11 @@ var ProvidersEvoSearchSdk = (function () {
885
909
  throw error;
886
910
  // Otherwise it was a timeout or a transient network error: retry if budget remains.
887
911
  if (i < retries && isRetryable(error)) {
888
- const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
912
+ // Offline: hold for the network instead of burning the retry on a route
913
+ // that doesn't exist — but only with time the call already had.
914
+ const wait = isOffline()
915
+ ? remaining()
916
+ : (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
889
917
  // Rejects with an AbortError if the caller aborts mid-backoff —
890
918
  // propagate that cancellation instead of the transient `error` above.
891
919
  await delay(wait, callerSignal);
@@ -901,14 +929,21 @@ var ProvidersEvoSearchSdk = (function () {
901
929
  throw lastError;
902
930
  }
903
931
 
904
- // Client-side deadlines for the Ask SSE path. `/search` already gets these via
905
- // withSearchResilience; Ask is the streaming counterpart. The request timeout
906
- // matches the resilience wrapper's per-attempt budget; the idle timeout catches
907
- // a stream that stalls mid-flight without a terminal frame and resets on every
908
- // chunk. Both surface a TimeoutError (not an AbortError) so submitAsk routes
909
- // them to the search fallback instead of treating them as caller-cancellation.
932
+ // Ask can't reuse withSearchResilience: a half-consumed stream isn't retryable.
933
+ // Idle timeout resets per chunk; both reject with TimeoutError so submitAsk
934
+ // routes them to the search fallback rather than treating them as cancellation.
910
935
  const ASK_REQUEST_TIMEOUT_MS = 7000;
911
936
  const ASK_SSE_IDLE_TIMEOUT_MS = 10000;
937
+ // Keep-warm cadence for `warm()`. Chrome reaps used-idle sockets after ~5
938
+ // minutes and the edge sooner than that under load; a ping well inside those
939
+ // windows keeps the established h2/h3 connection alive so a keystroke after an
940
+ // idle pause multiplexes onto it instead of paying (and possibly wedging on)
941
+ // fresh connection establishment. Lives here rather than in either caller so
942
+ // the widget's open-scoped timer and init's headless timer can't drift apart.
943
+ const KEEP_WARM_INTERVAL_MS = 60000;
944
+ // Stand-in caller signal for `search()` calls that don't pass one. Module-level
945
+ // and never aborted, so it carries no state between calls.
946
+ const NEVER_ABORTED = new AbortController().signal;
912
947
  // Always defer to what the storefront itself is rendering — same source
913
948
  // shop-gpt reads from. Two widgets on the same page would otherwise drift:
914
949
  // shop-gpt picks Shopify, evo-search would pick its own override and show
@@ -1067,9 +1102,13 @@ var ProvidersEvoSearchSdk = (function () {
1067
1102
  };
1068
1103
  const deleteRecent = (value) => sendMutation('/user/recents', 'DeleteRecent', 'DELETE', { value });
1069
1104
  const clearRecents = () => sendMutation('/user/recents', 'ClearRecents', 'DELETE');
1105
+ // Resilience lives here, not at each call site, so no consumer can opt out —
1106
+ // the headless channel previously had no deadline and no retry.
1070
1107
  const search = async (query, limit = 8, signal) => {
1071
1108
  logger.info(`EvoSearch API: Search request - query length: ${query.length}, limit: ${limit}`);
1072
- const data = await getJSON('/search', 'Search', { q: query, limit: String(limit) }, signal);
1109
+ const data = await withSearchResilience((attemptSignal) => getJSON('/search', 'Search', { q: query, limit: String(limit) }, attemptSignal),
1110
+ // Shared stand-in is safe: the wrapper removes its abort listener per attempt.
1111
+ signal !== null && signal !== void 0 ? signal : NEVER_ABORTED);
1073
1112
  logger.info(`EvoSearch API: Search success - ${data.llmSuggestions.length} suggestions, ${data.products.length} products`);
1074
1113
  return data;
1075
1114
  };
@@ -1267,6 +1306,36 @@ var ProvidersEvoSearchSdk = (function () {
1267
1306
  };
1268
1307
 
1269
1308
  const error = (message) => console.error(message);
1309
+ // Headless hosts render their own UI, so nothing else pings /warm for them.
1310
+ let disposeKeepWarm;
1311
+ // A ping on load, on the interval, and on the two events that mean the shopper
1312
+ // is back — tab return, and the network returning after a sleep suspended the
1313
+ // timer. Skipped while hidden: no keystroke is coming and /warm shares its
1314
+ // per-IP bucket with /search.
1315
+ const startHeadlessKeepWarm = (api) => {
1316
+ // Repeated init() would otherwise leave earlier timers and listeners warming
1317
+ // a replaced API forever.
1318
+ disposeKeepWarm === null || disposeKeepWarm === void 0 ? void 0 : disposeKeepWarm();
1319
+ const ping = () => {
1320
+ if (!document.hidden) {
1321
+ void api.warm();
1322
+ }
1323
+ };
1324
+ ping();
1325
+ const timer = setInterval(ping, KEEP_WARM_INTERVAL_MS);
1326
+ document.addEventListener('visibilitychange', ping);
1327
+ window.addEventListener('online', ping);
1328
+ disposeKeepWarm = () => {
1329
+ clearInterval(timer);
1330
+ document.removeEventListener('visibilitychange', ping);
1331
+ window.removeEventListener('online', ping);
1332
+ // Stopping the cadence must also kill an in-flight retry chain: a
1333
+ // black-holed /warm would otherwise keep re-establishing connections for
1334
+ // the wrapper's full budget against a replaced API.
1335
+ api.cancelWarm();
1336
+ disposeKeepWarm = undefined;
1337
+ };
1338
+ };
1270
1339
  const init = (params) => {
1271
1340
  var _a, _b, _c, _d, _e, _f, _g, _h;
1272
1341
  if (typeof window == 'undefined' || typeof document == 'undefined') {
@@ -1275,6 +1344,9 @@ var ProvidersEvoSearchSdk = (function () {
1275
1344
  try {
1276
1345
  (_a = window[registryKey]) !== null && _a !== void 0 ? _a : (window[registryKey] = {});
1277
1346
  logger.info('EvoSearch SDK: Initializing');
1347
+ // A re-init can land on a different channel, or bail out entirely, so drop
1348
+ // any previous cadence before deciding which one this run owns.
1349
+ disposeKeepWarm === null || disposeKeepWarm === void 0 ? void 0 : disposeKeepWarm();
1278
1350
  if (new URLSearchParams(window.location.search).get('evs') === 'preview') {
1279
1351
  try {
1280
1352
  sessionStorage.setItem(previewKeyName, '1');
@@ -1314,8 +1386,12 @@ var ProvidersEvoSearchSdk = (function () {
1314
1386
  options: { destination: params.baseUrl },
1315
1387
  });
1316
1388
  if (!uiImplementation) {
1317
- if ((_f = (_e = params.manifest) === null || _e === void 0 ? void 0 : _e.variables) === null || _f === void 0 ? void 0 : _f.disableDefaultUI) {
1389
+ if (((_f = (_e = params.manifest) === null || _e === void 0 ? void 0 : _e.variables) === null || _f === void 0 ? void 0 : _f.disableDefaultUI) === true) {
1318
1390
  logger.info('EvoSearch SDK: Running in headless mode, no default UI will render');
1391
+ // Deliberately keyed on `disableDefaultUI` rather than bare `headless`:
1392
+ // in the else branch the UI is missing by mistake, so search isn't
1393
+ // usable and there's no connection worth holding open.
1394
+ startHeadlessKeepWarm(evoSearchAPI);
1319
1395
  }
1320
1396
  else {
1321
1397
  error('UI implementation is missing');
@@ -8496,12 +8572,6 @@ var ProvidersEvoSearchSdk = (function () {
8496
8572
  `;
8497
8573
 
8498
8574
  const MIN_QUERY_LENGTH = 3;
8499
- // Keep-warm cadence while the widget is open. Chrome reaps used-idle sockets
8500
- // after ~5 minutes and the edge sooner than that under load; a ping well
8501
- // inside those windows keeps the established h2/h3 connection alive so a
8502
- // keystroke after an idle pause multiplexes onto it instead of paying (and
8503
- // possibly wedging on) fresh connection establishment.
8504
- const KEEP_WARM_INTERVAL_MS = 60000;
8505
8575
  // @state fields whose change should be persisted to the session snapshot.
8506
8576
  const SESSION_PERSIST_KEYS = [
8507
8577
  'searchQuery',
@@ -8591,7 +8661,27 @@ var ProvidersEvoSearchSdk = (function () {
8591
8661
  // autocomplete can't overwrite a fresh search's results.
8592
8662
  this.requestGeneration = 0;
8593
8663
  this.keydownListener = (event) => this.handleKeydown(event);
8594
- this.onlineListener = () => this.handleReconnect();
8664
+ // The keep-warm interval below is skipped while hidden, so the next tick can
8665
+ // be a minute after the shopper returns — re-establish on both signals that
8666
+ // mean they're back: tab return, and the network returning after a sleep
8667
+ // suspended the timer. Bails out while hidden: no keystroke is coming, and
8668
+ // both /warm and the self-heal /search share the per-IP bucket. A tab that
8669
+ // errored while hidden heals on the visibilitychange leg instead.
8670
+ // Warm before re-querying: re-querying first would pay connection setup on
8671
+ // the query itself, which is what wedges. warm() never rejects.
8672
+ this.rewarmListener = async () => {
8673
+ var _a, _b;
8674
+ if (document.hidden) {
8675
+ return;
8676
+ }
8677
+ await ((_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.warm) === null || _b === void 0 ? void 0 : _b.call(_a));
8678
+ // The overlay can close during the await; handleReconnect would otherwise
8679
+ // issue a query and mutate state on a detached widget.
8680
+ if (!this.isConnected) {
8681
+ return;
8682
+ }
8683
+ this.handleReconnect();
8684
+ };
8595
8685
  // Streaming tokens are coalesced into one per-frame state update rather than
8596
8686
  // reassigning `askAnswer` on every token — a ~220-token answer would
8597
8687
  // otherwise trigger ~220 full widget renders.
@@ -8666,7 +8756,8 @@ var ProvidersEvoSearchSdk = (function () {
8666
8756
  this.mobileMediaQuery = window.matchMedia('(max-width: 768px)');
8667
8757
  this.isMobile = this.mobileMediaQuery.matches;
8668
8758
  this.mobileMediaQuery.addEventListener('change', this.mobileMediaListener);
8669
- window.addEventListener('online', this.onlineListener);
8759
+ window.addEventListener('online', this.rewarmListener);
8760
+ document.addEventListener('visibilitychange', this.rewarmListener);
8670
8761
  // Restart keep-warm when the same instance re-attaches (a host re-append
8671
8762
  // moves the subtree and fires disconnected→connected without a fresh
8672
8763
  // firstUpdated). On the very first connect `api` may not be bound yet —
@@ -8716,7 +8807,8 @@ var ProvidersEvoSearchSdk = (function () {
8716
8807
  this.resetAnswerBuffer();
8717
8808
  this.removeEventListener('keydown', this.keydownListener);
8718
8809
  (_e = this.mobileMediaQuery) === null || _e === void 0 ? void 0 : _e.removeEventListener('change', this.mobileMediaListener);
8719
- window.removeEventListener('online', this.onlineListener);
8810
+ window.removeEventListener('online', this.rewarmListener);
8811
+ document.removeEventListener('visibilitychange', this.rewarmListener);
8720
8812
  super.disconnectedCallback();
8721
8813
  }
8722
8814
  willUpdate(changed) {
@@ -9136,7 +9228,9 @@ var ProvidersEvoSearchSdk = (function () {
9136
9228
  return;
9137
9229
  }
9138
9230
  const api = this.api;
9139
- const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
9231
+ // api.search() now owns the per-attempt timeout and retry, so wrapping
9232
+ // here would double-wrap to 9 attempts.
9233
+ const response = await api.search(query, undefined, this.newResultsSignal());
9140
9234
  if (generation !== this.requestGeneration) {
9141
9235
  return;
9142
9236
  }
@@ -9182,7 +9276,9 @@ var ProvidersEvoSearchSdk = (function () {
9182
9276
  return;
9183
9277
  }
9184
9278
  const api = this.api;
9185
- const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
9279
+ // api.search() now owns the per-attempt timeout and retry, so wrapping
9280
+ // here would double-wrap to 9 attempts.
9281
+ const response = await api.search(query, undefined, this.newResultsSignal());
9186
9282
  if (generation !== this.requestGeneration) {
9187
9283
  return;
9188
9284
  }
package/index.mjs CHANGED
@@ -835,24 +835,48 @@ const isRetryable = (error) => {
835
835
  const status = error === null || error === void 0 ? void 0 : error.status;
836
836
  return status == null || status === 429 || status >= 500;
837
837
  };
838
- const delay = (ms, signal) => new Promise((resolve, reject) => {
839
- if (signal.aborted)
840
- return reject(new DOMException('Aborted', 'AbortError'));
841
- const t = setTimeout(resolve, ms);
842
- signal.addEventListener('abort', () => {
843
- clearTimeout(t);
844
- reject(new DOMException('Aborted', 'AbortError'));
845
- }, { once: true });
846
- });
838
+ // `navigator.onLine` is only informative when false; wait for the network rather
839
+ // than spending retries on a route that doesn't exist.
840
+ const isOffline = () => typeof navigator != 'undefined' && navigator.onLine === false;
841
+ // Waits `ms`, or until the network returns, whichever is first.
842
+ const delay = (ms, signal) => {
843
+ // Whichever of timer/online/abort settles first tears down the other two.
844
+ let cleanup = () => undefined;
845
+ const waiting = new Promise((resolve, reject) => {
846
+ var _a;
847
+ if (signal.aborted)
848
+ return reject(new DOMException('Aborted', 'AbortError'));
849
+ const timer = setTimeout(resolve, ms);
850
+ const onOnline = () => resolve();
851
+ const onAbort = () => reject(new DOMException('Aborted', 'AbortError'));
852
+ (_a = globalThis.addEventListener) === null || _a === void 0 ? void 0 : _a.call(globalThis, 'online', onOnline);
853
+ signal.addEventListener('abort', onAbort, { once: true });
854
+ cleanup = () => {
855
+ var _a;
856
+ clearTimeout(timer);
857
+ (_a = globalThis.removeEventListener) === null || _a === void 0 ? void 0 : _a.call(globalThis, 'online', onOnline);
858
+ signal.removeEventListener('abort', onAbort);
859
+ };
860
+ });
861
+ return waiting.finally(() => cleanup());
862
+ };
847
863
  /**
848
864
  * Run `attempt` with a timeout, retrying transient failures. `callerSignal`
849
865
  * supersedes everything: if it aborts, we stop immediately without retrying
850
866
  * (the query changed / the widget cleaned up). A timeout or network error is
851
867
  * retried up to `retries` times; anything left over is rethrown.
868
+ *
869
+ * Every phase draws from one shared deadline — the nominal schedule of all
870
+ * attempts plus all backoffs — so waiting out an offline route borrows from the
871
+ * budget instead of widening it, and the whole call still ends within it.
852
872
  */
853
873
  async function withSearchResilience(attempt, callerSignal, options = {}) {
854
874
  var _a, _b;
855
875
  const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
876
+ const deadline = Date.now() +
877
+ (retries + 1) * timeoutMs +
878
+ backoffMs.reduce((sum, ms) => sum + ms, 0);
879
+ const remaining = () => deadline - Date.now();
856
880
  let lastError;
857
881
  for (let i = 0; i <= retries; i++) {
858
882
  if (callerSignal.aborted)
@@ -870,7 +894,7 @@ async function withSearchResilience(attempt, callerSignal, options = {}) {
870
894
  timer = setTimeout(() => {
871
895
  local.abort(); // still cancel the in-flight request to free the socket
872
896
  reject(new DOMException('Search request timed out', 'TimeoutError'));
873
- }, timeoutMs);
897
+ }, Math.min(timeoutMs, remaining()));
874
898
  });
875
899
  try {
876
900
  return await Promise.race([attempt(local.signal), timeout]);
@@ -882,7 +906,11 @@ async function withSearchResilience(attempt, callerSignal, options = {}) {
882
906
  throw error;
883
907
  // Otherwise it was a timeout or a transient network error: retry if budget remains.
884
908
  if (i < retries && isRetryable(error)) {
885
- const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
909
+ // Offline: hold for the network instead of burning the retry on a route
910
+ // that doesn't exist — but only with time the call already had.
911
+ const wait = isOffline()
912
+ ? remaining()
913
+ : (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
886
914
  // Rejects with an AbortError if the caller aborts mid-backoff —
887
915
  // propagate that cancellation instead of the transient `error` above.
888
916
  await delay(wait, callerSignal);
@@ -898,14 +926,21 @@ async function withSearchResilience(attempt, callerSignal, options = {}) {
898
926
  throw lastError;
899
927
  }
900
928
 
901
- // Client-side deadlines for the Ask SSE path. `/search` already gets these via
902
- // withSearchResilience; Ask is the streaming counterpart. The request timeout
903
- // matches the resilience wrapper's per-attempt budget; the idle timeout catches
904
- // a stream that stalls mid-flight without a terminal frame and resets on every
905
- // chunk. Both surface a TimeoutError (not an AbortError) so submitAsk routes
906
- // them to the search fallback instead of treating them as caller-cancellation.
929
+ // Ask can't reuse withSearchResilience: a half-consumed stream isn't retryable.
930
+ // Idle timeout resets per chunk; both reject with TimeoutError so submitAsk
931
+ // routes them to the search fallback rather than treating them as cancellation.
907
932
  const ASK_REQUEST_TIMEOUT_MS = 7000;
908
933
  const ASK_SSE_IDLE_TIMEOUT_MS = 10000;
934
+ // Keep-warm cadence for `warm()`. Chrome reaps used-idle sockets after ~5
935
+ // minutes and the edge sooner than that under load; a ping well inside those
936
+ // windows keeps the established h2/h3 connection alive so a keystroke after an
937
+ // idle pause multiplexes onto it instead of paying (and possibly wedging on)
938
+ // fresh connection establishment. Lives here rather than in either caller so
939
+ // the widget's open-scoped timer and init's headless timer can't drift apart.
940
+ const KEEP_WARM_INTERVAL_MS = 60000;
941
+ // Stand-in caller signal for `search()` calls that don't pass one. Module-level
942
+ // and never aborted, so it carries no state between calls.
943
+ const NEVER_ABORTED = new AbortController().signal;
909
944
  // Always defer to what the storefront itself is rendering — same source
910
945
  // shop-gpt reads from. Two widgets on the same page would otherwise drift:
911
946
  // shop-gpt picks Shopify, evo-search would pick its own override and show
@@ -1064,9 +1099,13 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
1064
1099
  };
1065
1100
  const deleteRecent = (value) => sendMutation('/user/recents', 'DeleteRecent', 'DELETE', { value });
1066
1101
  const clearRecents = () => sendMutation('/user/recents', 'ClearRecents', 'DELETE');
1102
+ // Resilience lives here, not at each call site, so no consumer can opt out —
1103
+ // the headless channel previously had no deadline and no retry.
1067
1104
  const search = async (query, limit = 8, signal) => {
1068
1105
  logger.info(`EvoSearch API: Search request - query length: ${query.length}, limit: ${limit}`);
1069
- const data = await getJSON('/search', 'Search', { q: query, limit: String(limit) }, signal);
1106
+ const data = await withSearchResilience((attemptSignal) => getJSON('/search', 'Search', { q: query, limit: String(limit) }, attemptSignal),
1107
+ // Shared stand-in is safe: the wrapper removes its abort listener per attempt.
1108
+ signal !== null && signal !== void 0 ? signal : NEVER_ABORTED);
1070
1109
  logger.info(`EvoSearch API: Search success - ${data.llmSuggestions.length} suggestions, ${data.products.length} products`);
1071
1110
  return data;
1072
1111
  };
@@ -1264,6 +1303,36 @@ const parseSseFrame = (frame) => {
1264
1303
  };
1265
1304
 
1266
1305
  const error = (message) => console.error(message);
1306
+ // Headless hosts render their own UI, so nothing else pings /warm for them.
1307
+ let disposeKeepWarm;
1308
+ // A ping on load, on the interval, and on the two events that mean the shopper
1309
+ // is back — tab return, and the network returning after a sleep suspended the
1310
+ // timer. Skipped while hidden: no keystroke is coming and /warm shares its
1311
+ // per-IP bucket with /search.
1312
+ const startHeadlessKeepWarm = (api) => {
1313
+ // Repeated init() would otherwise leave earlier timers and listeners warming
1314
+ // a replaced API forever.
1315
+ disposeKeepWarm === null || disposeKeepWarm === void 0 ? void 0 : disposeKeepWarm();
1316
+ const ping = () => {
1317
+ if (!document.hidden) {
1318
+ void api.warm();
1319
+ }
1320
+ };
1321
+ ping();
1322
+ const timer = setInterval(ping, KEEP_WARM_INTERVAL_MS);
1323
+ document.addEventListener('visibilitychange', ping);
1324
+ window.addEventListener('online', ping);
1325
+ disposeKeepWarm = () => {
1326
+ clearInterval(timer);
1327
+ document.removeEventListener('visibilitychange', ping);
1328
+ window.removeEventListener('online', ping);
1329
+ // Stopping the cadence must also kill an in-flight retry chain: a
1330
+ // black-holed /warm would otherwise keep re-establishing connections for
1331
+ // the wrapper's full budget against a replaced API.
1332
+ api.cancelWarm();
1333
+ disposeKeepWarm = undefined;
1334
+ };
1335
+ };
1267
1336
  const init = (params) => {
1268
1337
  var _a, _b, _c, _d, _e, _f, _g, _h;
1269
1338
  if (typeof window == 'undefined' || typeof document == 'undefined') {
@@ -1272,6 +1341,9 @@ const init = (params) => {
1272
1341
  try {
1273
1342
  (_a = window[registryKey]) !== null && _a !== void 0 ? _a : (window[registryKey] = {});
1274
1343
  logger.info('EvoSearch SDK: Initializing');
1344
+ // A re-init can land on a different channel, or bail out entirely, so drop
1345
+ // any previous cadence before deciding which one this run owns.
1346
+ disposeKeepWarm === null || disposeKeepWarm === void 0 ? void 0 : disposeKeepWarm();
1275
1347
  if (new URLSearchParams(window.location.search).get('evs') === 'preview') {
1276
1348
  try {
1277
1349
  sessionStorage.setItem(previewKeyName, '1');
@@ -1311,8 +1383,12 @@ const init = (params) => {
1311
1383
  options: { destination: params.baseUrl },
1312
1384
  });
1313
1385
  if (!uiImplementation) {
1314
- if ((_f = (_e = params.manifest) === null || _e === void 0 ? void 0 : _e.variables) === null || _f === void 0 ? void 0 : _f.disableDefaultUI) {
1386
+ if (((_f = (_e = params.manifest) === null || _e === void 0 ? void 0 : _e.variables) === null || _f === void 0 ? void 0 : _f.disableDefaultUI) === true) {
1315
1387
  logger.info('EvoSearch SDK: Running in headless mode, no default UI will render');
1388
+ // Deliberately keyed on `disableDefaultUI` rather than bare `headless`:
1389
+ // in the else branch the UI is missing by mistake, so search isn't
1390
+ // usable and there's no connection worth holding open.
1391
+ startHeadlessKeepWarm(evoSearchAPI);
1316
1392
  }
1317
1393
  else {
1318
1394
  error('UI implementation is missing');
@@ -8493,12 +8569,6 @@ const searchWidgetStyles = i$6 `
8493
8569
  `;
8494
8570
 
8495
8571
  const MIN_QUERY_LENGTH = 3;
8496
- // Keep-warm cadence while the widget is open. Chrome reaps used-idle sockets
8497
- // after ~5 minutes and the edge sooner than that under load; a ping well
8498
- // inside those windows keeps the established h2/h3 connection alive so a
8499
- // keystroke after an idle pause multiplexes onto it instead of paying (and
8500
- // possibly wedging on) fresh connection establishment.
8501
- const KEEP_WARM_INTERVAL_MS = 60000;
8502
8572
  // @state fields whose change should be persisted to the session snapshot.
8503
8573
  const SESSION_PERSIST_KEYS = [
8504
8574
  'searchQuery',
@@ -8588,7 +8658,27 @@ class SearchWidget extends ElementWithStylesheet {
8588
8658
  // autocomplete can't overwrite a fresh search's results.
8589
8659
  this.requestGeneration = 0;
8590
8660
  this.keydownListener = (event) => this.handleKeydown(event);
8591
- this.onlineListener = () => this.handleReconnect();
8661
+ // The keep-warm interval below is skipped while hidden, so the next tick can
8662
+ // be a minute after the shopper returns — re-establish on both signals that
8663
+ // mean they're back: tab return, and the network returning after a sleep
8664
+ // suspended the timer. Bails out while hidden: no keystroke is coming, and
8665
+ // both /warm and the self-heal /search share the per-IP bucket. A tab that
8666
+ // errored while hidden heals on the visibilitychange leg instead.
8667
+ // Warm before re-querying: re-querying first would pay connection setup on
8668
+ // the query itself, which is what wedges. warm() never rejects.
8669
+ this.rewarmListener = async () => {
8670
+ var _a, _b;
8671
+ if (document.hidden) {
8672
+ return;
8673
+ }
8674
+ await ((_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.warm) === null || _b === void 0 ? void 0 : _b.call(_a));
8675
+ // The overlay can close during the await; handleReconnect would otherwise
8676
+ // issue a query and mutate state on a detached widget.
8677
+ if (!this.isConnected) {
8678
+ return;
8679
+ }
8680
+ this.handleReconnect();
8681
+ };
8592
8682
  // Streaming tokens are coalesced into one per-frame state update rather than
8593
8683
  // reassigning `askAnswer` on every token — a ~220-token answer would
8594
8684
  // otherwise trigger ~220 full widget renders.
@@ -8663,7 +8753,8 @@ class SearchWidget extends ElementWithStylesheet {
8663
8753
  this.mobileMediaQuery = window.matchMedia('(max-width: 768px)');
8664
8754
  this.isMobile = this.mobileMediaQuery.matches;
8665
8755
  this.mobileMediaQuery.addEventListener('change', this.mobileMediaListener);
8666
- window.addEventListener('online', this.onlineListener);
8756
+ window.addEventListener('online', this.rewarmListener);
8757
+ document.addEventListener('visibilitychange', this.rewarmListener);
8667
8758
  // Restart keep-warm when the same instance re-attaches (a host re-append
8668
8759
  // moves the subtree and fires disconnected→connected without a fresh
8669
8760
  // firstUpdated). On the very first connect `api` may not be bound yet —
@@ -8713,7 +8804,8 @@ class SearchWidget extends ElementWithStylesheet {
8713
8804
  this.resetAnswerBuffer();
8714
8805
  this.removeEventListener('keydown', this.keydownListener);
8715
8806
  (_e = this.mobileMediaQuery) === null || _e === void 0 ? void 0 : _e.removeEventListener('change', this.mobileMediaListener);
8716
- window.removeEventListener('online', this.onlineListener);
8807
+ window.removeEventListener('online', this.rewarmListener);
8808
+ document.removeEventListener('visibilitychange', this.rewarmListener);
8717
8809
  super.disconnectedCallback();
8718
8810
  }
8719
8811
  willUpdate(changed) {
@@ -9133,7 +9225,9 @@ class SearchWidget extends ElementWithStylesheet {
9133
9225
  return;
9134
9226
  }
9135
9227
  const api = this.api;
9136
- const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
9228
+ // api.search() now owns the per-attempt timeout and retry, so wrapping
9229
+ // here would double-wrap to 9 attempts.
9230
+ const response = await api.search(query, undefined, this.newResultsSignal());
9137
9231
  if (generation !== this.requestGeneration) {
9138
9232
  return;
9139
9233
  }
@@ -9179,7 +9273,9 @@ class SearchWidget extends ElementWithStylesheet {
9179
9273
  return;
9180
9274
  }
9181
9275
  const api = this.api;
9182
- const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
9276
+ // api.search() now owns the per-attempt timeout and retry, so wrapping
9277
+ // here would double-wrap to 9 attempts.
9278
+ const response = await api.search(query, undefined, this.newResultsSignal());
9183
9279
  if (generation !== this.requestGeneration) {
9184
9280
  return;
9185
9281
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blotoutio/providers-evo-search-sdk",
3
- "version": "1.65.0",
3
+ "version": "1.65.2",
4
4
  "description": "Evo Search SDK for EdgeTag",
5
5
  "author": "Blotout",
6
6
  "license": "MIT",