@blotoutio/providers-evo-search-sdk 1.64.1 → 1.64.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 +30 -0
- package/core.cjs.js +5 -1
- package/core.js +5 -1
- package/core.mjs +5 -1
- package/hooks.cjs.js +135 -2
- package/hooks.mjs +136 -3
- package/index.cjs.js +105 -2
- package/index.js +105 -2
- package/index.mjs +105 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,6 +54,36 @@ function HeaderSearch() {
|
|
|
54
54
|
}
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
## Tracking interactions
|
|
58
|
+
|
|
59
|
+
EvoSearch needs three interaction signals to power the dashboard: searches, result clicks, and cart/purchase events. **In a headless integration, only `trackClick` requires code on your side.**
|
|
60
|
+
|
|
61
|
+
### Searches — handled server-side
|
|
62
|
+
|
|
63
|
+
Search volume, top queries, and zero-result queries are measured automatically from the search API. No client code needed.
|
|
64
|
+
|
|
65
|
+
### Result clicks — `trackClick`
|
|
66
|
+
|
|
67
|
+
Call `trackClick(productId)` when a user clicks a result, passing the `id` field from the search result:
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
{
|
|
71
|
+
results.map((p) => (
|
|
72
|
+
<li key={p.id}>
|
|
73
|
+
<button type='button' onClick={() => trackClick(p.id)}>
|
|
74
|
+
{p.title}
|
|
75
|
+
</button>
|
|
76
|
+
</li>
|
|
77
|
+
))
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Add-to-cart / purchase — use your existing EdgeTag tags
|
|
82
|
+
|
|
83
|
+
If you already send EdgeTag `AddToCart` and `Purchase` events via the EdgeTag SDK, you're done — EvoSearch receives them server-side and attributes them to searches automatically.
|
|
84
|
+
|
|
85
|
+
**Important:** The product id in `trackClick` must match the product id in your EdgeTag commerce tags. If your commerce tags use SKUs or variant ids, normalize them to product id on both sides, or search-to-cart attribution will report zero.
|
|
86
|
+
|
|
57
87
|
For full API reference, see the design spec in the Blotout solutions repo or the docs site.
|
|
58
88
|
|
|
59
89
|
## Disabling the default UI (headless mode)
|
package/core.cjs.js
CHANGED
|
@@ -662,10 +662,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
662
662
|
throw new Error('sendTag missing');
|
|
663
663
|
}
|
|
664
664
|
const getHeaders = () => {
|
|
665
|
+
var _a;
|
|
665
666
|
const headers = new Headers({ EdgeTagUserId: userId });
|
|
666
667
|
if (sessionId) {
|
|
667
668
|
headers.set('EdgeTagSessionId', sessionId);
|
|
668
669
|
}
|
|
670
|
+
headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
|
|
669
671
|
return headers;
|
|
670
672
|
};
|
|
671
673
|
// Parsing `baseURL` once at API construction avoids re-parsing the host on
|
|
@@ -699,7 +701,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
699
701
|
if (!response.ok) {
|
|
700
702
|
const errorText = await response.text().catch(() => '');
|
|
701
703
|
logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
|
|
702
|
-
|
|
704
|
+
const error = new Error(`${label} failed - ${response.status}`);
|
|
705
|
+
error.status = response.status;
|
|
706
|
+
throw error;
|
|
703
707
|
}
|
|
704
708
|
return (await response.json());
|
|
705
709
|
};
|
package/core.js
CHANGED
|
@@ -663,10 +663,12 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
663
663
|
throw new Error('sendTag missing');
|
|
664
664
|
}
|
|
665
665
|
const getHeaders = () => {
|
|
666
|
+
var _a;
|
|
666
667
|
const headers = new Headers({ EdgeTagUserId: userId });
|
|
667
668
|
if (sessionId) {
|
|
668
669
|
headers.set('EdgeTagSessionId', sessionId);
|
|
669
670
|
}
|
|
671
|
+
headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
|
|
670
672
|
return headers;
|
|
671
673
|
};
|
|
672
674
|
// Parsing `baseURL` once at API construction avoids re-parsing the host on
|
|
@@ -700,7 +702,9 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
700
702
|
if (!response.ok) {
|
|
701
703
|
const errorText = await response.text().catch(() => '');
|
|
702
704
|
logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
|
|
703
|
-
|
|
705
|
+
const error = new Error(`${label} failed - ${response.status}`);
|
|
706
|
+
error.status = response.status;
|
|
707
|
+
throw error;
|
|
704
708
|
}
|
|
705
709
|
return (await response.json());
|
|
706
710
|
};
|
package/core.mjs
CHANGED
|
@@ -660,10 +660,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
660
660
|
throw new Error('sendTag missing');
|
|
661
661
|
}
|
|
662
662
|
const getHeaders = () => {
|
|
663
|
+
var _a;
|
|
663
664
|
const headers = new Headers({ EdgeTagUserId: userId });
|
|
664
665
|
if (sessionId) {
|
|
665
666
|
headers.set('EdgeTagSessionId', sessionId);
|
|
666
667
|
}
|
|
668
|
+
headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
|
|
667
669
|
return headers;
|
|
668
670
|
};
|
|
669
671
|
// Parsing `baseURL` once at API construction avoids re-parsing the host on
|
|
@@ -697,7 +699,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
697
699
|
if (!response.ok) {
|
|
698
700
|
const errorText = await response.text().catch(() => '');
|
|
699
701
|
logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
|
|
700
|
-
|
|
702
|
+
const error = new Error(`${label} failed - ${response.status}`);
|
|
703
|
+
error.status = response.status;
|
|
704
|
+
throw error;
|
|
701
705
|
}
|
|
702
706
|
return (await response.json());
|
|
703
707
|
};
|
package/hooks.cjs.js
CHANGED
|
@@ -632,6 +632,11 @@ const uiActions = new Set([
|
|
|
632
632
|
new Set([...uiActions]);
|
|
633
633
|
|
|
634
634
|
const DEBOUNCE_MS = 200;
|
|
635
|
+
// How long a typed query must sit unchanged after its results render before
|
|
636
|
+
// the widget counts it as one real search (recents + server-side query
|
|
637
|
+
// metrics). Search fires per keystroke, so anything shorter than a genuine
|
|
638
|
+
// reading pause would count prefixes (`a`, `ap`, `app`) as searches.
|
|
639
|
+
const SEARCH_SETTLE_MS = 3000;
|
|
635
640
|
|
|
636
641
|
// Intentionally duplicated from @blotoutio/providers/evo-search/config to keep the hooks bundle lean — importing from the config barrel pulls the shared/universal geo data into dist/hooks.mjs (~4KB gzipped). Drift between the two values is caught by utils.test.ts.
|
|
637
642
|
const isHttpURL = (value) => {
|
|
@@ -690,6 +695,87 @@ const processAutocompleteResponse = (response) => {
|
|
|
690
695
|
};
|
|
691
696
|
};
|
|
692
697
|
|
|
698
|
+
// Resilience wrapper for /search requests. A transient network failure or a
|
|
699
|
+
// hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
|
|
700
|
+
// a rejection), retry transient failures with backoff, and never retry a
|
|
701
|
+
// caller-superseded (aborted) request. Shared by the widget and the headless
|
|
702
|
+
// hook. See callers: search-widget.ts (performAutocomplete) and
|
|
703
|
+
// hooks/use-evo-search.ts.
|
|
704
|
+
const DEFAULTS = {
|
|
705
|
+
timeoutMs: 7000,
|
|
706
|
+
retries: 2,
|
|
707
|
+
backoffMs: [400, 1200],
|
|
708
|
+
};
|
|
709
|
+
// Deterministic HTTP errors (4xx other than 429) will never succeed on
|
|
710
|
+
// retry — only retry timeouts, network errors, 429, and 5xx. Errors without
|
|
711
|
+
// an attached `status` (network/timeout failures) are always retryable.
|
|
712
|
+
const isRetryable = (error) => {
|
|
713
|
+
const status = error === null || error === void 0 ? void 0 : error.status;
|
|
714
|
+
return status == null || status === 429 || status >= 500;
|
|
715
|
+
};
|
|
716
|
+
const delay = (ms, signal) => new Promise((resolve, reject) => {
|
|
717
|
+
if (signal.aborted)
|
|
718
|
+
return reject(new DOMException('Aborted', 'AbortError'));
|
|
719
|
+
const t = setTimeout(resolve, ms);
|
|
720
|
+
signal.addEventListener('abort', () => {
|
|
721
|
+
clearTimeout(t);
|
|
722
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
723
|
+
}, { once: true });
|
|
724
|
+
});
|
|
725
|
+
/**
|
|
726
|
+
* Run `attempt` with a timeout, retrying transient failures. `callerSignal`
|
|
727
|
+
* supersedes everything: if it aborts, we stop immediately without retrying
|
|
728
|
+
* (the query changed / the widget cleaned up). A timeout or network error is
|
|
729
|
+
* retried up to `retries` times; anything left over is rethrown.
|
|
730
|
+
*/
|
|
731
|
+
async function withSearchResilience(attempt, callerSignal, options = {}) {
|
|
732
|
+
var _a, _b;
|
|
733
|
+
const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
|
|
734
|
+
let lastError;
|
|
735
|
+
for (let i = 0; i <= retries; i++) {
|
|
736
|
+
if (callerSignal.aborted)
|
|
737
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
738
|
+
const local = new AbortController();
|
|
739
|
+
const onCallerAbort = () => local.abort();
|
|
740
|
+
callerSignal.addEventListener('abort', onCallerAbort, { once: true });
|
|
741
|
+
// Race the attempt against an explicit timeout that REJECTS. Aborting the
|
|
742
|
+
// signal alone isn't enough — an attempt that ignores its signal would
|
|
743
|
+
// otherwise hang forever and never retry. Reject with a TimeoutError (not
|
|
744
|
+
// an AbortError) so callers surface it instead of treating it as a
|
|
745
|
+
// caller-cancellation.
|
|
746
|
+
let timer;
|
|
747
|
+
const timeout = new Promise((_, reject) => {
|
|
748
|
+
timer = setTimeout(() => {
|
|
749
|
+
local.abort(); // still cancel the in-flight request to free the socket
|
|
750
|
+
reject(new DOMException('Search request timed out', 'TimeoutError'));
|
|
751
|
+
}, timeoutMs);
|
|
752
|
+
});
|
|
753
|
+
try {
|
|
754
|
+
return await Promise.race([attempt(local.signal), timeout]);
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
lastError = error;
|
|
758
|
+
// The caller superseded this request — do not retry, propagate the abort.
|
|
759
|
+
if (callerSignal.aborted)
|
|
760
|
+
throw error;
|
|
761
|
+
// Otherwise it was a timeout or a transient network error: retry if budget remains.
|
|
762
|
+
if (i < retries && isRetryable(error)) {
|
|
763
|
+
const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
|
|
764
|
+
// Rejects with an AbortError if the caller aborts mid-backoff —
|
|
765
|
+
// propagate that cancellation instead of the transient `error` above.
|
|
766
|
+
await delay(wait, callerSignal);
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
throw error;
|
|
770
|
+
}
|
|
771
|
+
finally {
|
|
772
|
+
clearTimeout(timer);
|
|
773
|
+
callerSignal.removeEventListener('abort', onCallerAbort);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
throw lastError;
|
|
777
|
+
}
|
|
778
|
+
|
|
693
779
|
const isDevEnv$1 = () => typeof process != 'undefined' && process.env['NODE_ENV'] != 'production';
|
|
694
780
|
const emptyReturn = (query, setQuery, trackClick) => ({
|
|
695
781
|
query,
|
|
@@ -710,6 +796,9 @@ const useEvoSearch = (options = {}) => {
|
|
|
710
796
|
const [suggestions, setSuggestions] = react.useState([]);
|
|
711
797
|
const [loading, setLoading] = react.useState(false);
|
|
712
798
|
const [error, setError] = react.useState(null);
|
|
799
|
+
// Bumped when the browser regains connectivity, to re-run the search effect
|
|
800
|
+
// and self-heal a query that failed while offline.
|
|
801
|
+
const [retryNonce, setRetryNonce] = react.useState(0);
|
|
713
802
|
const ready = (_a = ctx === null || ctx === void 0 ? void 0 : ctx.ready) !== null && _a !== void 0 ? _a : false;
|
|
714
803
|
const api = (_b = ctx === null || ctx === void 0 ? void 0 : ctx.api) !== null && _b !== void 0 ? _b : null;
|
|
715
804
|
react.useEffect(() => {
|
|
@@ -717,6 +806,25 @@ const useEvoSearch = (options = {}) => {
|
|
|
717
806
|
console.error('[EvoSearch] useEvoSearch called outside <EvoSearchProvider>. Wrap your app in <EvoSearchProvider> at the root.');
|
|
718
807
|
}
|
|
719
808
|
}, [ctx]);
|
|
809
|
+
// Re-running a successful search on every reconnect would refetch (and
|
|
810
|
+
// re-bill) work that already completed; only self-heal a query that's
|
|
811
|
+
// currently in an error state, mirroring the widget's `hasError` gate.
|
|
812
|
+
const hasErrorRef = react.useRef(false);
|
|
813
|
+
react.useEffect(() => {
|
|
814
|
+
hasErrorRef.current = error != null;
|
|
815
|
+
}, [error]);
|
|
816
|
+
react.useEffect(() => {
|
|
817
|
+
if (typeof window === 'undefined') {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const onOnline = () => {
|
|
821
|
+
if (hasErrorRef.current) {
|
|
822
|
+
setRetryNonce((n) => n + 1);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
window.addEventListener('online', onOnline);
|
|
826
|
+
return () => window.removeEventListener('online', onOnline);
|
|
827
|
+
}, []);
|
|
720
828
|
react.useEffect(() => {
|
|
721
829
|
if (!ready || !api) {
|
|
722
830
|
return;
|
|
@@ -739,7 +847,7 @@ const useEvoSearch = (options = {}) => {
|
|
|
739
847
|
const controller = new AbortController();
|
|
740
848
|
const timer = setTimeout(async () => {
|
|
741
849
|
try {
|
|
742
|
-
const res = await api.search(query, undefined, controller.signal);
|
|
850
|
+
const res = await withSearchResilience((signal) => api.search(query, undefined, signal), controller.signal);
|
|
743
851
|
setResults(processAutocompleteResponse(res).products);
|
|
744
852
|
if (wantsSuggestions) {
|
|
745
853
|
setSuggestions((prev) => {
|
|
@@ -768,7 +876,32 @@ const useEvoSearch = (options = {}) => {
|
|
|
768
876
|
clearTimeout(timer);
|
|
769
877
|
controller.abort();
|
|
770
878
|
};
|
|
771
|
-
}, [query, ready, api, mode]);
|
|
879
|
+
}, [query, ready, api, mode, retryNonce]);
|
|
880
|
+
// Widget parity: report a settled query as one real search after SEARCH_SETTLE_MS.
|
|
881
|
+
const lastFinalizedRef = react.useRef('');
|
|
882
|
+
react.useEffect(() => {
|
|
883
|
+
if (!api || mode === 'autocomplete' || loading || error) {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const cleaned = query.trim();
|
|
887
|
+
if (!cleaned) {
|
|
888
|
+
// Clearing the query lets the same term finalize again on re-entry.
|
|
889
|
+
lastFinalizedRef.current = '';
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (cleaned.toLowerCase() === lastFinalizedRef.current) {
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
const timer = setTimeout(() => {
|
|
896
|
+
lastFinalizedRef.current = cleaned.toLowerCase();
|
|
897
|
+
const actionData = {
|
|
898
|
+
query: cleaned,
|
|
899
|
+
resultCount: results.length,
|
|
900
|
+
};
|
|
901
|
+
api.sendEvent('evoSearchSearchIssued', undefined, actionData);
|
|
902
|
+
}, SEARCH_SETTLE_MS);
|
|
903
|
+
return () => clearTimeout(timer);
|
|
904
|
+
}, [api, mode, query, results, loading, error]);
|
|
772
905
|
const trackClick = react.useCallback((productId, extra) => {
|
|
773
906
|
if (!api) {
|
|
774
907
|
return;
|
package/hooks.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx } from 'react/jsx-runtime';
|
|
2
|
-
import { createContext, useSyncExternalStore, useEffect, useContext, useState, useCallback, useMemo } from 'react';
|
|
2
|
+
import { createContext, useSyncExternalStore, useEffect, useContext, useState, useRef, useCallback, useMemo } from 'react';
|
|
3
3
|
|
|
4
4
|
const EvoSearchContext = createContext(null);
|
|
5
5
|
|
|
@@ -630,6 +630,11 @@ const uiActions = new Set([
|
|
|
630
630
|
new Set([...uiActions]);
|
|
631
631
|
|
|
632
632
|
const DEBOUNCE_MS = 200;
|
|
633
|
+
// How long a typed query must sit unchanged after its results render before
|
|
634
|
+
// the widget counts it as one real search (recents + server-side query
|
|
635
|
+
// metrics). Search fires per keystroke, so anything shorter than a genuine
|
|
636
|
+
// reading pause would count prefixes (`a`, `ap`, `app`) as searches.
|
|
637
|
+
const SEARCH_SETTLE_MS = 3000;
|
|
633
638
|
|
|
634
639
|
// Intentionally duplicated from @blotoutio/providers/evo-search/config to keep the hooks bundle lean — importing from the config barrel pulls the shared/universal geo data into dist/hooks.mjs (~4KB gzipped). Drift between the two values is caught by utils.test.ts.
|
|
635
640
|
const isHttpURL = (value) => {
|
|
@@ -688,6 +693,87 @@ const processAutocompleteResponse = (response) => {
|
|
|
688
693
|
};
|
|
689
694
|
};
|
|
690
695
|
|
|
696
|
+
// Resilience wrapper for /search requests. A transient network failure or a
|
|
697
|
+
// hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
|
|
698
|
+
// a rejection), retry transient failures with backoff, and never retry a
|
|
699
|
+
// caller-superseded (aborted) request. Shared by the widget and the headless
|
|
700
|
+
// hook. See callers: search-widget.ts (performAutocomplete) and
|
|
701
|
+
// hooks/use-evo-search.ts.
|
|
702
|
+
const DEFAULTS = {
|
|
703
|
+
timeoutMs: 7000,
|
|
704
|
+
retries: 2,
|
|
705
|
+
backoffMs: [400, 1200],
|
|
706
|
+
};
|
|
707
|
+
// Deterministic HTTP errors (4xx other than 429) will never succeed on
|
|
708
|
+
// retry — only retry timeouts, network errors, 429, and 5xx. Errors without
|
|
709
|
+
// an attached `status` (network/timeout failures) are always retryable.
|
|
710
|
+
const isRetryable = (error) => {
|
|
711
|
+
const status = error === null || error === void 0 ? void 0 : error.status;
|
|
712
|
+
return status == null || status === 429 || status >= 500;
|
|
713
|
+
};
|
|
714
|
+
const delay = (ms, signal) => new Promise((resolve, reject) => {
|
|
715
|
+
if (signal.aborted)
|
|
716
|
+
return reject(new DOMException('Aborted', 'AbortError'));
|
|
717
|
+
const t = setTimeout(resolve, ms);
|
|
718
|
+
signal.addEventListener('abort', () => {
|
|
719
|
+
clearTimeout(t);
|
|
720
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
721
|
+
}, { once: true });
|
|
722
|
+
});
|
|
723
|
+
/**
|
|
724
|
+
* Run `attempt` with a timeout, retrying transient failures. `callerSignal`
|
|
725
|
+
* supersedes everything: if it aborts, we stop immediately without retrying
|
|
726
|
+
* (the query changed / the widget cleaned up). A timeout or network error is
|
|
727
|
+
* retried up to `retries` times; anything left over is rethrown.
|
|
728
|
+
*/
|
|
729
|
+
async function withSearchResilience(attempt, callerSignal, options = {}) {
|
|
730
|
+
var _a, _b;
|
|
731
|
+
const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
|
|
732
|
+
let lastError;
|
|
733
|
+
for (let i = 0; i <= retries; i++) {
|
|
734
|
+
if (callerSignal.aborted)
|
|
735
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
736
|
+
const local = new AbortController();
|
|
737
|
+
const onCallerAbort = () => local.abort();
|
|
738
|
+
callerSignal.addEventListener('abort', onCallerAbort, { once: true });
|
|
739
|
+
// Race the attempt against an explicit timeout that REJECTS. Aborting the
|
|
740
|
+
// signal alone isn't enough — an attempt that ignores its signal would
|
|
741
|
+
// otherwise hang forever and never retry. Reject with a TimeoutError (not
|
|
742
|
+
// an AbortError) so callers surface it instead of treating it as a
|
|
743
|
+
// caller-cancellation.
|
|
744
|
+
let timer;
|
|
745
|
+
const timeout = new Promise((_, reject) => {
|
|
746
|
+
timer = setTimeout(() => {
|
|
747
|
+
local.abort(); // still cancel the in-flight request to free the socket
|
|
748
|
+
reject(new DOMException('Search request timed out', 'TimeoutError'));
|
|
749
|
+
}, timeoutMs);
|
|
750
|
+
});
|
|
751
|
+
try {
|
|
752
|
+
return await Promise.race([attempt(local.signal), timeout]);
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
lastError = error;
|
|
756
|
+
// The caller superseded this request — do not retry, propagate the abort.
|
|
757
|
+
if (callerSignal.aborted)
|
|
758
|
+
throw error;
|
|
759
|
+
// Otherwise it was a timeout or a transient network error: retry if budget remains.
|
|
760
|
+
if (i < retries && isRetryable(error)) {
|
|
761
|
+
const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
|
|
762
|
+
// Rejects with an AbortError if the caller aborts mid-backoff —
|
|
763
|
+
// propagate that cancellation instead of the transient `error` above.
|
|
764
|
+
await delay(wait, callerSignal);
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
throw error;
|
|
768
|
+
}
|
|
769
|
+
finally {
|
|
770
|
+
clearTimeout(timer);
|
|
771
|
+
callerSignal.removeEventListener('abort', onCallerAbort);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
throw lastError;
|
|
775
|
+
}
|
|
776
|
+
|
|
691
777
|
const isDevEnv$1 = () => typeof process != 'undefined' && process.env['NODE_ENV'] != 'production';
|
|
692
778
|
const emptyReturn = (query, setQuery, trackClick) => ({
|
|
693
779
|
query,
|
|
@@ -708,6 +794,9 @@ const useEvoSearch = (options = {}) => {
|
|
|
708
794
|
const [suggestions, setSuggestions] = useState([]);
|
|
709
795
|
const [loading, setLoading] = useState(false);
|
|
710
796
|
const [error, setError] = useState(null);
|
|
797
|
+
// Bumped when the browser regains connectivity, to re-run the search effect
|
|
798
|
+
// and self-heal a query that failed while offline.
|
|
799
|
+
const [retryNonce, setRetryNonce] = useState(0);
|
|
711
800
|
const ready = (_a = ctx === null || ctx === void 0 ? void 0 : ctx.ready) !== null && _a !== void 0 ? _a : false;
|
|
712
801
|
const api = (_b = ctx === null || ctx === void 0 ? void 0 : ctx.api) !== null && _b !== void 0 ? _b : null;
|
|
713
802
|
useEffect(() => {
|
|
@@ -715,6 +804,25 @@ const useEvoSearch = (options = {}) => {
|
|
|
715
804
|
console.error('[EvoSearch] useEvoSearch called outside <EvoSearchProvider>. Wrap your app in <EvoSearchProvider> at the root.');
|
|
716
805
|
}
|
|
717
806
|
}, [ctx]);
|
|
807
|
+
// Re-running a successful search on every reconnect would refetch (and
|
|
808
|
+
// re-bill) work that already completed; only self-heal a query that's
|
|
809
|
+
// currently in an error state, mirroring the widget's `hasError` gate.
|
|
810
|
+
const hasErrorRef = useRef(false);
|
|
811
|
+
useEffect(() => {
|
|
812
|
+
hasErrorRef.current = error != null;
|
|
813
|
+
}, [error]);
|
|
814
|
+
useEffect(() => {
|
|
815
|
+
if (typeof window === 'undefined') {
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const onOnline = () => {
|
|
819
|
+
if (hasErrorRef.current) {
|
|
820
|
+
setRetryNonce((n) => n + 1);
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
window.addEventListener('online', onOnline);
|
|
824
|
+
return () => window.removeEventListener('online', onOnline);
|
|
825
|
+
}, []);
|
|
718
826
|
useEffect(() => {
|
|
719
827
|
if (!ready || !api) {
|
|
720
828
|
return;
|
|
@@ -737,7 +845,7 @@ const useEvoSearch = (options = {}) => {
|
|
|
737
845
|
const controller = new AbortController();
|
|
738
846
|
const timer = setTimeout(async () => {
|
|
739
847
|
try {
|
|
740
|
-
const res = await api.search(query, undefined, controller.signal);
|
|
848
|
+
const res = await withSearchResilience((signal) => api.search(query, undefined, signal), controller.signal);
|
|
741
849
|
setResults(processAutocompleteResponse(res).products);
|
|
742
850
|
if (wantsSuggestions) {
|
|
743
851
|
setSuggestions((prev) => {
|
|
@@ -766,7 +874,32 @@ const useEvoSearch = (options = {}) => {
|
|
|
766
874
|
clearTimeout(timer);
|
|
767
875
|
controller.abort();
|
|
768
876
|
};
|
|
769
|
-
}, [query, ready, api, mode]);
|
|
877
|
+
}, [query, ready, api, mode, retryNonce]);
|
|
878
|
+
// Widget parity: report a settled query as one real search after SEARCH_SETTLE_MS.
|
|
879
|
+
const lastFinalizedRef = useRef('');
|
|
880
|
+
useEffect(() => {
|
|
881
|
+
if (!api || mode === 'autocomplete' || loading || error) {
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const cleaned = query.trim();
|
|
885
|
+
if (!cleaned) {
|
|
886
|
+
// Clearing the query lets the same term finalize again on re-entry.
|
|
887
|
+
lastFinalizedRef.current = '';
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (cleaned.toLowerCase() === lastFinalizedRef.current) {
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const timer = setTimeout(() => {
|
|
894
|
+
lastFinalizedRef.current = cleaned.toLowerCase();
|
|
895
|
+
const actionData = {
|
|
896
|
+
query: cleaned,
|
|
897
|
+
resultCount: results.length,
|
|
898
|
+
};
|
|
899
|
+
api.sendEvent('evoSearchSearchIssued', undefined, actionData);
|
|
900
|
+
}, SEARCH_SETTLE_MS);
|
|
901
|
+
return () => clearTimeout(timer);
|
|
902
|
+
}, [api, mode, query, results, loading, error]);
|
|
770
903
|
const trackClick = useCallback((productId, extra) => {
|
|
771
904
|
if (!api) {
|
|
772
905
|
return;
|
package/index.cjs.js
CHANGED
|
@@ -847,10 +847,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
847
847
|
throw new Error('sendTag missing');
|
|
848
848
|
}
|
|
849
849
|
const getHeaders = () => {
|
|
850
|
+
var _a;
|
|
850
851
|
const headers = new Headers({ EdgeTagUserId: userId });
|
|
851
852
|
if (sessionId) {
|
|
852
853
|
headers.set('EdgeTagSessionId', sessionId);
|
|
853
854
|
}
|
|
855
|
+
headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
|
|
854
856
|
return headers;
|
|
855
857
|
};
|
|
856
858
|
// Parsing `baseURL` once at API construction avoids re-parsing the host on
|
|
@@ -884,7 +886,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
884
886
|
if (!response.ok) {
|
|
885
887
|
const errorText = await response.text().catch(() => '');
|
|
886
888
|
logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
|
|
887
|
-
|
|
889
|
+
const error = new Error(`${label} failed - ${response.status}`);
|
|
890
|
+
error.status = response.status;
|
|
891
|
+
throw error;
|
|
888
892
|
}
|
|
889
893
|
return (await response.json());
|
|
890
894
|
};
|
|
@@ -8305,6 +8309,87 @@ const searchWidgetStyles = i$6 `
|
|
|
8305
8309
|
}
|
|
8306
8310
|
`;
|
|
8307
8311
|
|
|
8312
|
+
// Resilience wrapper for /search requests. A transient network failure or a
|
|
8313
|
+
// hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
|
|
8314
|
+
// a rejection), retry transient failures with backoff, and never retry a
|
|
8315
|
+
// caller-superseded (aborted) request. Shared by the widget and the headless
|
|
8316
|
+
// hook. See callers: search-widget.ts (performAutocomplete) and
|
|
8317
|
+
// hooks/use-evo-search.ts.
|
|
8318
|
+
const DEFAULTS = {
|
|
8319
|
+
timeoutMs: 7000,
|
|
8320
|
+
retries: 2,
|
|
8321
|
+
backoffMs: [400, 1200],
|
|
8322
|
+
};
|
|
8323
|
+
// Deterministic HTTP errors (4xx other than 429) will never succeed on
|
|
8324
|
+
// retry — only retry timeouts, network errors, 429, and 5xx. Errors without
|
|
8325
|
+
// an attached `status` (network/timeout failures) are always retryable.
|
|
8326
|
+
const isRetryable = (error) => {
|
|
8327
|
+
const status = error === null || error === void 0 ? void 0 : error.status;
|
|
8328
|
+
return status == null || status === 429 || status >= 500;
|
|
8329
|
+
};
|
|
8330
|
+
const delay = (ms, signal) => new Promise((resolve, reject) => {
|
|
8331
|
+
if (signal.aborted)
|
|
8332
|
+
return reject(new DOMException('Aborted', 'AbortError'));
|
|
8333
|
+
const t = setTimeout(resolve, ms);
|
|
8334
|
+
signal.addEventListener('abort', () => {
|
|
8335
|
+
clearTimeout(t);
|
|
8336
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
8337
|
+
}, { once: true });
|
|
8338
|
+
});
|
|
8339
|
+
/**
|
|
8340
|
+
* Run `attempt` with a timeout, retrying transient failures. `callerSignal`
|
|
8341
|
+
* supersedes everything: if it aborts, we stop immediately without retrying
|
|
8342
|
+
* (the query changed / the widget cleaned up). A timeout or network error is
|
|
8343
|
+
* retried up to `retries` times; anything left over is rethrown.
|
|
8344
|
+
*/
|
|
8345
|
+
async function withSearchResilience(attempt, callerSignal, options = {}) {
|
|
8346
|
+
var _a, _b;
|
|
8347
|
+
const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
|
|
8348
|
+
let lastError;
|
|
8349
|
+
for (let i = 0; i <= retries; i++) {
|
|
8350
|
+
if (callerSignal.aborted)
|
|
8351
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
8352
|
+
const local = new AbortController();
|
|
8353
|
+
const onCallerAbort = () => local.abort();
|
|
8354
|
+
callerSignal.addEventListener('abort', onCallerAbort, { once: true });
|
|
8355
|
+
// Race the attempt against an explicit timeout that REJECTS. Aborting the
|
|
8356
|
+
// signal alone isn't enough — an attempt that ignores its signal would
|
|
8357
|
+
// otherwise hang forever and never retry. Reject with a TimeoutError (not
|
|
8358
|
+
// an AbortError) so callers surface it instead of treating it as a
|
|
8359
|
+
// caller-cancellation.
|
|
8360
|
+
let timer;
|
|
8361
|
+
const timeout = new Promise((_, reject) => {
|
|
8362
|
+
timer = setTimeout(() => {
|
|
8363
|
+
local.abort(); // still cancel the in-flight request to free the socket
|
|
8364
|
+
reject(new DOMException('Search request timed out', 'TimeoutError'));
|
|
8365
|
+
}, timeoutMs);
|
|
8366
|
+
});
|
|
8367
|
+
try {
|
|
8368
|
+
return await Promise.race([attempt(local.signal), timeout]);
|
|
8369
|
+
}
|
|
8370
|
+
catch (error) {
|
|
8371
|
+
lastError = error;
|
|
8372
|
+
// The caller superseded this request — do not retry, propagate the abort.
|
|
8373
|
+
if (callerSignal.aborted)
|
|
8374
|
+
throw error;
|
|
8375
|
+
// Otherwise it was a timeout or a transient network error: retry if budget remains.
|
|
8376
|
+
if (i < retries && isRetryable(error)) {
|
|
8377
|
+
const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
|
|
8378
|
+
// Rejects with an AbortError if the caller aborts mid-backoff —
|
|
8379
|
+
// propagate that cancellation instead of the transient `error` above.
|
|
8380
|
+
await delay(wait, callerSignal);
|
|
8381
|
+
continue;
|
|
8382
|
+
}
|
|
8383
|
+
throw error;
|
|
8384
|
+
}
|
|
8385
|
+
finally {
|
|
8386
|
+
clearTimeout(timer);
|
|
8387
|
+
callerSignal.removeEventListener('abort', onCallerAbort);
|
|
8388
|
+
}
|
|
8389
|
+
}
|
|
8390
|
+
throw lastError;
|
|
8391
|
+
}
|
|
8392
|
+
|
|
8308
8393
|
const MIN_QUERY_LENGTH = 3;
|
|
8309
8394
|
// @state fields whose change should be persisted to the session snapshot.
|
|
8310
8395
|
const SESSION_PERSIST_KEYS = [
|
|
@@ -8395,6 +8480,7 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8395
8480
|
// autocomplete can't overwrite a fresh search's results.
|
|
8396
8481
|
this.requestGeneration = 0;
|
|
8397
8482
|
this.keydownListener = (event) => this.handleKeydown(event);
|
|
8483
|
+
this.onlineListener = () => this.handleReconnect();
|
|
8398
8484
|
// Streaming tokens are coalesced into one per-frame state update rather than
|
|
8399
8485
|
// reassigning `askAnswer` on every token — a ~220-token answer would
|
|
8400
8486
|
// otherwise trigger ~220 full widget renders.
|
|
@@ -8439,6 +8525,20 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8439
8525
|
var _a, _b;
|
|
8440
8526
|
(_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.warm) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
8441
8527
|
}
|
|
8528
|
+
// A transient network failure leaves the results in an error state; when
|
|
8529
|
+
// connectivity returns, re-issue the current query so it self-heals without
|
|
8530
|
+
// the shopper having to retype.
|
|
8531
|
+
handleReconnect() {
|
|
8532
|
+
if (!this.hasError) {
|
|
8533
|
+
return;
|
|
8534
|
+
}
|
|
8535
|
+
const trimmed = this.searchQuery.trim();
|
|
8536
|
+
if (trimmed.length < MIN_QUERY_LENGTH) {
|
|
8537
|
+
return;
|
|
8538
|
+
}
|
|
8539
|
+
this.loading = true;
|
|
8540
|
+
void this.performAutocomplete(trimmed);
|
|
8541
|
+
}
|
|
8442
8542
|
connectedCallback() {
|
|
8443
8543
|
super.connectedCallback();
|
|
8444
8544
|
// The widget always opens in Search view; Ask AI is entered only by
|
|
@@ -8455,6 +8555,7 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8455
8555
|
this.mobileMediaQuery = window.matchMedia('(max-width: 768px)');
|
|
8456
8556
|
this.isMobile = this.mobileMediaQuery.matches;
|
|
8457
8557
|
this.mobileMediaQuery.addEventListener('change', this.mobileMediaListener);
|
|
8558
|
+
window.addEventListener('online', this.onlineListener);
|
|
8458
8559
|
}
|
|
8459
8560
|
firstUpdated() {
|
|
8460
8561
|
this.focusSearchBox();
|
|
@@ -8470,6 +8571,7 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8470
8571
|
this.resetAnswerBuffer();
|
|
8471
8572
|
this.removeEventListener('keydown', this.keydownListener);
|
|
8472
8573
|
(_c = this.mobileMediaQuery) === null || _c === void 0 ? void 0 : _c.removeEventListener('change', this.mobileMediaListener);
|
|
8574
|
+
window.removeEventListener('online', this.onlineListener);
|
|
8473
8575
|
super.disconnectedCallback();
|
|
8474
8576
|
}
|
|
8475
8577
|
willUpdate(changed) {
|
|
@@ -8888,7 +8990,8 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8888
8990
|
this.loading = false;
|
|
8889
8991
|
return;
|
|
8890
8992
|
}
|
|
8891
|
-
const
|
|
8993
|
+
const api = this.api;
|
|
8994
|
+
const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
|
|
8892
8995
|
if (generation !== this.requestGeneration) {
|
|
8893
8996
|
return;
|
|
8894
8997
|
}
|
package/index.js
CHANGED
|
@@ -848,10 +848,12 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
848
848
|
throw new Error('sendTag missing');
|
|
849
849
|
}
|
|
850
850
|
const getHeaders = () => {
|
|
851
|
+
var _a;
|
|
851
852
|
const headers = new Headers({ EdgeTagUserId: userId });
|
|
852
853
|
if (sessionId) {
|
|
853
854
|
headers.set('EdgeTagSessionId', sessionId);
|
|
854
855
|
}
|
|
856
|
+
headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
|
|
855
857
|
return headers;
|
|
856
858
|
};
|
|
857
859
|
// Parsing `baseURL` once at API construction avoids re-parsing the host on
|
|
@@ -885,7 +887,9 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
885
887
|
if (!response.ok) {
|
|
886
888
|
const errorText = await response.text().catch(() => '');
|
|
887
889
|
logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
|
|
888
|
-
|
|
890
|
+
const error = new Error(`${label} failed - ${response.status}`);
|
|
891
|
+
error.status = response.status;
|
|
892
|
+
throw error;
|
|
889
893
|
}
|
|
890
894
|
return (await response.json());
|
|
891
895
|
};
|
|
@@ -8306,6 +8310,87 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
8306
8310
|
}
|
|
8307
8311
|
`;
|
|
8308
8312
|
|
|
8313
|
+
// Resilience wrapper for /search requests. A transient network failure or a
|
|
8314
|
+
// hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
|
|
8315
|
+
// a rejection), retry transient failures with backoff, and never retry a
|
|
8316
|
+
// caller-superseded (aborted) request. Shared by the widget and the headless
|
|
8317
|
+
// hook. See callers: search-widget.ts (performAutocomplete) and
|
|
8318
|
+
// hooks/use-evo-search.ts.
|
|
8319
|
+
const DEFAULTS = {
|
|
8320
|
+
timeoutMs: 7000,
|
|
8321
|
+
retries: 2,
|
|
8322
|
+
backoffMs: [400, 1200],
|
|
8323
|
+
};
|
|
8324
|
+
// Deterministic HTTP errors (4xx other than 429) will never succeed on
|
|
8325
|
+
// retry — only retry timeouts, network errors, 429, and 5xx. Errors without
|
|
8326
|
+
// an attached `status` (network/timeout failures) are always retryable.
|
|
8327
|
+
const isRetryable = (error) => {
|
|
8328
|
+
const status = error === null || error === void 0 ? void 0 : error.status;
|
|
8329
|
+
return status == null || status === 429 || status >= 500;
|
|
8330
|
+
};
|
|
8331
|
+
const delay = (ms, signal) => new Promise((resolve, reject) => {
|
|
8332
|
+
if (signal.aborted)
|
|
8333
|
+
return reject(new DOMException('Aborted', 'AbortError'));
|
|
8334
|
+
const t = setTimeout(resolve, ms);
|
|
8335
|
+
signal.addEventListener('abort', () => {
|
|
8336
|
+
clearTimeout(t);
|
|
8337
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
8338
|
+
}, { once: true });
|
|
8339
|
+
});
|
|
8340
|
+
/**
|
|
8341
|
+
* Run `attempt` with a timeout, retrying transient failures. `callerSignal`
|
|
8342
|
+
* supersedes everything: if it aborts, we stop immediately without retrying
|
|
8343
|
+
* (the query changed / the widget cleaned up). A timeout or network error is
|
|
8344
|
+
* retried up to `retries` times; anything left over is rethrown.
|
|
8345
|
+
*/
|
|
8346
|
+
async function withSearchResilience(attempt, callerSignal, options = {}) {
|
|
8347
|
+
var _a, _b;
|
|
8348
|
+
const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
|
|
8349
|
+
let lastError;
|
|
8350
|
+
for (let i = 0; i <= retries; i++) {
|
|
8351
|
+
if (callerSignal.aborted)
|
|
8352
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
8353
|
+
const local = new AbortController();
|
|
8354
|
+
const onCallerAbort = () => local.abort();
|
|
8355
|
+
callerSignal.addEventListener('abort', onCallerAbort, { once: true });
|
|
8356
|
+
// Race the attempt against an explicit timeout that REJECTS. Aborting the
|
|
8357
|
+
// signal alone isn't enough — an attempt that ignores its signal would
|
|
8358
|
+
// otherwise hang forever and never retry. Reject with a TimeoutError (not
|
|
8359
|
+
// an AbortError) so callers surface it instead of treating it as a
|
|
8360
|
+
// caller-cancellation.
|
|
8361
|
+
let timer;
|
|
8362
|
+
const timeout = new Promise((_, reject) => {
|
|
8363
|
+
timer = setTimeout(() => {
|
|
8364
|
+
local.abort(); // still cancel the in-flight request to free the socket
|
|
8365
|
+
reject(new DOMException('Search request timed out', 'TimeoutError'));
|
|
8366
|
+
}, timeoutMs);
|
|
8367
|
+
});
|
|
8368
|
+
try {
|
|
8369
|
+
return await Promise.race([attempt(local.signal), timeout]);
|
|
8370
|
+
}
|
|
8371
|
+
catch (error) {
|
|
8372
|
+
lastError = error;
|
|
8373
|
+
// The caller superseded this request — do not retry, propagate the abort.
|
|
8374
|
+
if (callerSignal.aborted)
|
|
8375
|
+
throw error;
|
|
8376
|
+
// Otherwise it was a timeout or a transient network error: retry if budget remains.
|
|
8377
|
+
if (i < retries && isRetryable(error)) {
|
|
8378
|
+
const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
|
|
8379
|
+
// Rejects with an AbortError if the caller aborts mid-backoff —
|
|
8380
|
+
// propagate that cancellation instead of the transient `error` above.
|
|
8381
|
+
await delay(wait, callerSignal);
|
|
8382
|
+
continue;
|
|
8383
|
+
}
|
|
8384
|
+
throw error;
|
|
8385
|
+
}
|
|
8386
|
+
finally {
|
|
8387
|
+
clearTimeout(timer);
|
|
8388
|
+
callerSignal.removeEventListener('abort', onCallerAbort);
|
|
8389
|
+
}
|
|
8390
|
+
}
|
|
8391
|
+
throw lastError;
|
|
8392
|
+
}
|
|
8393
|
+
|
|
8309
8394
|
const MIN_QUERY_LENGTH = 3;
|
|
8310
8395
|
// @state fields whose change should be persisted to the session snapshot.
|
|
8311
8396
|
const SESSION_PERSIST_KEYS = [
|
|
@@ -8396,6 +8481,7 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
8396
8481
|
// autocomplete can't overwrite a fresh search's results.
|
|
8397
8482
|
this.requestGeneration = 0;
|
|
8398
8483
|
this.keydownListener = (event) => this.handleKeydown(event);
|
|
8484
|
+
this.onlineListener = () => this.handleReconnect();
|
|
8399
8485
|
// Streaming tokens are coalesced into one per-frame state update rather than
|
|
8400
8486
|
// reassigning `askAnswer` on every token — a ~220-token answer would
|
|
8401
8487
|
// otherwise trigger ~220 full widget renders.
|
|
@@ -8440,6 +8526,20 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
8440
8526
|
var _a, _b;
|
|
8441
8527
|
(_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.warm) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
8442
8528
|
}
|
|
8529
|
+
// A transient network failure leaves the results in an error state; when
|
|
8530
|
+
// connectivity returns, re-issue the current query so it self-heals without
|
|
8531
|
+
// the shopper having to retype.
|
|
8532
|
+
handleReconnect() {
|
|
8533
|
+
if (!this.hasError) {
|
|
8534
|
+
return;
|
|
8535
|
+
}
|
|
8536
|
+
const trimmed = this.searchQuery.trim();
|
|
8537
|
+
if (trimmed.length < MIN_QUERY_LENGTH) {
|
|
8538
|
+
return;
|
|
8539
|
+
}
|
|
8540
|
+
this.loading = true;
|
|
8541
|
+
void this.performAutocomplete(trimmed);
|
|
8542
|
+
}
|
|
8443
8543
|
connectedCallback() {
|
|
8444
8544
|
super.connectedCallback();
|
|
8445
8545
|
// The widget always opens in Search view; Ask AI is entered only by
|
|
@@ -8456,6 +8556,7 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
8456
8556
|
this.mobileMediaQuery = window.matchMedia('(max-width: 768px)');
|
|
8457
8557
|
this.isMobile = this.mobileMediaQuery.matches;
|
|
8458
8558
|
this.mobileMediaQuery.addEventListener('change', this.mobileMediaListener);
|
|
8559
|
+
window.addEventListener('online', this.onlineListener);
|
|
8459
8560
|
}
|
|
8460
8561
|
firstUpdated() {
|
|
8461
8562
|
this.focusSearchBox();
|
|
@@ -8471,6 +8572,7 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
8471
8572
|
this.resetAnswerBuffer();
|
|
8472
8573
|
this.removeEventListener('keydown', this.keydownListener);
|
|
8473
8574
|
(_c = this.mobileMediaQuery) === null || _c === void 0 ? void 0 : _c.removeEventListener('change', this.mobileMediaListener);
|
|
8575
|
+
window.removeEventListener('online', this.onlineListener);
|
|
8474
8576
|
super.disconnectedCallback();
|
|
8475
8577
|
}
|
|
8476
8578
|
willUpdate(changed) {
|
|
@@ -8889,7 +8991,8 @@ var ProvidersEvoSearchSdk = (function () {
|
|
|
8889
8991
|
this.loading = false;
|
|
8890
8992
|
return;
|
|
8891
8993
|
}
|
|
8892
|
-
const
|
|
8994
|
+
const api = this.api;
|
|
8995
|
+
const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
|
|
8893
8996
|
if (generation !== this.requestGeneration) {
|
|
8894
8997
|
return;
|
|
8895
8998
|
}
|
package/index.mjs
CHANGED
|
@@ -845,10 +845,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
845
845
|
throw new Error('sendTag missing');
|
|
846
846
|
}
|
|
847
847
|
const getHeaders = () => {
|
|
848
|
+
var _a;
|
|
848
849
|
const headers = new Headers({ EdgeTagUserId: userId });
|
|
849
850
|
if (sessionId) {
|
|
850
851
|
headers.set('EdgeTagSessionId', sessionId);
|
|
851
852
|
}
|
|
853
|
+
headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
|
|
852
854
|
return headers;
|
|
853
855
|
};
|
|
854
856
|
// Parsing `baseURL` once at API construction avoids re-parsing the host on
|
|
@@ -882,7 +884,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
882
884
|
if (!response.ok) {
|
|
883
885
|
const errorText = await response.text().catch(() => '');
|
|
884
886
|
logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
|
|
885
|
-
|
|
887
|
+
const error = new Error(`${label} failed - ${response.status}`);
|
|
888
|
+
error.status = response.status;
|
|
889
|
+
throw error;
|
|
886
890
|
}
|
|
887
891
|
return (await response.json());
|
|
888
892
|
};
|
|
@@ -8303,6 +8307,87 @@ const searchWidgetStyles = i$6 `
|
|
|
8303
8307
|
}
|
|
8304
8308
|
`;
|
|
8305
8309
|
|
|
8310
|
+
// Resilience wrapper for /search requests. A transient network failure or a
|
|
8311
|
+
// hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
|
|
8312
|
+
// a rejection), retry transient failures with backoff, and never retry a
|
|
8313
|
+
// caller-superseded (aborted) request. Shared by the widget and the headless
|
|
8314
|
+
// hook. See callers: search-widget.ts (performAutocomplete) and
|
|
8315
|
+
// hooks/use-evo-search.ts.
|
|
8316
|
+
const DEFAULTS = {
|
|
8317
|
+
timeoutMs: 7000,
|
|
8318
|
+
retries: 2,
|
|
8319
|
+
backoffMs: [400, 1200],
|
|
8320
|
+
};
|
|
8321
|
+
// Deterministic HTTP errors (4xx other than 429) will never succeed on
|
|
8322
|
+
// retry — only retry timeouts, network errors, 429, and 5xx. Errors without
|
|
8323
|
+
// an attached `status` (network/timeout failures) are always retryable.
|
|
8324
|
+
const isRetryable = (error) => {
|
|
8325
|
+
const status = error === null || error === void 0 ? void 0 : error.status;
|
|
8326
|
+
return status == null || status === 429 || status >= 500;
|
|
8327
|
+
};
|
|
8328
|
+
const delay = (ms, signal) => new Promise((resolve, reject) => {
|
|
8329
|
+
if (signal.aborted)
|
|
8330
|
+
return reject(new DOMException('Aborted', 'AbortError'));
|
|
8331
|
+
const t = setTimeout(resolve, ms);
|
|
8332
|
+
signal.addEventListener('abort', () => {
|
|
8333
|
+
clearTimeout(t);
|
|
8334
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
8335
|
+
}, { once: true });
|
|
8336
|
+
});
|
|
8337
|
+
/**
|
|
8338
|
+
* Run `attempt` with a timeout, retrying transient failures. `callerSignal`
|
|
8339
|
+
* supersedes everything: if it aborts, we stop immediately without retrying
|
|
8340
|
+
* (the query changed / the widget cleaned up). A timeout or network error is
|
|
8341
|
+
* retried up to `retries` times; anything left over is rethrown.
|
|
8342
|
+
*/
|
|
8343
|
+
async function withSearchResilience(attempt, callerSignal, options = {}) {
|
|
8344
|
+
var _a, _b;
|
|
8345
|
+
const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
|
|
8346
|
+
let lastError;
|
|
8347
|
+
for (let i = 0; i <= retries; i++) {
|
|
8348
|
+
if (callerSignal.aborted)
|
|
8349
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
8350
|
+
const local = new AbortController();
|
|
8351
|
+
const onCallerAbort = () => local.abort();
|
|
8352
|
+
callerSignal.addEventListener('abort', onCallerAbort, { once: true });
|
|
8353
|
+
// Race the attempt against an explicit timeout that REJECTS. Aborting the
|
|
8354
|
+
// signal alone isn't enough — an attempt that ignores its signal would
|
|
8355
|
+
// otherwise hang forever and never retry. Reject with a TimeoutError (not
|
|
8356
|
+
// an AbortError) so callers surface it instead of treating it as a
|
|
8357
|
+
// caller-cancellation.
|
|
8358
|
+
let timer;
|
|
8359
|
+
const timeout = new Promise((_, reject) => {
|
|
8360
|
+
timer = setTimeout(() => {
|
|
8361
|
+
local.abort(); // still cancel the in-flight request to free the socket
|
|
8362
|
+
reject(new DOMException('Search request timed out', 'TimeoutError'));
|
|
8363
|
+
}, timeoutMs);
|
|
8364
|
+
});
|
|
8365
|
+
try {
|
|
8366
|
+
return await Promise.race([attempt(local.signal), timeout]);
|
|
8367
|
+
}
|
|
8368
|
+
catch (error) {
|
|
8369
|
+
lastError = error;
|
|
8370
|
+
// The caller superseded this request — do not retry, propagate the abort.
|
|
8371
|
+
if (callerSignal.aborted)
|
|
8372
|
+
throw error;
|
|
8373
|
+
// Otherwise it was a timeout or a transient network error: retry if budget remains.
|
|
8374
|
+
if (i < retries && isRetryable(error)) {
|
|
8375
|
+
const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
|
|
8376
|
+
// Rejects with an AbortError if the caller aborts mid-backoff —
|
|
8377
|
+
// propagate that cancellation instead of the transient `error` above.
|
|
8378
|
+
await delay(wait, callerSignal);
|
|
8379
|
+
continue;
|
|
8380
|
+
}
|
|
8381
|
+
throw error;
|
|
8382
|
+
}
|
|
8383
|
+
finally {
|
|
8384
|
+
clearTimeout(timer);
|
|
8385
|
+
callerSignal.removeEventListener('abort', onCallerAbort);
|
|
8386
|
+
}
|
|
8387
|
+
}
|
|
8388
|
+
throw lastError;
|
|
8389
|
+
}
|
|
8390
|
+
|
|
8306
8391
|
const MIN_QUERY_LENGTH = 3;
|
|
8307
8392
|
// @state fields whose change should be persisted to the session snapshot.
|
|
8308
8393
|
const SESSION_PERSIST_KEYS = [
|
|
@@ -8393,6 +8478,7 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8393
8478
|
// autocomplete can't overwrite a fresh search's results.
|
|
8394
8479
|
this.requestGeneration = 0;
|
|
8395
8480
|
this.keydownListener = (event) => this.handleKeydown(event);
|
|
8481
|
+
this.onlineListener = () => this.handleReconnect();
|
|
8396
8482
|
// Streaming tokens are coalesced into one per-frame state update rather than
|
|
8397
8483
|
// reassigning `askAnswer` on every token — a ~220-token answer would
|
|
8398
8484
|
// otherwise trigger ~220 full widget renders.
|
|
@@ -8437,6 +8523,20 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8437
8523
|
var _a, _b;
|
|
8438
8524
|
(_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.warm) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
8439
8525
|
}
|
|
8526
|
+
// A transient network failure leaves the results in an error state; when
|
|
8527
|
+
// connectivity returns, re-issue the current query so it self-heals without
|
|
8528
|
+
// the shopper having to retype.
|
|
8529
|
+
handleReconnect() {
|
|
8530
|
+
if (!this.hasError) {
|
|
8531
|
+
return;
|
|
8532
|
+
}
|
|
8533
|
+
const trimmed = this.searchQuery.trim();
|
|
8534
|
+
if (trimmed.length < MIN_QUERY_LENGTH) {
|
|
8535
|
+
return;
|
|
8536
|
+
}
|
|
8537
|
+
this.loading = true;
|
|
8538
|
+
void this.performAutocomplete(trimmed);
|
|
8539
|
+
}
|
|
8440
8540
|
connectedCallback() {
|
|
8441
8541
|
super.connectedCallback();
|
|
8442
8542
|
// The widget always opens in Search view; Ask AI is entered only by
|
|
@@ -8453,6 +8553,7 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8453
8553
|
this.mobileMediaQuery = window.matchMedia('(max-width: 768px)');
|
|
8454
8554
|
this.isMobile = this.mobileMediaQuery.matches;
|
|
8455
8555
|
this.mobileMediaQuery.addEventListener('change', this.mobileMediaListener);
|
|
8556
|
+
window.addEventListener('online', this.onlineListener);
|
|
8456
8557
|
}
|
|
8457
8558
|
firstUpdated() {
|
|
8458
8559
|
this.focusSearchBox();
|
|
@@ -8468,6 +8569,7 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8468
8569
|
this.resetAnswerBuffer();
|
|
8469
8570
|
this.removeEventListener('keydown', this.keydownListener);
|
|
8470
8571
|
(_c = this.mobileMediaQuery) === null || _c === void 0 ? void 0 : _c.removeEventListener('change', this.mobileMediaListener);
|
|
8572
|
+
window.removeEventListener('online', this.onlineListener);
|
|
8471
8573
|
super.disconnectedCallback();
|
|
8472
8574
|
}
|
|
8473
8575
|
willUpdate(changed) {
|
|
@@ -8886,7 +8988,8 @@ class SearchWidget extends ElementWithStylesheet {
|
|
|
8886
8988
|
this.loading = false;
|
|
8887
8989
|
return;
|
|
8888
8990
|
}
|
|
8889
|
-
const
|
|
8991
|
+
const api = this.api;
|
|
8992
|
+
const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
|
|
8890
8993
|
if (generation !== this.requestGeneration) {
|
|
8891
8994
|
return;
|
|
8892
8995
|
}
|