@blotoutio/providers-evo-search-sdk 1.70.2 → 1.71.1

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/core.cjs.js CHANGED
@@ -817,6 +817,16 @@ const uiActions = new Set([
817
817
  'evoSearchProductAddedToCart',
818
818
  'evoSearchProductPurchased',
819
819
  'evoSearchSearchIssued',
820
+ // Relevance-telemetry pair (PR2 task 3): one impressions beacon per
821
+ // finalized query, one click beacon per result click. Server handling
822
+ // (Task 4) wired in routes/user.ts.
823
+ 'evoSearchImpressions',
824
+ 'evoSearchResultClick',
825
+ // One beacon per Ask answer the assistant could not produce (generation
826
+ // failure, budget exhaustion) — the client-side counterpart to the worker's
827
+ // own degrade counters, and the only signal for a failure the worker never
828
+ // sees (the request never reaching it).
829
+ 'evoSearchAskDegraded',
820
830
  ]);
821
831
  new Set([...uiActions]);
822
832
 
@@ -854,6 +864,172 @@ const logger = {
854
864
  },
855
865
  };
856
866
 
867
+ // Pure, DOM-free session-context logic: what the shopper's tab has seen this
868
+ // visit, kept small enough to ride along on every search request as a `ctx`
869
+ // query param and every finalized-query beacon. No PII — ids, capped query
870
+ // strings, and an enum page type only. Storage wiring (sessionStorage
871
+ // read/write, entry page detection from location.pathname) lives in
872
+ // utils.ts; this module only knows how to grow and shrink the shape
873
+ // immutably, and how to encode it for the wire.
874
+ // Single source of truth for the enum — utils.ts's sessionStorage parse
875
+ // validates membership against this (not just `typeof === 'string'`) before
876
+ // trusting a stored value, so a hostile/corrupted entry can't smuggle an
877
+ // arbitrary string into the wire payload.
878
+ const PAGE_TYPES = [
879
+ 'home',
880
+ 'plp',
881
+ 'pdp',
882
+ 'cart',
883
+ 'search',
884
+ 'other',
885
+ ];
886
+ const isPageType = (value) => typeof value === 'string' && PAGE_TYPES.includes(value);
887
+ const MAX_VIEWED = 20;
888
+ // A product id longer than this is not an id any catalog issues; it is a
889
+ // runaway string that would eat the whole byte budget on one entry. Mirrors
890
+ // the decoder's own per-id cap, which DROPS (never truncates) an oversized
891
+ // id — a truncated id silently becomes a different, wrong product.
892
+ const MAX_PRODUCT_ID_CHARS = 64;
893
+ // The platform's global-id form for a product. Hosts hand us whichever form
894
+ // their storefront layer has to hand; the catalog is keyed on the bare id, so
895
+ // normalize here rather than making every caller remember which one to pass.
896
+ const GLOBAL_ID_PREFIX = 'gid://shopify/Product/';
897
+ /**
898
+ * Coerces a caller-supplied product id into the id space the catalog uses, or
899
+ * null when it cannot be one. Accepts a number (storefront globals expose the
900
+ * id numerically) or a string, tolerates the platform's global-id form, and
901
+ * rejects empty/oversized values rather than putting an id on the wire that
902
+ * can never resolve.
903
+ */
904
+ const normalizeProductId = (value) => {
905
+ const raw = typeof value === 'number' && Number.isFinite(value)
906
+ ? String(value)
907
+ : typeof value === 'string'
908
+ ? value.trim()
909
+ : '';
910
+ const id = raw.startsWith(GLOBAL_ID_PREFIX)
911
+ ? raw.slice(GLOBAL_ID_PREFIX.length)
912
+ : raw;
913
+ if (!id || id.length > MAX_PRODUCT_ID_CHARS) {
914
+ return null;
915
+ }
916
+ return id;
917
+ };
918
+ // Hard cap enforced by serializeContext, measured in ENCODED UTF-8 BYTES —
919
+ // `.length` counts UTF-16 code units, which undercounts CJK/emoji text by up
920
+ // to 3x. Kept in sync with the `ctx` query param budget documented in the
921
+ // PR2 brief (base64url grows this ~4/3, so ~2.7KB encoded — comfortably
922
+ // inside URL limits).
923
+ const MAX_SERIALIZED_BYTES = 2048;
924
+ const pushViewed = (ctx, id) => ({
925
+ ...ctx,
926
+ viewed: [id, ...ctx.viewed.filter((viewedId) => viewedId !== id)].slice(0, MAX_VIEWED),
927
+ });
928
+ // Drops entries from the tail (pushViewed/pushQuery keep newest-first) until
929
+ // the ENCODED byte length fits the hard cap, in order of how much each byte
930
+ // is worth: the browse list first (its tail is the least recent thing the
931
+ // shopper looked at, and it is the longest list), then the cart, then the
932
+ // queries — fewest entries and the most intent per byte, so they yield last
933
+ // but are not exempt: five 200-char CJK queries alone encode to ~3KB, so a
934
+ // character-bounded list can still blow a byte budget.
935
+ const serializeContext = (ctx) => {
936
+ let working = {
937
+ ...ctx,
938
+ viewed: [...ctx.viewed],
939
+ cart: [...ctx.cart],
940
+ queries: [...ctx.queries],
941
+ };
942
+ let serialized = JSON.stringify(working);
943
+ while (textEncoder.encode(serialized).length > MAX_SERIALIZED_BYTES &&
944
+ (working.viewed.length > 0 ||
945
+ working.cart.length > 0 ||
946
+ working.queries.length > 0)) {
947
+ working =
948
+ working.viewed.length > 0
949
+ ? { ...working, viewed: working.viewed.slice(0, -1) }
950
+ : working.cart.length > 0
951
+ ? { ...working, cart: working.cart.slice(0, -1) }
952
+ : { ...working, queries: working.queries.slice(0, -1) };
953
+ serialized = JSON.stringify(working);
954
+ }
955
+ return serialized;
956
+ };
957
+ // Detected once per session from the entry URL — home/plp/pdp/cart by path
958
+ // shape, everything else is 'other'. Pure so it's testable alongside the
959
+ // rest of this module; utils.ts calls it exactly once, on first read.
960
+ const detectEntryPage = (pathname) => {
961
+ if (pathname === '/') {
962
+ return 'home';
963
+ }
964
+ if (pathname.startsWith('/products/')) {
965
+ return 'pdp';
966
+ }
967
+ if (pathname.startsWith('/collections/')) {
968
+ return 'plp';
969
+ }
970
+ if (pathname.startsWith('/cart')) {
971
+ return 'cart';
972
+ }
973
+ return 'other';
974
+ };
975
+ // Native TextEncoder + btoa, same as the CDN sibling's utils/user-auth.ts —
976
+ // jsdom's missing TextEncoder is a test-environment gap, polyfilled in
977
+ // jest.setup.ts rather than worked around in shipped code.
978
+ const textEncoder = new TextEncoder();
979
+ const toBase64Url = (bytes) => {
980
+ let bin = '';
981
+ for (let i = 0; i < bytes.length; i++) {
982
+ bin += String.fromCharCode(bytes[i]);
983
+ }
984
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
985
+ };
986
+ // Wire form for the `ctx` search query param: base64url(serializeContext(ctx)),
987
+ // no padding. Returns null when there's nothing worth the extra URL bytes —
988
+ // a context with no viewed products, cart lines or past queries yet (e.g. the very
989
+ // first search of a fresh tab) — so a brand-new session doesn't pay for a
990
+ // param carrying only the entryPage enum.
991
+ const encodeContextParam = (ctx) => {
992
+ if (ctx.viewed.length === 0 &&
993
+ ctx.cart.length === 0 &&
994
+ ctx.queries.length === 0) {
995
+ return null;
996
+ }
997
+ return toBase64Url(textEncoder.encode(serializeContext(ctx)));
998
+ };
999
+
1000
+ // `crypto.randomUUID` is secure-context-only: on an `http://` storefront it is
1001
+ // undefined and calling it throws a TypeError. Called bare, that threw from
1002
+ // inside the autocomplete and image-search render paths — after results were
1003
+ // already assigned — so the catch cleared them and the shopper saw an error
1004
+ // state for a working search. `crypto.getRandomValues` has no such
1005
+ // restriction, so it carries the fallback; Math.random is the last resort for
1006
+ // an environment with no crypto at all. The output stays RFC4122-v4 shaped
1007
+ // either way, because the worker's beacon validators reject anything else.
1008
+ const randomBytes16 = () => {
1009
+ const bytes = new Uint8Array(16);
1010
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
1011
+ crypto.getRandomValues(bytes);
1012
+ return bytes;
1013
+ }
1014
+ for (let i = 0; i < bytes.length; i++) {
1015
+ bytes[i] = Math.floor(Math.random() * 256);
1016
+ }
1017
+ return bytes;
1018
+ };
1019
+ const mintId = () => {
1020
+ var _a, _b;
1021
+ if (typeof crypto !== 'undefined' &&
1022
+ typeof crypto.randomUUID === 'function') {
1023
+ return crypto.randomUUID();
1024
+ }
1025
+ const bytes = randomBytes16();
1026
+ // Version 4, variant 10xx — the two bits the shape check would otherwise
1027
+ // read as a malformed id.
1028
+ bytes[6] = (((_a = bytes[6]) !== null && _a !== void 0 ? _a : 0) & 0x0f) | 0x40;
1029
+ bytes[8] = (((_b = bytes[8]) !== null && _b !== void 0 ? _b : 0) & 0x3f) | 0x80;
1030
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
1031
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1032
+ };
857
1033
  // 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.
