@thg-agentic/hyper-personalisation-web 3.18.0 → 3.22.0
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 +86 -16
- package/dist/hp.min.js +3 -3
- package/dist/snippet.min.js +1 -1
- package/package.json +2 -2
- package/src/bootstrap.js +11 -7
- package/src/consent-gate.js +8 -2
- package/src/context.js +0 -2
- package/src/device-storage.js +3 -2
- package/src/guard.js +46 -0
- package/src/identity-option.js +6 -0
- package/src/index.js +202 -30
- package/src/recommendation-context.js +10 -1
- package/src/segment.js +17 -9
- package/src/session/record.js +79 -18
- 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 +84 -11
- package/src/version.js +1 -1
- package/types/bootstrap.d.ts +1 -1
- package/types/consent.d.ts +4 -6
- package/types/events.d.ts +84 -42
- package/types/index.d.ts +17 -18
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;
|
|
@@ -13,11 +13,46 @@ const ENGAGEMENT_EVENTS = new Set([
|
|
|
13
13
|
'Product Added to Wishlist',
|
|
14
14
|
]);
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The browsing events the warehouse's category affinity counts (identifier_affinity_daily), so a
|
|
18
|
+
* session measure counts the same: a product shown in a listing, removed or found in search is not
|
|
19
|
+
* interest. The warehouse also counts orders, which a visit's own measure leaves out.
|
|
20
|
+
*/
|
|
21
|
+
const CATEGORY_EVENTS = new Set(['Product Viewed', 'Product Added']);
|
|
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
|
+
}
|
|
21
56
|
|
|
22
57
|
/**
|
|
23
58
|
* Fold one CDP event into the session signals.
|
|
@@ -36,38 +71,64 @@ export function recordSessionEvent(eventName, properties) {
|
|
|
36
71
|
switch (eventName) {
|
|
37
72
|
case 'Product Viewed':
|
|
38
73
|
s.productViews += 1;
|
|
39
|
-
|
|
74
|
+
browsed(s, s.browsingCategory);
|
|
40
75
|
break;
|
|
41
76
|
case 'Product List Viewed':
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
//
|
|
77
|
+
// Only a category list is browsing, or a category page viewed. A rail or carousel names its
|
|
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
|
-
|
|
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;
|
|
53
101
|
}
|
|
102
|
+
s.browsingListing = { key, category: listed };
|
|
103
|
+
browsed(s, key);
|
|
104
|
+
s.browsingCategory = key;
|
|
105
|
+
countListView(s, listed);
|
|
106
|
+
s.listViews += 1;
|
|
54
107
|
}
|
|
55
108
|
break;
|
|
56
109
|
case 'Product List Filtered': {
|
|
57
|
-
// 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.
|
|
58
113
|
const change = properties?.change;
|
|
59
114
|
const list =
|
|
60
115
|
typeof properties?.list_id === 'string' ? properties.list_id : '';
|
|
61
116
|
const facet = typeof change?.value === 'string' ? change.value : '';
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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;
|
|
65
126
|
}
|
|
66
127
|
break;
|
|
67
128
|
}
|
|
68
129
|
case 'Product Added': {
|
|
69
130
|
s.cartAdds += 1;
|
|
70
|
-
|
|
131
|
+
browsed(s, s.browsingCategory);
|
|
71
132
|
for (const p of products) {
|
|
72
133
|
const id = p.product_id;
|
|
73
134
|
if (typeof id !== 'string' || !id) continue;
|
|
@@ -159,7 +220,7 @@ export function recordSessionEvent(eventName, properties) {
|
|
|
159
220
|
].slice(0, RECENT_LIMIT);
|
|
160
221
|
}
|
|
161
222
|
if (
|
|
162
|
-
|
|
223
|
+
CATEGORY_EVENTS.has(eventName) &&
|
|
163
224
|
typeof p.category === 'string' &&
|
|
164
225
|
p.category
|
|
165
226
|
) {
|
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
|
@@ -32,12 +32,30 @@ const DEFAULTS = {
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
const now = () => Date.now();
|
|
35
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
36
|
+
|
|
37
|
+
/** The largest event the SDK sends: with its batch envelope it still fits one keepalive request. */
|
|
38
|
+
export const MAX_EVENT_BYTES = 55 * 1024;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Retry-After as milliseconds from now: delay-seconds or an HTTP date (RFC 9110 §10.2.3); 0 when
|
|
42
|
+
* absent or unreadable. Cross-origin, the browser exposes it only if the ingest lists it in
|
|
43
|
+
* Access-Control-Expose-Headers; a first-party proxy always can.
|
|
44
|
+
*/
|
|
45
|
+
export function retryAfterMs(value, at = now()) {
|
|
46
|
+
if (typeof value !== 'string') return 0;
|
|
47
|
+
const text = value.trim();
|
|
48
|
+
if (/^\d+$/.test(text)) return Number(text) * 1000;
|
|
49
|
+
if (text === '') return 0;
|
|
50
|
+
const date = Date.parse(text);
|
|
51
|
+
return Number.isNaN(date) ? 0 : Math.max(0, date - at);
|
|
52
|
+
}
|
|
35
53
|
|
|
36
54
|
/**
|
|
37
55
|
* UTF-8 bytes, the unit of sendBeacon's ceiling and the keepalive budget. String length counts
|
|
38
56
|
* UTF-16 code units and undercounts any non-ASCII catalogue.
|
|
39
57
|
*/
|
|
40
|
-
const byteLength = (s) =>
|
|
58
|
+
export const byteLength = (s) =>
|
|
41
59
|
typeof TextEncoder !== 'undefined'
|
|
42
60
|
? new TextEncoder().encode(s).length
|
|
43
61
|
: Buffer.byteLength(s);
|
|
@@ -77,6 +95,12 @@ export class Transport {
|
|
|
77
95
|
this.inflight = null;
|
|
78
96
|
// Ids of persisted entries this instance is already sending, so a replay never races them.
|
|
79
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();
|
|
80
104
|
this.replayAllowed = !this.o.deferReplay;
|
|
81
105
|
this.draining = null;
|
|
82
106
|
|
|
@@ -105,12 +129,31 @@ export class Transport {
|
|
|
105
129
|
this.replayAllowed = false;
|
|
106
130
|
if (!this.storage) return;
|
|
107
131
|
try {
|
|
108
|
-
|
|
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);
|
|
109
139
|
} catch {
|
|
110
140
|
/* nothing was readable, so nothing will be replayed either */
|
|
111
141
|
}
|
|
112
142
|
}
|
|
113
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
|
+
|
|
114
157
|
/**
|
|
115
158
|
* Queued as its JSON form, made once here: an event JSON cannot serialise (a BigInt, a cycle, a
|
|
116
159
|
* throwing toJSON) is dropped on its own, rather than throwing from every later flush and holding
|
|
@@ -221,12 +264,22 @@ export class Transport {
|
|
|
221
264
|
).map((batch) => {
|
|
222
265
|
const id = this.#persist(batch);
|
|
223
266
|
// Claimed now, not when its turn comes, so a replay started meanwhile does not send it too.
|
|
224
|
-
if (id)
|
|
267
|
+
if (id) {
|
|
268
|
+
this.sending.add(id);
|
|
269
|
+
this.sinceRefusal.add(id);
|
|
270
|
+
}
|
|
225
271
|
return { batch, id };
|
|
226
272
|
});
|
|
227
273
|
this.queue = [];
|
|
274
|
+
const epoch = this.epoch;
|
|
228
275
|
this.inflight = (async () => {
|
|
229
|
-
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
|
+
}
|
|
230
283
|
})().finally(() => {
|
|
231
284
|
this.inflight = null;
|
|
232
285
|
if (this.queue.length >= this.o.flushAt) this.flush();
|
|
@@ -279,9 +332,10 @@ export class Transport {
|
|
|
279
332
|
* The body is rebuilt on every attempt: the ingest corrects clock skew against `sentAt`, so a
|
|
280
333
|
* stale `sentAt` would shift every timestamp in the batch by the time spent waiting.
|
|
281
334
|
*/
|
|
282
|
-
async #send(batch, attempt, persisted = null) {
|
|
335
|
+
async #send(batch, attempt, persisted = null, epoch = this.epoch) {
|
|
283
336
|
if (persisted) this.sending.add(persisted);
|
|
284
337
|
const body = this.#body(batch);
|
|
338
|
+
let wait = 0;
|
|
285
339
|
try {
|
|
286
340
|
const res = await fetch(this.#url(), {
|
|
287
341
|
method: 'POST',
|
|
@@ -297,9 +351,11 @@ export class Transport {
|
|
|
297
351
|
this.#drainPersisted();
|
|
298
352
|
return true;
|
|
299
353
|
}
|
|
300
|
-
// 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
|
|
301
355
|
// would loop forever. Drop and surface.
|
|
302
|
-
const retryable =
|
|
356
|
+
const retryable =
|
|
357
|
+
res.status === 408 || res.status === 429 || res.status >= 500;
|
|
358
|
+
if (retryable) wait = retryAfterMs(res.headers?.get?.('retry-after'));
|
|
303
359
|
if (!retryable) {
|
|
304
360
|
this.#settle(persisted);
|
|
305
361
|
this.o.onError?.(
|
|
@@ -318,16 +374,33 @@ export class Transport {
|
|
|
318
374
|
);
|
|
319
375
|
return false;
|
|
320
376
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
377
|
+
// The ingest's Retry-After is a floor on the backoff, within the same cap.
|
|
378
|
+
const delay = Math.max(
|
|
379
|
+
Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS) * (0.5 + Math.random()),
|
|
380
|
+
Math.min(wait, MAX_BACKOFF_MS),
|
|
381
|
+
);
|
|
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);
|
|
325
397
|
}
|
|
326
398
|
}
|
|
327
399
|
|
|
328
400
|
#settle(id) {
|
|
329
401
|
if (!id) return;
|
|
330
402
|
this.sending.delete(id);
|
|
403
|
+
this.sinceRefusal.delete(id);
|
|
331
404
|
this.#unpersist(id);
|
|
332
405
|
}
|
|
333
406
|
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = '3.
|
|
1
|
+
export const VERSION = '3.22.0';
|
|
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. */
|
package/types/consent.d.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
| 'ad_user_data'
|
|
6
|
-
| 'ad_personalization';
|
|
1
|
+
import type { CdpConsentPurpose } from './events';
|
|
2
|
+
|
|
3
|
+
/** The purposes a visitor answers for: the spec's, generated. */
|
|
4
|
+
export type ConsentPurpose = CdpConsentPurpose;
|
|
7
5
|
|
|
8
6
|
/** schemas/common/consent-state.json: true granted, false refused, null never asked. */
|
|
9
7
|
export type ConsentState = Record<ConsentPurpose, boolean | null>;
|