@thg-agentic/hyper-personalisation-web 3.19.0 → 3.22.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 +73 -27
- package/dist/hp.min.js +3 -3
- package/package.json +2 -2
- package/src/bootstrap.js +9 -3
- package/src/consent-gate.js +9 -1
- package/src/consent.js +23 -17
- package/src/context.js +17 -2
- package/src/index.js +91 -20
- package/src/segment.js +17 -9
- package/src/session/record.js +70 -10
- package/src/session/rules.js +26 -1
- package/src/session/signals.js +6 -0
- package/src/session.js +1 -0
- package/src/settings.js +25 -1
- package/src/spec-version.js +1 -1
- package/src/transport.js +58 -8
- package/src/version.js +1 -1
- package/types/bootstrap.d.ts +1 -1
- package/types/events.d.ts +49 -49
- package/types/index.d.ts +0 -1
package/src/index.js
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
import { assignmentSession, surfaceExposureToken } from './assignment.js';
|
|
46
46
|
import { hostConsent, servingHeaders } from './consent.js';
|
|
47
47
|
import { createConsentGate } from './consent-gate.js';
|
|
48
|
-
import { buildContext, getCookie, perRequest } from './context.js';
|
|
48
|
+
import { buildContext, getCookie, perRequest, scrubHref } from './context.js';
|
|
49
49
|
import { createDecisionMemory, servedDecisions } from './decisions.js';
|
|
50
50
|
import { createHeldStore, deviceIdFrom } from './device-storage.js';
|
|
51
51
|
import {
|
|
@@ -183,6 +183,64 @@ export function normaliseSha256(value) {
|
|
|
183
183
|
return hex === EMPTY_SHA256 ? null : hex;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/** The identify traits the spec defines (core/identify, `additionalProperties: false`), by type. */
|
|
187
|
+
export const IDENTIFY_TRAITS = Object.freeze({
|
|
188
|
+
email_sha256: 'sha256',
|
|
189
|
+
phone_sha256: 'sha256',
|
|
190
|
+
accepts_marketing: 'boolean',
|
|
191
|
+
gender: 'string',
|
|
192
|
+
created_at: 'date-time',
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const RFC3339 =
|
|
196
|
+
/^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d+)?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)$/i;
|
|
197
|
+
|
|
198
|
+
/** An RFC 3339 date-time naming a real day: 2026-02-30 is not one, though Date.parse rolls it on. */
|
|
199
|
+
function isDateTime(value) {
|
|
200
|
+
if (typeof value !== 'string' || !RFC3339.test(value)) return false;
|
|
201
|
+
const [year, month, day] = value.slice(0, 10).split('-').map(Number);
|
|
202
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
203
|
+
return (
|
|
204
|
+
date.getUTCMonth() === month - 1 &&
|
|
205
|
+
date.getUTCDate() === day &&
|
|
206
|
+
!Number.isNaN(Date.parse(value))
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** One trait as the spec takes it, or undefined when it is not one: then it is not sent. */
|
|
211
|
+
function identifyTrait(kind, value) {
|
|
212
|
+
if (value === null) return null;
|
|
213
|
+
if (kind === 'sha256') return normaliseSha256(value) ?? undefined;
|
|
214
|
+
if (kind === 'date-time') {
|
|
215
|
+
if (value instanceof Date)
|
|
216
|
+
return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
|
|
217
|
+
return isDateTime(value) ? value : undefined;
|
|
218
|
+
}
|
|
219
|
+
return typeof value === kind ? value : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Only the traits the spec defines, each of its type: a raw email or phone never leaves the page, and
|
|
224
|
+
* an identify is never quarantined for an extra key. Digests in hex or base64 are sent as hex.
|
|
225
|
+
*/
|
|
226
|
+
function identifyTraits(traits, debug) {
|
|
227
|
+
const kept = {};
|
|
228
|
+
const dropped = [];
|
|
229
|
+
for (const [key, value] of Object.entries(traits)) {
|
|
230
|
+
const kind = Object.hasOwn(IDENTIFY_TRAITS, key)
|
|
231
|
+
? IDENTIFY_TRAITS[key]
|
|
232
|
+
: null;
|
|
233
|
+
const trait = kind ? identifyTrait(kind, value) : undefined;
|
|
234
|
+
if (trait === undefined) dropped.push(key);
|
|
235
|
+
else kept[key] = trait;
|
|
236
|
+
}
|
|
237
|
+
if (debug && dropped.length)
|
|
238
|
+
console.warn(
|
|
239
|
+
`[hp] identify: not sent, not spec traits: ${dropped.join(', ')}`,
|
|
240
|
+
);
|
|
241
|
+
return kept;
|
|
242
|
+
}
|
|
243
|
+
|
|
186
244
|
// On the main entry so integrators find them: without an assigned variant a rail is unmeasurable.
|
|
187
245
|
export {
|
|
188
246
|
assignRecommendationVariant,
|
|
@@ -336,6 +394,13 @@ export function createAnalytics(options) {
|
|
|
336
394
|
}
|
|
337
395
|
};
|
|
338
396
|
|
|
397
|
+
// A document restored from the back/forward cache resumes after the pages it led to: the tab's
|
|
398
|
+
// last page view is the one in storage, not this document's own.
|
|
399
|
+
if (typeof window !== 'undefined')
|
|
400
|
+
window.addEventListener('pageshow', (event) => {
|
|
401
|
+
if (event.persisted) lastPageMessageId = null;
|
|
402
|
+
});
|
|
403
|
+
|
|
339
404
|
const writeLastPageMessageId = (id) => {
|
|
340
405
|
lastPageMessageId = id || null;
|
|
341
406
|
if (strict && gate.status !== 'granted') return;
|
|
@@ -375,6 +440,10 @@ export function createAnalytics(options) {
|
|
|
375
440
|
function compose(partial, ctxOverrides, force) {
|
|
376
441
|
const made = replaying;
|
|
377
442
|
replaying = null;
|
|
443
|
+
// The plan is keyed as the gateway keys it: a track's name, otherwise the call's type. A
|
|
444
|
+
// withdrawal is the record of the refusal, so no plan drops it.
|
|
445
|
+
const key = partial.type === 'track' ? partial.event : partial.type;
|
|
446
|
+
if (!force && !planAllows(cfg.plan, key)) return null;
|
|
378
447
|
let event = {
|
|
379
448
|
messageId: made?.messageId ?? uuid(),
|
|
380
449
|
timestamp: made?.timestamp ?? new Date().toISOString(),
|
|
@@ -385,6 +454,8 @@ export function createAnalytics(options) {
|
|
|
385
454
|
};
|
|
386
455
|
|
|
387
456
|
if (event.properties) event.properties = pruneProperties(event.properties);
|
|
457
|
+
if (typeof event.properties?.href === 'string')
|
|
458
|
+
event.properties.href = scrubHref(event.properties.href, cfg);
|
|
388
459
|
|
|
389
460
|
// Outside the source's sample (3.18): nothing about this visitor is sent.
|
|
390
461
|
if (!force && !sampledIn(event.anonymousId, cfg.sampleRate)) {
|
|
@@ -514,9 +585,7 @@ export function createAnalytics(options) {
|
|
|
514
585
|
}
|
|
515
586
|
|
|
516
587
|
const track = (name, properties, ctx) =>
|
|
517
|
-
|
|
518
|
-
? emit({ type: 'track', event: name, properties: properties ?? {} }, ctx)
|
|
519
|
-
: null;
|
|
588
|
+
emit({ type: 'track', event: name, properties: properties ?? {} }, ctx);
|
|
520
589
|
|
|
521
590
|
// One exposure event per serving response, however often the page reuses it.
|
|
522
591
|
const exposedTokens = new Set();
|
|
@@ -588,6 +657,8 @@ export function createAnalytics(options) {
|
|
|
588
657
|
// forgets the signals an earlier page kept under the permission.
|
|
589
658
|
if (state?.personalisation !== true)
|
|
590
659
|
forgetKeptVisit(previous?.personalisation === true);
|
|
660
|
+
// Events collected under the grant are not sent after it is withdrawn: only the withdrawal.
|
|
661
|
+
if (withdrawal) transport.discardQueued?.();
|
|
591
662
|
emit(
|
|
592
663
|
{
|
|
593
664
|
type: 'track',
|
|
@@ -682,9 +753,19 @@ export function createAnalytics(options) {
|
|
|
682
753
|
);
|
|
683
754
|
}
|
|
684
755
|
|
|
756
|
+
/** After a sign-out is sent under the signed-in identity: clear it for whoever uses the device next. */
|
|
757
|
+
function signOut(event) {
|
|
758
|
+
if (userId) forgetVisitor();
|
|
759
|
+
userId = null;
|
|
760
|
+
identifiedUserId = null;
|
|
761
|
+
optionsUserId = null;
|
|
762
|
+
return event;
|
|
763
|
+
}
|
|
764
|
+
|
|
685
765
|
return guardMethods({
|
|
686
766
|
/** Escape hatch for events not yet in the core spec. Prefer a named method. */
|
|
687
|
-
track,
|
|
767
|
+
track: (event, p, c) =>
|
|
768
|
+
event === 'Signed Out' ? signOut(track(event, p, c)) : track(event, p, c),
|
|
688
769
|
|
|
689
770
|
page(props, ctx) {
|
|
690
771
|
props ??= {};
|
|
@@ -744,13 +825,10 @@ export function createAnalytics(options) {
|
|
|
744
825
|
userId = next;
|
|
745
826
|
if (next && source !== IDENTITY_OPTION) identifiedUserId = next;
|
|
746
827
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
else delete rest.email_sha256;
|
|
752
|
-
}
|
|
753
|
-
return emit({ type: 'identify', traits: rest }, ctx);
|
|
828
|
+
return emit(
|
|
829
|
+
{ type: 'identify', traits: identifyTraits(rest, cfg.debug) },
|
|
830
|
+
ctx,
|
|
831
|
+
);
|
|
754
832
|
},
|
|
755
833
|
|
|
756
834
|
/**
|
|
@@ -885,14 +963,7 @@ export function createAnalytics(options) {
|
|
|
885
963
|
signedIn: (p, c) => track('Signed In', p, c),
|
|
886
964
|
signedUp: (p, c) => track('Signed Up', p, c),
|
|
887
965
|
/** Sent under the signed-in identity, which is then cleared for whoever uses the device next. */
|
|
888
|
-
signedOut(c) {
|
|
889
|
-
const event = track('Signed Out', {}, c);
|
|
890
|
-
if (userId) forgetVisitor();
|
|
891
|
-
userId = null;
|
|
892
|
-
identifiedUserId = null;
|
|
893
|
-
optionsUserId = null;
|
|
894
|
-
return event;
|
|
895
|
-
},
|
|
966
|
+
signedOut: (c) => signOut(track('Signed Out', {}, c)),
|
|
896
967
|
newsletterSubscribed: (p, c) => track('Newsletter Subscribed', p, c),
|
|
897
968
|
/** An on-site opt-out. The ESP reports its own as Email Unsubscribed. */
|
|
898
969
|
newsletterUnsubscribed: (p, c) => track('Newsletter Unsubscribed', p, c),
|
package/src/segment.js
CHANGED
|
@@ -33,7 +33,7 @@ import { hostConsent, reportingPurposes } from './consent.js';
|
|
|
33
33
|
import { createConsentGate } from './consent-gate.js';
|
|
34
34
|
import { buildContext, CONTEXT_KEYS } from './context.js';
|
|
35
35
|
import { deviceIdFrom } from './device-storage.js';
|
|
36
|
-
import { planAllows, readSettings
|
|
36
|
+
import { eventsHost, planAllows, readSettings } from './settings.js';
|
|
37
37
|
|
|
38
38
|
export { DEFAULT_API_HOST } from './settings.js';
|
|
39
39
|
|
|
@@ -134,12 +134,20 @@ export function hpLoadOptions({ writeKey, apiHost }) {
|
|
|
134
134
|
disableClientPersistence: true,
|
|
135
135
|
updateCDNSettings(cdnSettings) {
|
|
136
136
|
const integrations = { ...cdnSettings.integrations };
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
137
|
+
const block = integrations['Segment.io'];
|
|
138
|
+
// The settings' block is kept only where the settings' host would be: no apiHost set, and https.
|
|
139
|
+
const configured =
|
|
140
|
+
apiHost !== undefined && apiHost !== null && apiHost !== '';
|
|
141
|
+
if (
|
|
142
|
+
configured ||
|
|
143
|
+
!block?.apiHost ||
|
|
144
|
+
(block.protocol ?? 'https') !== 'https'
|
|
145
|
+
) {
|
|
146
|
+
const origin = eventsHost(apiHost, readSettings(cdnSettings).apiHost);
|
|
140
147
|
if (!origin) return cdnSettings;
|
|
141
148
|
const { protocol, host } = new URL(origin);
|
|
142
149
|
integrations['Segment.io'] = {
|
|
150
|
+
...block,
|
|
143
151
|
apiKey: writeKey,
|
|
144
152
|
apiHost: `${host}/v1`,
|
|
145
153
|
protocol: protocol.replace(':', ''),
|
|
@@ -156,8 +164,8 @@ export function hpLoadOptions({ writeKey, apiHost }) {
|
|
|
156
164
|
|
|
157
165
|
/**
|
|
158
166
|
* @param {object} options Same names and meanings as createAnalytics().
|
|
159
|
-
* @param {string} [options.propertyId]
|
|
160
|
-
* the
|
|
167
|
+
* @param {string} [options.propertyId] The page's property. It wins over the `hp` block's, which is
|
|
168
|
+
* the source's default property, as on Android, iOS and React Native.
|
|
161
169
|
* @param {string} [options.channel]
|
|
162
170
|
* @param {string} [options.market]
|
|
163
171
|
* @param {string} [options.environment]
|
|
@@ -181,7 +189,6 @@ export function hpLoadOptions({ writeKey, apiHost }) {
|
|
|
181
189
|
* @param {object} [options.anonymousIdSource] Only without settings: the `hp` block's wins.
|
|
182
190
|
* @param {() => string|null} [options.getPageType]
|
|
183
191
|
* @param {() => string|null} [options.getSessionId]
|
|
184
|
-
* @param {() => string|null} [options.getRenderId]
|
|
185
192
|
* @param {() => object|null} [options.getExtensions]
|
|
186
193
|
* @param {() => object[]|null} [options.getExperiments]
|
|
187
194
|
* @param {() => object[]|null} [options.getAudiences]
|
|
@@ -211,7 +218,7 @@ export function cdpPlugins(options = {}) {
|
|
|
211
218
|
if (cdnSettings?.hp !== undefined) {
|
|
212
219
|
const settings = readSettings(cdnSettings);
|
|
213
220
|
if (settings.enabled) {
|
|
214
|
-
cfg.propertyId =
|
|
221
|
+
cfg.propertyId = cfg.propertyId ?? settings.propertyId;
|
|
215
222
|
cfg.environment = settings.environment;
|
|
216
223
|
if (settings.queryParamAllowlist)
|
|
217
224
|
cfg.queryParamAllowlist = settings.queryParamAllowlist;
|
|
@@ -244,7 +251,8 @@ export function cdpPlugins(options = {}) {
|
|
|
244
251
|
delete context[WITHDRAWN_AS];
|
|
245
252
|
|
|
246
253
|
if (off) throw cancellation(ctx, 'not enabled for this write key');
|
|
247
|
-
|
|
254
|
+
const key = ctx.event.type === 'track' ? ctx.event.event : ctx.event.type;
|
|
255
|
+
if (!forced && !planAllows(cfg.plan, key)) {
|
|
248
256
|
throw cancellation(ctx, 'disabled by the tracking plan');
|
|
249
257
|
}
|
|
250
258
|
|
package/src/session/record.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { bump, categoryKey } from './category.js';
|
|
1
|
+
import { bump, categoryKey, pathPrefix } from './category.js';
|
|
2
2
|
import { readSignals, writeSignals } from './signals.js';
|
|
3
3
|
|
|
4
4
|
const RECENT_LIMIT = 20;
|
|
@@ -20,6 +20,40 @@ const ENGAGEMENT_EVENTS = new Set([
|
|
|
20
20
|
*/
|
|
21
21
|
const CATEGORY_EVENTS = new Set(['Product Viewed', 'Product Added']);
|
|
22
22
|
|
|
23
|
+
/** The most listing categories a visit counts views of: more than any visit browses. */
|
|
24
|
+
const LIST_CATEGORY_LIMIT = 50;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One interaction with a browse key, and beside it the category of the listing it was reached from
|
|
28
|
+
* (null for none), so each trail entry is judged on its own listing, as gold judges each event.
|
|
29
|
+
* Returns the key used.
|
|
30
|
+
*/
|
|
31
|
+
function browsed(s, value) {
|
|
32
|
+
const key = bump(s.recentBrowse, value);
|
|
33
|
+
if (!key) return null;
|
|
34
|
+
// Older visits have no categories: the two lists stay aligned at the head, where entries enter.
|
|
35
|
+
s.recentBrowseCategories = [
|
|
36
|
+
s.browsingListing?.category ?? null,
|
|
37
|
+
...s.recentBrowseCategories,
|
|
38
|
+
].slice(0, s.recentBrowse.length);
|
|
39
|
+
return key;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One listing view of a category, the counts kept to the categories most viewed. */
|
|
43
|
+
function countListView(s, category) {
|
|
44
|
+
const counts = s.listViewsByCategory;
|
|
45
|
+
counts[category] = (counts[category] ?? 0) + 1;
|
|
46
|
+
const categories = Object.keys(counts);
|
|
47
|
+
if (categories.length > LIST_CATEGORY_LIMIT) {
|
|
48
|
+
const kept = {};
|
|
49
|
+
for (const c of categories
|
|
50
|
+
.sort((a, b) => counts[b] - counts[a])
|
|
51
|
+
.slice(0, LIST_CATEGORY_LIMIT))
|
|
52
|
+
kept[c] = counts[c];
|
|
53
|
+
s.listViewsByCategory = kept;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
23
57
|
/**
|
|
24
58
|
* Fold one CDP event into the session signals.
|
|
25
59
|
*
|
|
@@ -37,38 +71,64 @@ export function recordSessionEvent(eventName, properties) {
|
|
|
37
71
|
switch (eventName) {
|
|
38
72
|
case 'Product Viewed':
|
|
39
73
|
s.productViews += 1;
|
|
40
|
-
|
|
74
|
+
browsed(s, s.browsingCategory);
|
|
41
75
|
break;
|
|
42
76
|
case 'Product List Viewed':
|
|
43
77
|
// Only a category list is browsing, or a category page viewed. A rail or carousel names its
|
|
44
78
|
// widget (no slash, or a `cdp-` id), and counting it would let personalisation measure itself.
|
|
79
|
+
// A listing must name its own `category`, as the warehouse requires. Each trail entry keeps
|
|
80
|
+
// that listing's category, as gold judges each event on its own: whether the catalogue files
|
|
81
|
+
// products under it (a category page, not a merchandising list) is decided against the
|
|
82
|
+
// datafile's `browse_categories` when audiences are resolved.
|
|
45
83
|
{
|
|
46
84
|
const list = properties?.list_id;
|
|
85
|
+
const category = properties?.category;
|
|
47
86
|
const isCategoryPath =
|
|
48
87
|
typeof list === 'string' &&
|
|
49
88
|
list.includes('/') &&
|
|
50
89
|
!list.startsWith('cdp-');
|
|
51
|
-
if (isCategoryPath)
|
|
52
|
-
|
|
53
|
-
|
|
90
|
+
if (!isCategoryPath) break;
|
|
91
|
+
const listed =
|
|
92
|
+
typeof category === 'string'
|
|
93
|
+
? pathPrefix(categoryKey(category)) || null
|
|
94
|
+
: null;
|
|
95
|
+
const key = listed ? pathPrefix(categoryKey(list)) : '';
|
|
96
|
+
if (!key) {
|
|
97
|
+
// A page that is not recorded: what is viewed from it is not the last page's browsing.
|
|
98
|
+
s.browsingCategory = null;
|
|
99
|
+
s.browsingListing = null;
|
|
100
|
+
break;
|
|
54
101
|
}
|
|
102
|
+
s.browsingListing = { key, category: listed };
|
|
103
|
+
browsed(s, key);
|
|
104
|
+
s.browsingCategory = key;
|
|
105
|
+
countListView(s, listed);
|
|
106
|
+
s.listViews += 1;
|
|
55
107
|
}
|
|
56
108
|
break;
|
|
57
109
|
case 'Product List Filtered': {
|
|
58
|
-
// Storefronts whose menus link to facet URLs navigate by filtering
|
|
110
|
+
// Storefronts whose menus link to facet URLs navigate by filtering: a facet of the listing
|
|
111
|
+
// being browsed, which carries that listing's category. A facet of a listing that was not
|
|
112
|
+
// recorded is not browsing either.
|
|
59
113
|
const change = properties?.change;
|
|
60
114
|
const list =
|
|
61
115
|
typeof properties?.list_id === 'string' ? properties.list_id : '';
|
|
62
116
|
const facet = typeof change?.value === 'string' ? change.value : '';
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
117
|
+
const listing = s.browsingListing;
|
|
118
|
+
if (
|
|
119
|
+
facet &&
|
|
120
|
+
change?.action !== 'removed' &&
|
|
121
|
+
listing &&
|
|
122
|
+
pathPrefix(categoryKey(list)) === listing.key
|
|
123
|
+
) {
|
|
124
|
+
const key = browsed(s, `${list}/${facet}`);
|
|
125
|
+
if (key) s.browsingCategory = key;
|
|
66
126
|
}
|
|
67
127
|
break;
|
|
68
128
|
}
|
|
69
129
|
case 'Product Added': {
|
|
70
130
|
s.cartAdds += 1;
|
|
71
|
-
|
|
131
|
+
browsed(s, s.browsingCategory);
|
|
72
132
|
for (const p of products) {
|
|
73
133
|
const id = p.product_id;
|
|
74
134
|
if (typeof id !== 'string' || !id) continue;
|
package/src/session/rules.js
CHANGED
|
@@ -131,15 +131,38 @@ export function resolveAudiences(
|
|
|
131
131
|
sessionAudiences,
|
|
132
132
|
signals = readSignals(),
|
|
133
133
|
qualifiedFor = [],
|
|
134
|
+
browseCategories = null,
|
|
134
135
|
) {
|
|
135
136
|
return resolveAudienceMemberships(
|
|
136
137
|
precomputed,
|
|
137
138
|
sessionAudiences,
|
|
138
139
|
signals,
|
|
139
140
|
qualifiedFor,
|
|
141
|
+
browseCategories,
|
|
140
142
|
).map((m) => m.id);
|
|
141
143
|
}
|
|
142
144
|
|
|
145
|
+
/**
|
|
146
|
+
* The visit as the warehouse counts browsing: only the listings whose own `category` the catalogue
|
|
147
|
+
* files products under (the datafile's `browse_categories`), so a merchandising list (best sellers,
|
|
148
|
+
* new in, a sale) is never browsing interest. Each trail entry is judged on the listing it came
|
|
149
|
+
* from. Without the list (a datafile that predates it, or a serving store with no catalogue yet),
|
|
150
|
+
* the visit as recorded.
|
|
151
|
+
*/
|
|
152
|
+
export function catalogueBrowse(signals, browseCategories) {
|
|
153
|
+
if (!Array.isArray(browseCategories)) return signals;
|
|
154
|
+
const held = new Set(browseCategories);
|
|
155
|
+
const categories = signals.recentBrowseCategories ?? [];
|
|
156
|
+
const recentBrowse = (signals.recentBrowse ?? []).filter((_, i) =>
|
|
157
|
+
held.has(categories[i]),
|
|
158
|
+
);
|
|
159
|
+
const listViews = Object.entries(signals.listViewsByCategory ?? {}).reduce(
|
|
160
|
+
(sum, [category, n]) => (held.has(category) ? sum + n : sum),
|
|
161
|
+
0,
|
|
162
|
+
);
|
|
163
|
+
return { ...signals, recentBrowse, listViews };
|
|
164
|
+
}
|
|
165
|
+
|
|
143
166
|
/**
|
|
144
167
|
* The same resolution, with where each membership came from, for `context.audiences`.
|
|
145
168
|
*
|
|
@@ -151,7 +174,9 @@ export function resolveAudienceMemberships(
|
|
|
151
174
|
sessionAudiences,
|
|
152
175
|
signals = readSignals(),
|
|
153
176
|
qualifiedFor = [],
|
|
177
|
+
browseCategories = null,
|
|
154
178
|
) {
|
|
179
|
+
const visit = catalogueBrowse(signals, browseCategories);
|
|
155
180
|
const active = new Set(precomputed);
|
|
156
181
|
const bySlug = new Map();
|
|
157
182
|
// Qualifying on history is not membership of a mixed audience, so this stays out of `active`.
|
|
@@ -176,7 +201,7 @@ export function resolveAudienceMemberships(
|
|
|
176
201
|
if (!required.every(met)) continue;
|
|
177
202
|
if (audience.mixed && !qualified.has(audience.slug)) continue;
|
|
178
203
|
if (!audience.rules || audience.rules.length === 0) continue;
|
|
179
|
-
if (sessionRulesHold(audience,
|
|
204
|
+
if (sessionRulesHold(audience, visit)) {
|
|
180
205
|
active.add(audience.slug);
|
|
181
206
|
// Mixed is its own source: half of it was decided by a batch run, not by this visit.
|
|
182
207
|
bySlug.set(audience.slug, audience.mixed ? 'mixed' : 'session');
|
package/src/session/signals.js
CHANGED
|
@@ -95,6 +95,12 @@ function emptySignals() {
|
|
|
95
95
|
// read as interest. The threshold that makes them meaningful lives server-side in `fatigue.ts`.
|
|
96
96
|
impressions: {},
|
|
97
97
|
browsingCategory: null,
|
|
98
|
+
// Beside each `recentBrowse` entry, the category of the listing it came from (two levels,
|
|
99
|
+
// `categoryKey` spelling, null for none); the listing being browsed; and listing views per
|
|
100
|
+
// category. What `browse_categories` is checked against when audiences are resolved.
|
|
101
|
+
recentBrowseCategories: [],
|
|
102
|
+
browsingListing: null,
|
|
103
|
+
listViewsByCategory: {},
|
|
98
104
|
startedAt: 0,
|
|
99
105
|
};
|
|
100
106
|
}
|
package/src/session.js
CHANGED
package/src/settings.js
CHANGED
|
@@ -51,6 +51,19 @@ export function resolveApiHost(host) {
|
|
|
51
51
|
: apiOrigin(host);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Where events go. An `apiHost` the integrator set is a first-party proxy and carries events too;
|
|
56
|
+
* the settings' host applies only when none was set, and only over https (as the app SDKs do).
|
|
57
|
+
*/
|
|
58
|
+
export function eventsHost(configured, fromSettings) {
|
|
59
|
+
const hasConfigured =
|
|
60
|
+
configured !== undefined && configured !== null && configured !== '';
|
|
61
|
+
if (hasConfigured) return resolveApiHost(configured);
|
|
62
|
+
return fromSettings?.startsWith('https://')
|
|
63
|
+
? fromSettings
|
|
64
|
+
: resolveApiHost(undefined);
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
/**
|
|
55
68
|
* An API host as an origin. The settings give a bare host (`api.example.com`, the OpenAPI server
|
|
56
69
|
* variable); an integrator may write either. Anything else is not a host.
|
|
@@ -143,6 +156,16 @@ function readAnonymousId(raw) {
|
|
|
143
156
|
: null;
|
|
144
157
|
}
|
|
145
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Whether a 2xx body is a settings answer at all: an `hp` block saying on or off. Anything else (a
|
|
161
|
+
* captive portal's page, a proxy's error) is no answer, so the last good settings and what was
|
|
162
|
+
* carried are kept, as on Android, iOS and React Native.
|
|
163
|
+
*/
|
|
164
|
+
export function isSettingsAnswer(body) {
|
|
165
|
+
const hp = isObject(body) ? body.hp : null;
|
|
166
|
+
return isObject(hp) && typeof hp.enabled === 'boolean';
|
|
167
|
+
}
|
|
168
|
+
|
|
146
169
|
/**
|
|
147
170
|
* The settings response as the SDK uses it, or DISABLED for anything that does not switch it on.
|
|
148
171
|
* Unknown fields are ignored, so the API can add to the response without breaking a deployed SDK.
|
|
@@ -191,7 +214,8 @@ export async function fetchSettings(
|
|
|
191
214
|
headers: { accept: 'application/json' },
|
|
192
215
|
});
|
|
193
216
|
if (!res.ok) return null;
|
|
194
|
-
|
|
217
|
+
const body = await res.json();
|
|
218
|
+
return isSettingsAnswer(body) ? readSettings(body) : null;
|
|
195
219
|
} catch {
|
|
196
220
|
return null;
|
|
197
221
|
}
|
package/src/spec-version.js
CHANGED
package/src/transport.js
CHANGED
|
@@ -95,6 +95,12 @@ export class Transport {
|
|
|
95
95
|
this.inflight = null;
|
|
96
96
|
// Ids of persisted entries this instance is already sending, so a replay never races them.
|
|
97
97
|
this.sending = new Set();
|
|
98
|
+
/** Bumped by a refusal: batches cut before it are not sent or retried after it. */
|
|
99
|
+
this.epoch = 0;
|
|
100
|
+
/** Persisted ids of the batches cut since the last refusal: the withdrawal's among them. */
|
|
101
|
+
this.sinceRefusal = new Set();
|
|
102
|
+
/** End backoff waits early, so a refusal is not held behind a retry. */
|
|
103
|
+
this.wakers = new Set();
|
|
98
104
|
this.replayAllowed = !this.o.deferReplay;
|
|
99
105
|
this.draining = null;
|
|
100
106
|
|
|
@@ -123,12 +129,31 @@ export class Transport {
|
|
|
123
129
|
this.replayAllowed = false;
|
|
124
130
|
if (!this.storage) return;
|
|
125
131
|
try {
|
|
126
|
-
|
|
132
|
+
// A batch cut since the refusal is the withdrawal: it keeps its saved copy until delivered.
|
|
133
|
+
const kept = this.#readPersisted().filter((entry) =>
|
|
134
|
+
this.sinceRefusal.has(entry?.id),
|
|
135
|
+
);
|
|
136
|
+
if (kept.length)
|
|
137
|
+
this.storage.setItem(this.o.storageKey, JSON.stringify(kept));
|
|
138
|
+
else this.storage.removeItem(this.o.storageKey);
|
|
127
139
|
} catch {
|
|
128
140
|
/* nothing was readable, so nothing will be replayed either */
|
|
129
141
|
}
|
|
130
142
|
}
|
|
131
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Drop every event queued in memory, and stop sending or retrying the batches already cut from
|
|
146
|
+
* it. For a refusal: what was collected under the permission is not sent after it is withdrawn.
|
|
147
|
+
* The batch already on the wire cannot be recalled.
|
|
148
|
+
*/
|
|
149
|
+
discardQueued() {
|
|
150
|
+
this.queue = [];
|
|
151
|
+
this.epoch += 1;
|
|
152
|
+
this.sinceRefusal.clear();
|
|
153
|
+
this.#clearTimer();
|
|
154
|
+
for (const wake of this.wakers) wake();
|
|
155
|
+
}
|
|
156
|
+
|
|
132
157
|
/**
|
|
133
158
|
* Queued as its JSON form, made once here: an event JSON cannot serialise (a BigInt, a cycle, a
|
|
134
159
|
* throwing toJSON) is dropped on its own, rather than throwing from every later flush and holding
|
|
@@ -239,12 +264,22 @@ export class Transport {
|
|
|
239
264
|
).map((batch) => {
|
|
240
265
|
const id = this.#persist(batch);
|
|
241
266
|
// Claimed now, not when its turn comes, so a replay started meanwhile does not send it too.
|
|
242
|
-
if (id)
|
|
267
|
+
if (id) {
|
|
268
|
+
this.sending.add(id);
|
|
269
|
+
this.sinceRefusal.add(id);
|
|
270
|
+
}
|
|
243
271
|
return { batch, id };
|
|
244
272
|
});
|
|
245
273
|
this.queue = [];
|
|
274
|
+
const epoch = this.epoch;
|
|
246
275
|
this.inflight = (async () => {
|
|
247
|
-
for (const { batch, id } of batches)
|
|
276
|
+
for (const { batch, id } of batches) {
|
|
277
|
+
if (epoch !== this.epoch) {
|
|
278
|
+
this.#settle(id);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
await this.#send(batch, 0, id, epoch);
|
|
282
|
+
}
|
|
248
283
|
})().finally(() => {
|
|
249
284
|
this.inflight = null;
|
|
250
285
|
if (this.queue.length >= this.o.flushAt) this.flush();
|
|
@@ -297,7 +332,7 @@ export class Transport {
|
|
|
297
332
|
* The body is rebuilt on every attempt: the ingest corrects clock skew against `sentAt`, so a
|
|
298
333
|
* stale `sentAt` would shift every timestamp in the batch by the time spent waiting.
|
|
299
334
|
*/
|
|
300
|
-
async #send(batch, attempt, persisted = null) {
|
|
335
|
+
async #send(batch, attempt, persisted = null, epoch = this.epoch) {
|
|
301
336
|
if (persisted) this.sending.add(persisted);
|
|
302
337
|
const body = this.#body(batch);
|
|
303
338
|
let wait = 0;
|
|
@@ -316,9 +351,10 @@ export class Transport {
|
|
|
316
351
|
this.#drainPersisted();
|
|
317
352
|
return true;
|
|
318
353
|
}
|
|
319
|
-
// 4xx (except 429) means the payload is wrong; retrying cannot fix it and
|
|
354
|
+
// 4xx (except 408 and 429) means the payload is wrong; retrying cannot fix it and
|
|
320
355
|
// would loop forever. Drop and surface.
|
|
321
|
-
const retryable =
|
|
356
|
+
const retryable =
|
|
357
|
+
res.status === 408 || res.status === 429 || res.status >= 500;
|
|
322
358
|
if (retryable) wait = retryAfterMs(res.headers?.get?.('retry-after'));
|
|
323
359
|
if (!retryable) {
|
|
324
360
|
this.#settle(persisted);
|
|
@@ -343,14 +379,28 @@ export class Transport {
|
|
|
343
379
|
Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS) * (0.5 + Math.random()),
|
|
344
380
|
Math.min(wait, MAX_BACKOFF_MS),
|
|
345
381
|
);
|
|
346
|
-
|
|
347
|
-
|
|
382
|
+
if (epoch === this.epoch)
|
|
383
|
+
await new Promise((r) => {
|
|
384
|
+
const wake = () => {
|
|
385
|
+
clearTimeout(timer);
|
|
386
|
+
this.wakers.delete(wake);
|
|
387
|
+
r();
|
|
388
|
+
};
|
|
389
|
+
const timer = setTimeout(wake, delay);
|
|
390
|
+
this.wakers.add(wake);
|
|
391
|
+
});
|
|
392
|
+
if (epoch !== this.epoch) {
|
|
393
|
+
this.#settle(persisted);
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
return this.#send(batch, attempt + 1, persisted, epoch);
|
|
348
397
|
}
|
|
349
398
|
}
|
|
350
399
|
|
|
351
400
|
#settle(id) {
|
|
352
401
|
if (!id) return;
|
|
353
402
|
this.sending.delete(id);
|
|
403
|
+
this.sinceRefusal.delete(id);
|
|
354
404
|
this.#unpersist(id);
|
|
355
405
|
}
|
|
356
406
|
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = '3.
|
|
1
|
+
export const VERSION = '3.22.2';
|
|
2
2
|
export const LIB = '@thg-agentic/hyper-personalisation-web';
|
package/types/bootstrap.d.ts
CHANGED
|
@@ -45,7 +45,7 @@ export interface SettingsResponse {
|
|
|
45
45
|
plan?: { track?: Record<string, { enabled?: boolean }> };
|
|
46
46
|
hp?: {
|
|
47
47
|
enabled: boolean;
|
|
48
|
-
/**
|
|
48
|
+
/** The source's default property, or null. A `propertyId` the page passes wins over it. */
|
|
49
49
|
propertyId?: string | null;
|
|
50
50
|
environment?: 'production' | 'staging' | 'development';
|
|
51
51
|
/** Strict is the only mode. `purposes`: the ones this property asks for, and so reports. */
|