858
1034
  const previewKeyName = 'previewEvoSearch';
859
1035
  const hasPreviewKey = () => {
@@ -878,6 +1054,101 @@ const hasDisableKey = () => {
878
1054
  return false;
879
1055
  }
880
1056
  };
1057
+ // Cross-widget, cross-search shopper context (recently viewed products,
1058
+ // finalized queries, entry page) that rides along on every search
1059
+ // request as a `ctx` query param and every finalized-query beacon, so
1060
+ // ranking/telemetry can see the visit's shape without the worker
1061
+ // maintaining its own session. A query param, not a header, so /search
1062
+ // stays a CORS-simple request (no preflight on the per-keystroke hot path).
1063
+ // sessionStorage-scoped like the rest of this file's keys: it dies with the
1064
+ // tab and never needs a retention policy. No PII — see session-context.ts.
1065
+ const sessionContextKeyName = 'evoSearchSessionContext';
1066
+ const emptySessionContext = () => ({
1067
+ viewed: [],
1068
+ cart: [],
1069
+ queries: [],
1070
+ entryPage: detectEntryPage(typeof window !== 'undefined' ? window.location.pathname : ''),
1071
+ });
1072
+ const getSessionContext = () => {
1073
+ try {
1074
+ const raw = sessionStorage.getItem(sessionContextKeyName);
1075
+ if (raw) {
1076
+ const parsed = JSON.parse(raw);
1077
+ // Element types are checked, not just array-ness: a corrupted stored
1078
+ // value (e.g. viewed: [123]) must not flow onto the wire as non-string
1079
+ // data. Fall through to a fresh context instead, same as the
1080
+ // entryPage enum check below.
1081
+ const isStringArray = (value) => Array.isArray(value) && value.every((v) => typeof v === 'string');
1082
+ // Absent, not invalid: a tab that started on an older SDK has a stored
1083
+ // context with no `cart` key at all. Reading that as a shape failure
1084
+ // would throw away its viewed/queries history on every upgrade, so a
1085
+ // missing list reads as an empty one; a wrong-typed one still falls
1086
+ // through to a fresh context like any other corrupted array.
1087
+ const storedCart = parsed.cart === undefined ? [] : parsed.cart;
1088
+ if (isStringArray(parsed.viewed) &&
1089
+ isStringArray(parsed.queries) &&
1090
+ isStringArray(storedCart)) {
1091
+ return {
1092
+ viewed: parsed.viewed,
1093
+ cart: storedCart,
1094
+ queries: parsed.queries,
1095
+ // A hostile/corrupted stored value (or a future enum member from a
1096
+ // newer SDK version) falls back to 'other' rather than discarding
1097
+ // otherwise-good viewed/queries data, and rather than passing
1098
+ // an unvalidated string through to the wire.
1099
+ entryPage: isPageType(parsed.entryPage) ? parsed.entryPage : 'other',
1100
+ };
1101
+ }
1102
+ }
1103
+ }
1104
+ catch {
1105
+ // Storage unavailable or corrupt; fall through to a fresh context.
1106
+ }
1107
+ // First read this session: detect entryPage once and persist immediately
1108
+ // so a later navigation within the same tab doesn't re-detect it from a
1109
+ // different path.
1110
+ const fresh = emptySessionContext();
1111
+ setSessionContext(fresh);
1112
+ return fresh;
1113
+ };
1114
+ /** @internal */
1115
+ const setSessionContext = (ctx) => {
1116
+ try {
1117
+ sessionStorage.setItem(sessionContextKeyName, serializeContext(ctx));
1118
+ }
1119
+ catch {
1120
+ // Storage unavailable; the context is best-effort telemetry, not
1121
+ // functionality — swallow rather than fail whatever caller triggered it.
1122
+ }
1123
+ };
1124
+ // base64url-encoded form for the `ctx` search query param — null when
1125
+ // there's nothing worth adding to the URL yet (see encodeContextParam).
1126
+ // api.ts stays decoupled from session-context's internals. Memoized because
1127
+ // search() calls this on every debounced keystroke: the cache is keyed on
1128
+ // the raw stored string, so a hit costs one getItem + compare instead of the
1129
+ // parse/serialize/encode chain, and any writer (not just setSessionContext)
1130
+ // invalidates it naturally.
1131
+ let cachedContextRaw = null;
1132
+ let cachedContextParam = null;
1133
+ const getSessionContextParam = () => {
1134
+ let raw = null;
1135
+ try {
1136
+ raw = sessionStorage.getItem(sessionContextKeyName);
1137
+ }
1138
+ catch {
1139
+ // Storage unavailable — nothing stable to memoize against.
1140
+ return encodeContextParam(getSessionContext());
1141
+ }
1142
+ if (raw !== null && raw === cachedContextRaw) {
1143
+ return cachedContextParam;
1144
+ }
1145
+ const ctx = getSessionContext();
1146
+ cachedContextParam = encodeContextParam(ctx);
1147
+ // A null raw means getSessionContext just persisted a fresh context — key
1148
+ // the cache on what it wrote so the next call hits.
1149
+ cachedContextRaw = raw !== null && raw !== void 0 ? raw : serializeContext(ctx);
1150
+ return cachedContextParam;
1151
+ };
881
1152
 
