@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 +2 -0
- package/core.cjs.js +95 -19
- package/core.js +95 -19
- package/core.mjs +95 -19
- package/hooks.cjs.js +3 -82
- package/hooks.mjs +3 -82
- package/index.cjs.js +126 -30
- package/index.js +126 -30
- package/index.mjs +126 -30
- package/package.json +1 -1
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
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) },
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
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) },
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
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) },
|
|
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');
|