@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/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/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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
905
|
-
//
|
|
906
|
-
//
|
|
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) },
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
902
|
-
//
|
|
903
|
-
//
|
|
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) },
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|