882
1153
  var _a;
883
1154
  const registryKey = Symbol.for('evo-search');
@@ -914,6 +1185,260 @@ const notifyReady = () => {
914
1185
  notifySubscribers(registry.subscribers, 'notifyReady');
915
1186
  };
916
1187
 
1188
+ /**
1189
+ * Storefront-local helpers, deliberately separate from the worker-scoped API
1190
+ * client: `api.ts` injects identity params and headers that must never reach
1191
+ * the merchant's own origin, and its `credentials: omit` policy is the
1192
+ * opposite of what storefront requests (which ride the cart cookie) need.
1193
+ */
1194
+ /**
1195
+ * The storefront's root path. Shopify publishes it (locale prefix included,
1196
+ * e.g. `/en-ca/`) on `window.Shopify.routes.root`; default to `/`.
1197
+ */
1198
+ const storefrontRoot = () => {
1199
+ var _a, _b;
1200
+ if (typeof window === 'undefined') {
1201
+ return '/';
1202
+ }
1203
+ const root = (_b = (_a = window.Shopify) === null || _a === void 0 ? void 0 : _a.routes) === null || _b === void 0 ? void 0 : _b.root;
1204
+ return typeof root === 'string' && root.startsWith('/') && root.endsWith('/')
1205
+ ? root
1206
+ : '/';
1207
+ };
1208
+
1209
+ // Storefront-side producers for the shopper session context. `session-context.ts`
1210
+ // knows how to grow the shape and `utils.ts` how to persist it; this module is
1211
+ // the only place that decides, from the page itself, WHAT to put in it.
1212
+ //
1213
+ // Why it exists: the widget's product-click handler is the only other producer,
1214
+ // so a shopper who browses the storefront normally — landing on product pages
1215
+ // from a collection, an ad, or a link — contributes nothing to ranking until
1216
+ // they happen to click a result inside search. Capturing the product page the
1217
+ // shopper is standing on closes that gap.
1218
+ //
1219
+ // Constraints this module works under, all deliberate:
1220
+ // - No network calls. This runs on the merchant's storefront; every id comes
1221
+ // from something already in the page (a global the platform published, or
1222
+ // the page's own structured data) or from a host that hands it to us.
1223
+ // - No PII, no page text. Ids only — never titles, prices or descriptions.
1224
+ // - Never throws. Every source is optional and best-effort: a page that
1225
+ // exposes none of them simply contributes nothing.
1226
+ // - No consent gate of its own. The context is anonymous, client-held and
1227
+ // dies with the tab; the provider is loaded under the pixel's own consent
1228
+ // decision, so a suppressed init means no capture at all, and the
1229
+ // transport (the `ctx` search param) stays gated on the merchant opt-in
1230
+ // that api.ts checks.
1231
+ const shopifyMeta = () => {
1232
+ var _a, _b;
1233
+ const w = window;
1234
+ // Two spellings of the same platform-published object: themes render the
1235
+ // analytics one, and some render a bare `meta` as well. Neither is catalog
1236
+ // data — they are the platform's page-scoped conventions.
1237
+ return (_b = (_a = w.ShopifyAnalytics) === null || _a === void 0 ? void 0 : _a.meta) !== null && _b !== void 0 ? _b : w.meta;
1238
+ };
1239
+ /**
1240
+ * True when the page the shopper is on is a product page, by the two signals
1241
+ * a storefront publishes without us asking: the platform's own page-type
1242
+ * marker, and the product path shape under the storefront root (which carries
1243
+ * the locale prefix on localized stores, so `/fr/products/…` is recognized).
1244
+ */
1245
+ const onProductPage = () => {
1246
+ var _a, _b;
1247
+ if (((_b = (_a = shopifyMeta()) === null || _a === void 0 ? void 0 : _a.page) === null || _b === void 0 ? void 0 : _b.pageType) === 'product') {
1248
+ return true;
1249
+ }
1250
+ return window.location.pathname.startsWith(`${storefrontRoot()}products/`);
1251
+ };
1252
+ // Structured data is the last resort and the only source read out of the DOM,
1253
+ // so it is bounded on every axis: how many blocks are parsed, how big a block
1254
+ // may be, and how deep the search for a Product node goes.
1255
+ const MAX_LD_BLOCKS = 5;
1256
+ // A structured-data block far larger than this is a page-wide graph, not one
1257
+ // product's node; parsing it would cost more than the signal is worth.
1258
+ const MAX_LD_CHARS = 100000;
1259
+ const productIdFromLdNode = (node) => {
1260
+ if (!node || typeof node !== 'object') {
1261
+ return null;
1262
+ }
1263
+ if (Array.isArray(node)) {
1264
+ for (const entry of node) {
1265
+ const found = productIdFromLdNode(entry);
1266
+ if (found) {
1267
+ return found;
1268
+ }
1269
+ }
1270
+ return null;
1271
+ }
1272
+ const record = node;
1273
+ const graph = record['@graph'];
1274
+ if (Array.isArray(graph)) {
1275
+ return productIdFromLdNode(graph);
1276
+ }
1277
+ const type = record['@type'];
1278
+ const isProduct = Array.isArray(type)
1279
+ ? type.includes('Product')
1280
+ : type === 'Product';
1281
+ if (!isProduct) {
1282
+ return null;
1283
+ }
1284
+ const id = normalizeProductId(record['productID']);
1285
+ // Only an all-digits id is accepted here, and `sku` is never read: the
1286
+ // catalog is keyed on the platform's own numeric product id, whereas a
1287
+ // merchant SKU (or a handle) is a different id space entirely. An id from
1288
+ // the wrong space can never resolve server-side — it would silently occupy
1289
+ // one of the few recently-viewed slots and contribute nothing. A host whose
1290
+ // structured data carries something else should call `trackProductView`.
1291
+ return id && /^\d+$/.test(id) ? id : null;
1292
+ };
1293
+ const productIdFromStructuredData = () => {
1294
+ const blocks = document.querySelectorAll('script[type="application/ld+json"]');
1295
+ const limit = Math.min(blocks.length, MAX_LD_BLOCKS);
1296
+ for (let i = 0; i < limit; i++) {
1297
+ const text = blocks[i].textContent;
1298
+ if (!text || text.length > MAX_LD_CHARS) {
1299
+ continue;
1300
+ }
1301
+ try {
1302
+ const found = productIdFromLdNode(JSON.parse(text));
1303
+ if (found) {
1304
+ return found;
1305
+ }
1306
+ }
1307
+ catch {
1308
+ // A malformed block is one merchant app's problem, not ours; try the
1309
+ // next one.
1310
+ }
1311
+ }
1312
+ return null;
1313
+ };
1314
+ /**
1315
+ * The id of the product this page is about, or null when the page isn't a
1316
+ * product page (or doesn't say which product it is). Sources are tried
1317
+ * cheapest first and each one is optional.
1318
+ */
1319
+ const currentProductId = () => {
1320
+ var _a, _b;
1321
+ try {
1322
+ // The platform global is authoritative and free: it is only populated on
1323
+ // a product page, and it carries the id in the catalog's own id space.
1324
+ const fromGlobal = normalizeProductId((_b = (_a = shopifyMeta()) === null || _a === void 0 ? void 0 : _a.product) === null || _b === void 0 ? void 0 : _b.id);
1325
+ if (fromGlobal) {
1326
+ return fromGlobal;
1327
+ }
1328
+ if (!onProductPage()) {
1329
+ return null;
1330
+ }
1331
+ return productIdFromStructuredData();
1332
+ }
1333
+ catch {
1334
+ return null;
1335
+ }
1336
+ };
1337
+ const rememberView = (id) => {
1338
+ const ctx = getSessionContext();
1339
+ // Once per page load: a re-entrant call (a second capture attempt, a
1340
+ // re-init) must not churn storage for a product that is already the most
1341
+ // recent view. A LATER load of the same product is a different matter — by
1342
+ // then something else is at the head, so it pushes again and moves back to
1343
+ // the front, which is how recency is expressed. There is no repeat counter:
1344
+ // `viewed` is a deduplicated, newest-first list and the server tallies each
1345
+ // id once.
1346
+ if (ctx.viewed[0] === id) {
1347
+ return;
1348
+ }
1349
+ setSessionContext(pushViewed(ctx, id));
1350
+ };
1351
+ /**
1352
+ * Records the product page the shopper is currently on, if this is one.
1353
+ * Returns whether anything was recorded (the retry below is its only caller
1354
+ * that cares).
1355
+ */
1356
+ const captureProductView = () => {
1357
+ try {
1358
+ const id = currentProductId();
1359
+ if (!id) {
1360
+ return false;
1361
+ }
1362
+ rememberView(id);
1363
+ return true;
1364
+ }
1365
+ catch {
1366
+ // Storage or DOM unavailable — the context is best-effort ranking input,
1367
+ // never functionality.
1368
+ return false;
1369
+ }
1370
+ };
1371
+ /**
1372
+ * Called once from the SDK bootstrap. The platform's page globals are written
1373
+ * by the theme's own head markup, so a loader that runs ahead of them would
1374
+ * see nothing at init; when the document is still parsing and nothing was
1375
+ * found, try again once the DOM is complete.
1376
+ */
1377
+ const startProductViewCapture = () => {
1378
+ try {
1379
+ if (captureProductView()) {
1380
+ return;
1381
+ }
1382
+ if (document.readyState === 'loading') {
1383
+ document.addEventListener('DOMContentLoaded', () => {
1384
+ captureProductView();
1385
+ }, { once: true });
1386
+ }
1387
+ }
1388
+ catch {
1389
+ // See captureProductView.
1390
+ }
1391
+ };
1392
+
1393
+ const createSessionContextGate = (uiPreferences, fetchConfig,
1394
+ // Whether this shopper has accumulated anything worth sending yet. Spending
1395
+ // a request to ask permission to send nothing is the one case where the
1396
+ // probe is pure waste, and it is also the common one — a session that has
1397
+ // just started has an empty context.
1398
+ hasContext) => {
1399
+ let serverEnabled = false;
1400
+ // One probe per API instance, latched BEFORE the request is issued so a
1401
+ // slow or failing config fetch can never turn the per-keystroke search path
1402
+ // into a retry storm. A probe that fails leaves the gate off, which is the
1403
+ // safe answer: the context is best-effort ranking input, never correctness.
1404
+ let probed = false;
1405
+ const prime = () => {
1406
+ // An explicit merchant preference already decides the question in both
1407
+ // directions, so there is nothing to ask the server about.
1408
+ if (probed || (uiPreferences === null || uiPreferences === void 0 ? void 0 : uiPreferences.sessionContext) !== undefined) {
1409
+ return;
1410
+ }
1411
+ // Deliberately does NOT latch `probed`: a session with nothing to say yet
1412
+ // will have something to say later, and that later call is the one worth
1413
+ // spending the probe on.
1414
+ if (!hasContext()) {
1415
+ return;
1416
+ }
1417
+ probed = true;
1418
+ // Deferred to a microtask so the request that triggered the prime is
1419
+ // already on the wire: the probe must never compete with, delay, or
1420
+ // reorder the search it rides behind.
1421
+ void Promise.resolve().then(() => fetchConfig().catch(() => {
1422
+ // Swallowed deliberately — the gate simply stays off. The caller that
1423
+ // triggered this never learns of it, and never retries.
1424
+ }));
1425
+ };
1426
+ return {
1427
+ shouldSend: () => {
1428
+ var _a;
1429
+ prime();
1430
+ // The merchant preference is an explicit override in both directions;
1431
+ // absent — the normal state — defers to the server's last answer.
1432
+ return (_a = uiPreferences === null || uiPreferences === void 0 ? void 0 : uiPreferences.sessionContext) !== null && _a !== void 0 ? _a : serverEnabled;
1433
+ },
1434
+ prime,
1435
+ resolveFrom: (sessionContextEnabled) => {
1436
+ probed = true;
1437
+ serverEnabled = sessionContextEnabled === true;
1438
+ },
1439
+ };
1440
+ };
1441
+
917
1442
  // Resilience wrapper for /search requests. A transient network failure or a
918
1443
  // hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
919
1444
  // a rejection), retry transient failures with backoff, and never retry a
@@ -1150,6 +1675,13 @@ const downscaleImage = async (source) => {
1150
1675
  // Ask can't reuse withSearchResilience: a half-consumed stream isn't retryable.
1151
1676
  // Idle timeout resets per chunk; both reject with TimeoutError so submitAsk
1152
1677
  // routes them to the search fallback rather than treating them as cancellation.
1678
+ // Ceiling for an assembled GET URL, past which the optional session-context
1679
+ // param is dropped (see getJSON). Chosen above the encoder's own worst case —
1680
+ // session-context.ts caps its payload at 2KB, which base64url-grows to ~2.7KB
1681
+ // — so a legitimately full context is never dropped on its own, and several
1682
+ // times under the request-line ceiling of the edge that terminates these
1683
+ // requests. It is a safety bound, not a tuning knob.
1684
+ const MAX_REQUEST_URL_CHARS = 4096;
1153
1685
  const QUICK_ADD_VALIDATE_TIMEOUT_MS = 7000;
1154
1686
  const ASK_REQUEST_TIMEOUT_MS = 7000;
1155
1687
  const ASK_SSE_IDLE_TIMEOUT_MS = 10000;
@@ -1163,6 +1695,16 @@ const KEEP_WARM_INTERVAL_MS = 60000;
1163
1695
  // Stand-in caller signal for `search()` calls that don't pass one. Module-level
1164
1696
  // and never aborted, so it carries no state between calls.
1165
1697
  const NEVER_ABORTED = new AbortController().signal;
1698
+ // Tells /search this client resolves the response's product and collection
1699
+ // lanes against the top-level arrays itself, so the lanes need only carry ids.
1700
+ // Without it the server repeats every lane entry in full, which roughly doubles
1701
+ // the body for entries the client already has. Sent on both search legs so
1702
+ // neither pays for the duplication.
1703
+ //
1704
+ // Opt-in from the client, not a server-side switch: the client is published
1705
+ // separately from the worker, so a storefront can be running any earlier
1706
+ // version of it. One that never sends this keeps receiving whole entries.
1707
+ const LANE_REFERENCES = { lanes: 'ids' };
1166
1708
  // Always defer to what the storefront itself is rendering — same source
1167
1709
  // shop-gpt reads from. Two widgets on the same page would otherwise drift:
1168
1710
  // shop-gpt picks Shopify, evo-search would pick its own override and show
@@ -1293,6 +1835,16 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
1293
1835
  if (!credentialed) {
1294
1836
  setIdentityParams(url);
1295
1837
  }
1838
+ // Measured only here, where the URL is finally assembled: the results-page
1839
+ // leg carries facets, filter selections, a continuation cursor and identity
1840
+ // on top of the query, so its length is not knowable at the call site. A
1841
+ // session context is a ranking hint, so an over-long URL drops the hint
1842
+ // rather than the request — a slightly less personalized answer beats a
1843
+ // refused one.
1844
+ if (url.href.length > MAX_REQUEST_URL_CHARS &&
1845
+ url.searchParams.has('ctx')) {
1846
+ url.searchParams.delete('ctx');
1847
+ }
1296
1848
  const response = await fetchImpl(url, {
1297
1849
  method: 'GET',
1298
1850
  ...(credentialed ? { headers: getHeaders() } : {}),
@@ -1312,9 +1864,21 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
1312
1864
  logger.info('EvoSearch API: Fetching trending searches');
1313
1865
  return getJSON('/trending', 'Trending', undefined, signal);
1314
1866
  };
1315
- const config = (signal) => {
1867
+ // Held in the gate rather than read off `uiPreferences` at each send site:
1868
+ // that object is captured by value when this API instance is built, and the
1869
+ // widget replaces its own copy after fetching config, so an overlay there
1870
+ // would never reach this closure. The gate starts closed, so a search that
1871
+ // races ahead of the first answer sends nothing — session context is
1872
+ // best-effort ranking input, never a correctness input. `config` is
1873
+ // referenced lazily (only once the probe fires, long after this line runs).
1874
+ const sessionContextGate = createSessionContextGate(uiPreferences, () => config(), () => getSessionContextParam() !== null);
1875
+ const config = async (signal) => {
1316
1876
  logger.info('EvoSearch API: Fetching ask config');
1317
- return getJSON('/config', 'Config', undefined, signal);
1877
+ const data = await getJSON('/config', 'Config', undefined, signal);
1878
+ // A worker deployed before this gate omits the field; an absent answer
1879
+ // must read as off rather than as an opt-in.
1880
+ sessionContextGate.resolveFrom(data.sessionContextEnabled);
1881
+ return data;
1318
1882
  };
1319
1883
  const searchPageDesign = (signal, previewToken) => {
1320
1884
  logger.info(`EvoSearch API: Fetching ${previewToken ? 'preview' : 'published'} search page design`);
@@ -1384,11 +1948,38 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
1384
1948
  };
1385
1949
  const deleteRecent = (value) => sendMutation('/user/recents', 'DeleteRecent', 'DELETE', { value });
1386
1950
  const clearRecents = () => sendMutation('/user/recents', 'ClearRecents', 'DELETE');
1951
+ // The single decision point for whether a request carries the shopper's
1952
+ // session context, shared by both search legs so the autocomplete and
1953
+ // results-page requests cannot drift apart on when it is sent — the results
1954
+ // page is where a shopper spends the most time, so a leg without it is a
1955
+ // silently unpersonalized surface.
1956
+ //
1957
+ // Best-effort shopper context (recently viewed, past queries, entry page)
1958
+ // for server-side ranking, capped well under 2KB by session-context.ts.
1959
+ // Carried as a query param rather than a header so /search stays a
1960
+ // CORS-simple request — no OPTIONS preflight on the per-keystroke hot path.
1961
+ // Omitted while there's nothing to say yet. Gated on the server, which is
1962
+ // the only side that knows whether a `ctx` consumer is live: sending browsing
1963
+ // history nobody reads is pure cost and pure exposure. The merchant
1964
+ // preference is an explicit override in both directions — set, it wins;
1965
+ // absent (the normal state), the server decides.
1966
+ const withSessionContext = (params) => {
1967
+ if (!sessionContextGate.shouldSend()) {
1968
+ return params;
1969
+ }
1970
+ const ctxParam = getSessionContextParam();
1971
+ return ctxParam ? { ...params, ctx: ctxParam } : params;
1972
+ };
1387
1973
  // Resilience lives here, not at each call site, so no consumer can opt out —
1388
1974
  // the headless channel previously had no deadline and no retry.
1389
1975
  const search = async (query, limit = 24, signal) => {
1390
1976
  logger.info(`EvoSearch API: Search request - query length: ${query.length}, limit: ${limit}`);
1391
- const data = await withSearchResilience((attemptSignal) => getJSON('/search', 'Search', { q: query, limit: String(limit) }, attemptSignal),
1977
+ const searchParams = withSessionContext({
1978
+ q: query,
1979
+ limit: String(limit),
1980
+ ...LANE_REFERENCES,
1981
+ });
1982
+ const data = await withSearchResilience((attemptSignal) => getJSON('/search', 'Search', searchParams, attemptSignal),
1392
1983
  // Shared stand-in is safe: the wrapper removes its abort listener per attempt.
1393
1984
  signal !== null && signal !== void 0 ? signal : NEVER_ABORTED);
1394
1985
  logger.info(`EvoSearch API: Search success - ${data.llmSuggestions.length} suggestions, ${data.products.length} products`);
@@ -1402,6 +1993,7 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
1402
1993
  const params = {
1403
1994
  q: query,
1404
1995
  facets: '1',
1996
+ ...LANE_REFERENCES,
1405
1997
  };
1406
1998
  if (options.limit != null) {
1407
1999
  params['limit'] = String(options.limit);
@@ -1435,7 +2027,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
1435
2027
  if ((filters === null || filters === void 0 ? void 0 : filters.priceMax) != null) {
1436
2028
  params['priceMax'] = String(filters.priceMax);
1437
2029
  }
1438
- return withSearchResilience((attemptSignal) => getJSON('/search', 'SearchResults', params, attemptSignal), (_f = options.signal) !== null && _f !== void 0 ? _f : NEVER_ABORTED);
2030
+ return withSearchResilience((attemptSignal) => getJSON('/search', 'SearchResults',
2031
+ // Applied per attempt, and to every page: a continuation (cursor) or
2032
+ // a numbered page (offset) is the same result set the shopper is
2033
+ // still reading, so dropping the context there would re-rank the
2034
+ // next page against a different profile than the first.
2035
+ withSessionContext(params), attemptSignal), (_f = options.signal) !== null && _f !== void 0 ? _f : NEVER_ABORTED);
1439
2036
  };
1440
2037
  // Same deadline as `validateQuickAdd`, and for the same reason: the only
1441
2038
  // caller passes no signal, so without one a stalled request never settles —
@@ -1692,7 +2289,7 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
1692
2289
  .catch(logger.error);
1693
2290
  // Also send to analytics via sendTag
1694
2291
  sendTag({
1695
- eventId: crypto.randomUUID(),
2292
+ eventId: mintId(),
1696
2293
  eventName: action,
1697
2294
  data: {
1698
2295
  ...actionData,
@@ -1760,6 +2357,17 @@ const parseSseFrame = (frame) => {
1760
2357
  }
1761
2358
  };
1762
2359
 
2360
+ const FORCED_KEY = 'evsUiVersion';
2361
+ // ?evs=reset must also drop the QA force so a shopper returns to the configured version.
2362
+ const clearForcedUiVersion = () => {
2363
+ try {
2364
+ sessionStorage.removeItem(FORCED_KEY);
2365
+ }
2366
+ catch {
2367
+ /* no-op: storage unavailable */
2368
+ }
2369
+ };
2370
+
1763
2371
  const error = (message) => console.error(message);
1764
2372
  // Headless hosts render their own UI, so nothing else pings /warm for them.
1765
2373
  let disposeKeepWarm;
@@ -1832,6 +2440,7 @@ const init = (params) => {
1832
2440
  try {
1833
2441
  sessionStorage.removeItem(previewKeyName);
1834
2442
  sessionStorage.removeItem(disableKeyName);
2443
+ clearForcedUiVersion();
1835
2444
  logger.info('EvoSearch SDK: Reset to configured state via ?evs=reset URL param');
1836
2445
  }
1837
2446
  catch {
@@ -1851,6 +2460,13 @@ const init = (params) => {
1851
2460
  if (hasPreview) {
1852
2461
  logger.info('EvoSearch SDK: Preview mode enabled via sessionStorage');
1853
2462
  }
2463
+ // Before the UI/headless fork: a headless host returns early below, and
2464
+ // its shoppers browse product pages exactly like everyone else's. Not
2465
+ // gated on the session-context opt-in — that flag gates the TRANSPORT
2466
+ // (api.ts decides whether the context leaves the browser), and gating
2467
+ // capture too would leave a merchant who turns it on with an empty
2468
+ // history until the shopper navigates again.
2469
+ startProductViewCapture();
1854
2470
  const evoSearchAPI = createEvoSearchAPI({
1855
2471
  baseURL: params.baseUrl,
1856
2472
  userId: params.userId,
@@ -1862,7 +2478,7 @@ const init = (params) => {
1862
2478
  const uiImplementation = window[registryKey].ui;
1863
2479
  const headless = !uiImplementation;
1864
2480
  params.sendTag({
1865
- eventId: crypto.randomUUID(),
2481
+ eventId: mintId(),
1866
2482
  eventName: 'evoSearchInitialized',
1867
2483
  data: { mode, hasPreview, headless },
1868
2484
  providers: { evoSearch: true },
@@ -1894,7 +2510,7 @@ const init = (params) => {
1894
2510
  visualSearchEnabled,
1895
2511
  });
1896
2512
  params.sendTag({
1897
- eventId: crypto.randomUUID(),
2513
+ eventId: mintId(),
1898
2514
  eventName: 'evoSearchLoaded',
1899
2515
  data: {},
1900
2516
  providers: { evoSearch: true },
@@ -1914,7 +2530,7 @@ const init = (params) => {
1914
2530
  const tag = ({ data }) => {
1915
2531
  try {
1916
2532
  return {
1917
- sdkVersion: "1.70.2" || '',
2533
+ sdkVersion: "1.71.1" || '',
1918
2534
  searchQuery: data['searchQuery'],
1919
2535
  searchResults: data['searchResults'],
1920
2536
  timestamp: Date.now(),
@@ -1923,7 +2539,7 @@ const tag = ({ data }) => {
1923
2539
  catch (err) {
1924
2540
  console.error(`EvoSearch SDK: Tagging failed - ${err}`);
1925
2541
  return {
1926
- sdkVersion: "1.70.2" ,
2542
+ sdkVersion: "1.71.1" ,
1927
2543
  timestamp: Date.now(),
1928
2544
  };
1929
2545
  }