@thg-agentic/hyper-personalisation-web 0.0.0-stage → 3.17.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/LICENSE +21 -0
- package/README.md +825 -2
- package/dist/hp.min.js +3 -0
- package/dist/snippet.min.js +1 -0
- package/package.json +83 -3
- package/src/assignment.js +75 -0
- package/src/auto/attributes.js +16 -0
- package/src/auto/exposures.js +45 -0
- package/src/auto/lists.js +136 -0
- package/src/auto/promotions.js +104 -0
- package/src/auto/rails.js +217 -0
- package/src/auto/viewability.js +150 -0
- package/src/auto.js +13 -0
- package/src/bootstrap.js +476 -0
- package/src/carry.js +74 -0
- package/src/conformance.js +530 -0
- package/src/consent-gate.js +324 -0
- package/src/consent.js +604 -0
- package/src/context.js +439 -0
- package/src/datalayer.js +411 -0
- package/src/decisions.js +90 -0
- package/src/device-storage.js +201 -0
- package/src/experiments.js +220 -0
- package/src/ga4.js +124 -0
- package/src/hash.js +138 -0
- package/src/hp.js +135 -0
- package/src/index.js +818 -0
- package/src/preview.js +44 -0
- package/src/recommendation-context.js +101 -0
- package/src/segment.js +375 -0
- package/src/session/category.js +40 -0
- package/src/session/record.js +163 -0
- package/src/session/rules.js +195 -0
- package/src/session/signals.js +146 -0
- package/src/session-id.js +62 -0
- package/src/session.js +28 -0
- package/src/settings.js +211 -0
- package/src/shape.js +111 -0
- package/src/social-proof.js +139 -0
- package/src/spec-version.js +4 -0
- package/src/storage-scope.js +159 -0
- package/src/transport.js +436 -0
- package/src/version.js +2 -0
- package/types/auto.d.ts +51 -0
- package/types/bootstrap.d.ts +154 -0
- package/types/consent.d.ts +126 -0
- package/types/datalayer.d.ts +76 -0
- package/types/events.d.ts +680 -0
- package/types/hp.d.ts +64 -0
- package/types/index.d.ts +610 -0
- package/types/segment.d.ts +68 -0
package/src/bootstrap.js
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One call from a write key to a running client: remote settings, consent, identity, the page
|
|
3
|
+
* view, the dataLayer bridge and a once-per-session identify.
|
|
4
|
+
*
|
|
5
|
+
* const hp = await bootstrap(
|
|
6
|
+
* { writeKey: 'hp_pk_live_…' },
|
|
7
|
+
* { consent: { provider: 'onetrust' }, identityCookies: { anonymousId: 'device_id' }, pageType: 'product' },
|
|
8
|
+
* );
|
|
9
|
+
*
|
|
10
|
+
* The key is the whole config: `apiHost` is optional, for a first-party proxy (DEFAULT_API_HOST
|
|
11
|
+
* otherwise). Everything tenant-level comes from `GET {apiHost}/v1/projects/{writeKey}/settings`: whether the
|
|
12
|
+
* SDK is on, the property, the environment, the reported consent purposes, the query-param
|
|
13
|
+
* allowlist, where device ids come from, and the tracking plan. What only the page can know stays
|
|
14
|
+
* in code: the CMP, the session cookie's name (and, for a source whose settings name no device
|
|
15
|
+
* cookie, the device cookie's), the per-request currency / ship-to / locale getters, the preset,
|
|
16
|
+
* the page type, debug, and which property this page is for a key scoped to several.
|
|
17
|
+
*
|
|
18
|
+
* Resolves to the client, or null when the key is disabled or unknown, the settings cannot be
|
|
19
|
+
* fetched on a first visit, or the options are invalid. Never throws into the page, and is
|
|
20
|
+
* idempotent per page. createAnalytics() and connectDataLayer() remain the lower-level API.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { assignmentDecisionId, assignmentSession } from './assignment.js';
|
|
24
|
+
import {
|
|
25
|
+
trackExposures,
|
|
26
|
+
trackProductLists,
|
|
27
|
+
trackPromotions,
|
|
28
|
+
trackRecommendationRails,
|
|
29
|
+
} from './auto.js';
|
|
30
|
+
import { createCarry } from './carry.js';
|
|
31
|
+
import {
|
|
32
|
+
cookieConsent,
|
|
33
|
+
didomiConsent,
|
|
34
|
+
forwardedConsent,
|
|
35
|
+
oneTrustConsent,
|
|
36
|
+
parseConsentCookie,
|
|
37
|
+
reportingPurposes,
|
|
38
|
+
staticConsent,
|
|
39
|
+
} from './consent.js';
|
|
40
|
+
import { getCookie } from './context.js';
|
|
41
|
+
import { connectDataLayer } from './datalayer.js';
|
|
42
|
+
import { createAnalytics, normaliseSha256, PAGE_TYPES } from './index.js';
|
|
43
|
+
import {
|
|
44
|
+
cachedSettings,
|
|
45
|
+
cacheSettings,
|
|
46
|
+
DISABLED,
|
|
47
|
+
fetchSettings,
|
|
48
|
+
forgetSettings,
|
|
49
|
+
readSettings,
|
|
50
|
+
resolveApiHost,
|
|
51
|
+
} from './settings.js';
|
|
52
|
+
import { realId } from './shape.js';
|
|
53
|
+
import {
|
|
54
|
+
configureStorageNames,
|
|
55
|
+
migrateLegacyStorage,
|
|
56
|
+
storageKey,
|
|
57
|
+
storageName,
|
|
58
|
+
} from './storage-scope.js';
|
|
59
|
+
|
|
60
|
+
const INSTANCE = Symbol.for('@thg-agentic/hyper-personalisation-web.bootstrap');
|
|
61
|
+
const DRAIN = Symbol.for('@thg-agentic/hyper-personalisation-web.drain');
|
|
62
|
+
const CARRY = Symbol.for('@thg-agentic/hyper-personalisation-web.carry');
|
|
63
|
+
const REPLAY = Symbol.for('@thg-agentic/hyper-personalisation-web.replay');
|
|
64
|
+
/** Where bootstrap leaves `hp.assignmentDecisionId` its reader of the page's identity cookies. */
|
|
65
|
+
const ASSIGNMENT = Symbol.for(
|
|
66
|
+
'@thg-agentic/hyper-personalisation-web.assignment',
|
|
67
|
+
);
|
|
68
|
+
/** Where bootstrap leaves `hp.recommendationContext` the started client's reader of the visit. */
|
|
69
|
+
const RECOMMENDATION_CONTEXT = Symbol.for(
|
|
70
|
+
'@thg-agentic/hyper-personalisation-web.recommendation-context',
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const cookie = (name) => (name ? () => getCookie(name) : () => null);
|
|
74
|
+
|
|
75
|
+
const BUILT_IN_CONSENT = {
|
|
76
|
+
didomi: (o) => didomiConsent({ purposes: o.purposes }),
|
|
77
|
+
onetrust: (o) =>
|
|
78
|
+
oneTrustConsent({ groups: o.groups, impliedConsent: o.impliedConsent }),
|
|
79
|
+
cookie: (o) =>
|
|
80
|
+
cookieConsent({
|
|
81
|
+
name: o.name,
|
|
82
|
+
categories: o.categories,
|
|
83
|
+
refusedValue: o.refusedValue,
|
|
84
|
+
source: o.source,
|
|
85
|
+
changeEvent: o.changeEvent,
|
|
86
|
+
}),
|
|
87
|
+
// The page without a CMP reads the cookie the CMP page writes, so they share storageNames.consent.
|
|
88
|
+
forwarded: (o) => forwardedConsent({ name: o.name }),
|
|
89
|
+
static: (o) => {
|
|
90
|
+
if (!o.state || typeof o.state !== 'object')
|
|
91
|
+
throw new Error('consent.state is required for provider "static"');
|
|
92
|
+
return staticConsent(o.state);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const PROVIDERS = Object.keys(BUILT_IN_CONSENT);
|
|
97
|
+
|
|
98
|
+
const isObject = (v) =>
|
|
99
|
+
v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
100
|
+
const isString = (v) => typeof v === 'string' && v !== '';
|
|
101
|
+
const isStringArray = (v) =>
|
|
102
|
+
Array.isArray(v) && v.every((s) => typeof s === 'string');
|
|
103
|
+
const isAdapter = (v) => isObject(v) && typeof v.read === 'function';
|
|
104
|
+
|
|
105
|
+
/** Every reason the config and options cannot be used; empty when they can. */
|
|
106
|
+
export function validateConfig(config, options = {}) {
|
|
107
|
+
const problems = [];
|
|
108
|
+
if (!isObject(config)) return ['config must be { writeKey, apiHost? }'];
|
|
109
|
+
if (!isString(config.writeKey)) problems.push('writeKey is required');
|
|
110
|
+
if (!resolveApiHost(config.apiHost))
|
|
111
|
+
problems.push('apiHost, when given, must be a host or an http(s) origin');
|
|
112
|
+
if (options.propertyId !== undefined && !isString(options.propertyId))
|
|
113
|
+
problems.push('propertyId must be a string');
|
|
114
|
+
|
|
115
|
+
const { consent } = options;
|
|
116
|
+
if (
|
|
117
|
+
!isAdapter(consent) &&
|
|
118
|
+
!(isObject(consent) && PROVIDERS.includes(consent.provider))
|
|
119
|
+
) {
|
|
120
|
+
problems.push(
|
|
121
|
+
`consent must be a consent adapter or { provider: ${PROVIDERS.join(' | ')} }`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
options.autocapture !== undefined &&
|
|
126
|
+
options.autocapture !== false &&
|
|
127
|
+
!isObject(options.autocapture)
|
|
128
|
+
)
|
|
129
|
+
problems.push(
|
|
130
|
+
'autocapture must be false or { promotions, productLists, recommendations }',
|
|
131
|
+
);
|
|
132
|
+
if (
|
|
133
|
+
options.beforeSend !== undefined &&
|
|
134
|
+
typeof options.beforeSend !== 'function'
|
|
135
|
+
)
|
|
136
|
+
problems.push('beforeSend must be a function');
|
|
137
|
+
if (options.identityCookies !== undefined) {
|
|
138
|
+
if (!isObject(options.identityCookies))
|
|
139
|
+
problems.push('identityCookies must be an object');
|
|
140
|
+
else {
|
|
141
|
+
for (const key of ['anonymousId', 'sessionId']) {
|
|
142
|
+
const name = options.identityCookies[key];
|
|
143
|
+
if (name !== undefined && !isString(name))
|
|
144
|
+
problems.push(`identityCookies.${key} must be a cookie name`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const key of ['getCurrency', 'getShipToCountry', 'getLocale']) {
|
|
149
|
+
if (options[key] !== undefined && typeof options[key] !== 'function')
|
|
150
|
+
problems.push(`${key} must be a function`);
|
|
151
|
+
}
|
|
152
|
+
if (options.dataLayer !== undefined) {
|
|
153
|
+
if (!isObject(options.dataLayer))
|
|
154
|
+
problems.push('dataLayer must be an object');
|
|
155
|
+
else {
|
|
156
|
+
if (
|
|
157
|
+
options.dataLayer.name !== undefined &&
|
|
158
|
+
!isString(options.dataLayer.name)
|
|
159
|
+
) {
|
|
160
|
+
problems.push('dataLayer.name must be a string');
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
options.dataLayer.ignore !== undefined &&
|
|
164
|
+
!isStringArray(options.dataLayer.ignore)
|
|
165
|
+
) {
|
|
166
|
+
problems.push('dataLayer.ignore must be an array of strings');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return problems;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function consentAdapter(consent, settings, preset) {
|
|
174
|
+
if (isAdapter(consent))
|
|
175
|
+
return reportingPurposes(consent, settings.consentPurposes);
|
|
176
|
+
const { provider, ...options } = consent;
|
|
177
|
+
const factory = preset?.consent?.[provider] ?? BUILT_IN_CONSENT[provider];
|
|
178
|
+
return reportingPurposes(factory(options), settings.consentPurposes);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Who an identify named, as the session remembers it: `userId|email_sha256`. */
|
|
182
|
+
const identifyMarker = (userId, email) => `${userId ?? ''}|${email ?? ''}`;
|
|
183
|
+
|
|
184
|
+
/** The traits to identify with on this page, or null when this session already identified them. */
|
|
185
|
+
function pendingIdentify(propertyId, identity) {
|
|
186
|
+
const userId = realId(identity?.userId);
|
|
187
|
+
const email = normaliseSha256(identity?.emailSha256);
|
|
188
|
+
if (!userId && !email) return null;
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const key = storageKey(storageName('identified'), propertyId);
|
|
192
|
+
if (sessionStorage.getItem(key) === identifyMarker(userId, email))
|
|
193
|
+
return null;
|
|
194
|
+
} catch {
|
|
195
|
+
/* no session storage: identify on every page rather than never */
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
...(userId ? { userId } : {}),
|
|
199
|
+
...(email ? { email_sha256: email } : {}),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The settings to start this page with, and the answer to cache once analytics is granted.
|
|
205
|
+
*
|
|
206
|
+
* Inline settings (development, tests) are used as given and never fetched or cached. Otherwise a
|
|
207
|
+
* cached answer starts the page at once while the API is asked again for the next page; with no
|
|
208
|
+
* cache the page waits for the API, and `unreachable` says when it could not be asked.
|
|
209
|
+
*/
|
|
210
|
+
async function resolveSettings({ writeKey, apiHost }, options) {
|
|
211
|
+
if (options.settings !== undefined)
|
|
212
|
+
return { settings: readSettings(options.settings), revalidated: null };
|
|
213
|
+
const fetched = fetchSettings(apiHost, writeKey);
|
|
214
|
+
const cached = cachedSettings(writeKey);
|
|
215
|
+
if (cached) return { settings: cached, revalidated: fetched };
|
|
216
|
+
const answer = await fetched;
|
|
217
|
+
const settings = answer ?? DISABLED;
|
|
218
|
+
return {
|
|
219
|
+
settings,
|
|
220
|
+
revalidated: Promise.resolve(settings),
|
|
221
|
+
unreachable: answer === null,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* @param {{ writeKey: string, apiHost?: string }} config
|
|
227
|
+
* @param {import('../types/bootstrap').BootstrapOptions} options See types/bootstrap.d.ts.
|
|
228
|
+
* @returns {Promise<object|null>} the client, or null when it is off.
|
|
229
|
+
*/
|
|
230
|
+
export function bootstrap(config, options = {}) {
|
|
231
|
+
const host = typeof window !== 'undefined' ? window : null;
|
|
232
|
+
if (host?.[INSTANCE]) return host[INSTANCE];
|
|
233
|
+
// What `hp` queued on an earlier page that navigated before its client started. Carried only as
|
|
234
|
+
// the held store is: once this device has granted analytics, or unanswered with holdAcrossPages.
|
|
235
|
+
const carry = createCarry(config?.writeKey, () => {
|
|
236
|
+
const { analytics } = parseConsentCookie(getCookie(storageName('consent')));
|
|
237
|
+
return (
|
|
238
|
+
analytics === true ||
|
|
239
|
+
(analytics === null && options.holdAcrossPages === true)
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
const started = start(config, options, carry);
|
|
243
|
+
if (host) {
|
|
244
|
+
// Known from the options alone, so it answers before the client has started: the session the
|
|
245
|
+
// events carry, else the visitor cookie, as the renderer forwards them to the assignment.
|
|
246
|
+
const session =
|
|
247
|
+
options.extend?.getSessionId ??
|
|
248
|
+
cookie(options.identityCookies?.sessionId);
|
|
249
|
+
const visitor = cookie(options.identityCookies?.anonymousId);
|
|
250
|
+
host[ASSIGNMENT] = ({ slot, arm } = {}) =>
|
|
251
|
+
assignmentDecisionId({
|
|
252
|
+
sessionId: assignmentSession(session(), visitor()),
|
|
253
|
+
slot,
|
|
254
|
+
arm,
|
|
255
|
+
});
|
|
256
|
+
host[INSTANCE] = started;
|
|
257
|
+
host[CARRY] = carry;
|
|
258
|
+
// Calls made through `hp` before this ran: that module, if the page loaded it, replays them.
|
|
259
|
+
host[DRAIN]?.(started);
|
|
260
|
+
}
|
|
261
|
+
return started;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The declarative trackers: they report only what the page tags (`data-hp-promotion`,
|
|
266
|
+
* `data-hp-list` / `data-hp-product`, `data-hp-reco-request`, `data-hp-exposure`), so they are on
|
|
267
|
+
* unless turned off.
|
|
268
|
+
* Returns what stops them.
|
|
269
|
+
*/
|
|
270
|
+
function startAutocapture(client, autocapture) {
|
|
271
|
+
if (autocapture === false) return () => {};
|
|
272
|
+
const on = {
|
|
273
|
+
promotions: true,
|
|
274
|
+
productLists: true,
|
|
275
|
+
recommendations: true,
|
|
276
|
+
exposures: true,
|
|
277
|
+
...autocapture,
|
|
278
|
+
};
|
|
279
|
+
const options = (value) => (value && typeof value === 'object' ? value : {});
|
|
280
|
+
const stops = [];
|
|
281
|
+
try {
|
|
282
|
+
if (on.promotions)
|
|
283
|
+
stops.push(trackPromotions(client, options(on.promotions)));
|
|
284
|
+
if (on.productLists)
|
|
285
|
+
stops.push(trackProductLists(client, options(on.productLists)));
|
|
286
|
+
if (on.recommendations)
|
|
287
|
+
stops.push(trackRecommendationRails(client, options(on.recommendations)));
|
|
288
|
+
if (on.exposures) stops.push(trackExposures(client, options(on.exposures)));
|
|
289
|
+
} catch (err) {
|
|
290
|
+
// A page the trackers cannot read still gets its explicit calls sent.
|
|
291
|
+
console.warn('[hp] autocapture not started:', err?.message ?? err);
|
|
292
|
+
}
|
|
293
|
+
return () => {
|
|
294
|
+
for (const stop of stops) stop();
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** This page is off: what `hp` queued on it is dropped rather than carried. */
|
|
299
|
+
function off(carry) {
|
|
300
|
+
carry.close();
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function start(config, options, carry) {
|
|
305
|
+
try {
|
|
306
|
+
const problems = validateConfig(config, options);
|
|
307
|
+
if (problems.length) {
|
|
308
|
+
console.warn(
|
|
309
|
+
`[hp] not started, the configuration is invalid: ${problems.join('; ')}`,
|
|
310
|
+
);
|
|
311
|
+
return off(carry);
|
|
312
|
+
}
|
|
313
|
+
configureStorageNames(options.storageNames);
|
|
314
|
+
migrateLegacyStorage();
|
|
315
|
+
|
|
316
|
+
const { writeKey } = config;
|
|
317
|
+
// The visitor's identity may still be looking itself up; it is waited for alongside the
|
|
318
|
+
// settings, never before them. A lookup that fails leaves the visitor anonymous.
|
|
319
|
+
const [{ settings, revalidated, unreachable }, identity] =
|
|
320
|
+
await Promise.all([
|
|
321
|
+
resolveSettings(config, options),
|
|
322
|
+
Promise.resolve(options.identity).catch(() => null),
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
// What to cache is the newest answer; when is the first grant. Either may come first.
|
|
326
|
+
let latest = revalidated ? settings : null;
|
|
327
|
+
let granted = false;
|
|
328
|
+
const keep = () => granted && latest && cacheSettings(writeKey, latest);
|
|
329
|
+
revalidated?.then((next) => {
|
|
330
|
+
if (!next) return;
|
|
331
|
+
latest = next;
|
|
332
|
+
if (next.enabled) keep();
|
|
333
|
+
else forgetSettings(writeKey);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Unreachable, or left before the answer: what `hp` queued goes to the next page.
|
|
337
|
+
if (unreachable) return null;
|
|
338
|
+
if (!settings.enabled) {
|
|
339
|
+
if (options.debug)
|
|
340
|
+
console.info(
|
|
341
|
+
'[hp] off: the settings for this write key do not enable it',
|
|
342
|
+
);
|
|
343
|
+
return off(carry);
|
|
344
|
+
}
|
|
345
|
+
// A source scoped to one property names it; a source scoped to several leaves it to the page.
|
|
346
|
+
const propertyId = settings.propertyId ?? options.propertyId;
|
|
347
|
+
if (!propertyId) {
|
|
348
|
+
console.warn(
|
|
349
|
+
'[hp] not started: this write key covers several properties, so propertyId is required',
|
|
350
|
+
);
|
|
351
|
+
return off(carry);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const { preset } = options;
|
|
355
|
+
const identified = pendingIdentify(propertyId, identity);
|
|
356
|
+
const userId = realId(identity?.userId);
|
|
357
|
+
|
|
358
|
+
const client = createAnalytics({
|
|
359
|
+
apiHost: settings.apiHost ?? resolveApiHost(config.apiHost),
|
|
360
|
+
writeKey,
|
|
361
|
+
propertyId,
|
|
362
|
+
// Known before the first page view, so an event the client sends while starting (the consent
|
|
363
|
+
// answer a returning visitor already gave) carries it too.
|
|
364
|
+
pageType: PAGE_TYPES.has(options.pageType) ? options.pageType : undefined,
|
|
365
|
+
environment: settings.environment,
|
|
366
|
+
queryParamAllowlist: settings.queryParamAllowlist ?? undefined,
|
|
367
|
+
plan: settings.plan,
|
|
368
|
+
holdout: settings.holdouts?.[propertyId],
|
|
369
|
+
// Per request, never per tenant: a site can trade in several currencies and ship anywhere.
|
|
370
|
+
getCurrency: options.getCurrency,
|
|
371
|
+
getShipToCountry: options.getShipToCountry,
|
|
372
|
+
getLocale: options.getLocale,
|
|
373
|
+
consent: consentAdapter(options.consent, settings, preset),
|
|
374
|
+
consentCookieDomain: options.consentCookieDomain,
|
|
375
|
+
storageNames: options.storageNames,
|
|
376
|
+
holdAcrossPages: options.holdAcrossPages === true,
|
|
377
|
+
// Deprecated in 3.6 and ignored; passed on only so debug can say so.
|
|
378
|
+
mintAnonymousId: options.mintAnonymousId,
|
|
379
|
+
debug: options.debug === true,
|
|
380
|
+
// Where device ids come from is the source's: with no setting (settings from before 3.6),
|
|
381
|
+
// the cookie named here, as before.
|
|
382
|
+
anonymousIdSource: settings.anonymousId ?? undefined,
|
|
383
|
+
getAnonymousId: cookie(options.identityCookies?.anonymousId),
|
|
384
|
+
// No session cookie named: the SDK keeps the session itself (3.15).
|
|
385
|
+
getSessionId: options.identityCookies?.sessionId
|
|
386
|
+
? cookie(options.identityCookies.sessionId)
|
|
387
|
+
: undefined,
|
|
388
|
+
// Every page carries the signed-in user; only the identify itself is once per session.
|
|
389
|
+
getUserId: () => userId,
|
|
390
|
+
beforeSend: options.beforeSend,
|
|
391
|
+
...options.extend,
|
|
392
|
+
onConsentStatus(status) {
|
|
393
|
+
if (status === 'granted') {
|
|
394
|
+
granted = true;
|
|
395
|
+
keep();
|
|
396
|
+
}
|
|
397
|
+
if (status === 'denied') {
|
|
398
|
+
forgetSettings(writeKey);
|
|
399
|
+
try {
|
|
400
|
+
sessionStorage.removeItem(
|
|
401
|
+
storageKey(storageName('identified'), propertyId),
|
|
402
|
+
);
|
|
403
|
+
} catch {
|
|
404
|
+
/* nothing was stored */
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
options.extend?.onConsentStatus?.(status);
|
|
408
|
+
},
|
|
409
|
+
onEvent(event) {
|
|
410
|
+
// The session flag is set only once an identify is on its way, so a visitor whose consent
|
|
411
|
+
// arrives later in the session is still identified. Any identify counts, including one the
|
|
412
|
+
// page sends itself at sign-in, so the next page of the session does not repeat it.
|
|
413
|
+
if (event.type === 'identify') {
|
|
414
|
+
const marker = identifyMarker(
|
|
415
|
+
realId(event.userId),
|
|
416
|
+
normaliseSha256(event.traits?.email_sha256),
|
|
417
|
+
);
|
|
418
|
+
try {
|
|
419
|
+
sessionStorage.setItem(
|
|
420
|
+
storageKey(storageName('identified'), propertyId),
|
|
421
|
+
marker,
|
|
422
|
+
);
|
|
423
|
+
} catch {
|
|
424
|
+
/* identify again next page */
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
options.extend?.onEvent?.(event);
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
if (typeof window !== 'undefined')
|
|
432
|
+
window[RECOMMENDATION_CONTEXT] = () => client.recommendationContext();
|
|
433
|
+
|
|
434
|
+
// Before this page's own events: they were made first. Consent here decides, as for any call.
|
|
435
|
+
for (const [method, args, made] of carry.take()) {
|
|
436
|
+
try {
|
|
437
|
+
client[REPLAY](method, args, made);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
console.warn(`[hp] carried ${method} failed`, err);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
client.page(options.pageType ? { type: options.pageType } : {});
|
|
444
|
+
let stopAutocapture = startAutocapture(client, options.autocapture);
|
|
445
|
+
// Impressions count once per page view, so a single-page app's next route reports afresh.
|
|
446
|
+
const page = client.page;
|
|
447
|
+
client.page = (...args) => {
|
|
448
|
+
const event = page(...args);
|
|
449
|
+
stopAutocapture();
|
|
450
|
+
stopAutocapture = startAutocapture(client, options.autocapture);
|
|
451
|
+
return event;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const host = typeof window !== 'undefined' ? window : null;
|
|
455
|
+
if (options.dataLayer && host) {
|
|
456
|
+
const name = options.dataLayer.name ?? 'dataLayer';
|
|
457
|
+
host[name] = host[name] || [];
|
|
458
|
+
connectDataLayer(client, {
|
|
459
|
+
...preset,
|
|
460
|
+
dataLayer: host[name],
|
|
461
|
+
ignore: [
|
|
462
|
+
...(preset?.ignore ?? []),
|
|
463
|
+
...(options.dataLayer.ignore ?? []),
|
|
464
|
+
],
|
|
465
|
+
onError: (err) =>
|
|
466
|
+
options.debug && console.warn('[hp] dataLayer mapping failed', err),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (identified) client.identify(identified);
|
|
471
|
+
return client;
|
|
472
|
+
} catch (err) {
|
|
473
|
+
console.warn('[hp] not started:', err?.message ?? err);
|
|
474
|
+
return off(carry);
|
|
475
|
+
}
|
|
476
|
+
}
|
package/src/carry.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calls made through `hp` before the SDK started, carried to the next page (3.9).
|
|
3
|
+
*
|
|
4
|
+
* A page that navigates before the SDK has started (a sign-in that redirects at once) would lose
|
|
5
|
+
* what `hp` queued for it: the queue is in memory. At pagehide `hp` saves it here, in
|
|
6
|
+
* sessionStorage per write key, and the next page's bootstrap replays it once, in order, with each
|
|
7
|
+
* call's original messageId and time. Consent is the next page's to apply, as for any call: a grant
|
|
8
|
+
* sends them, a refusal drops them.
|
|
9
|
+
*
|
|
10
|
+
* Saving is a device write, so it follows the held store's rule: only once this device has
|
|
11
|
+
* granted analytics (the consent cookie), or unanswered with `holdAcrossPages`. Never after a
|
|
12
|
+
* refusal. And never from a page whose settings turned it off: what was made while the key is off is
|
|
13
|
+
* not collected later (3.9.1).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { HELD_MAX_AGE_MS } from './device-storage.js';
|
|
17
|
+
import { storageKey, storageName } from './storage-scope.js';
|
|
18
|
+
|
|
19
|
+
/** As many calls as `hp` queues for one page. */
|
|
20
|
+
const MAX_CARRIED = 200;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} writeKey
|
|
24
|
+
* @param {() => boolean} mayStore Whether consent allows writing the calls to the device.
|
|
25
|
+
* @param {() => number} [now]
|
|
26
|
+
*/
|
|
27
|
+
export function createCarry(writeKey, mayStore, now = () => Date.now()) {
|
|
28
|
+
const key = () => storageKey(storageName('carried'), writeKey);
|
|
29
|
+
let closed = false;
|
|
30
|
+
|
|
31
|
+
/** What is carried and still fresh. Unreadable is nothing. */
|
|
32
|
+
function read() {
|
|
33
|
+
try {
|
|
34
|
+
const saved = JSON.parse(sessionStorage.getItem(key()) ?? 'null');
|
|
35
|
+
if (!saved || !(now() - saved.savedAt <= HELD_MAX_AGE_MS)) return [];
|
|
36
|
+
return Array.isArray(saved.calls) ? saved.calls : [];
|
|
37
|
+
} catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
/** What an earlier page carried, removed so it is replayed once. Oldest first. */
|
|
44
|
+
take() {
|
|
45
|
+
const calls = read();
|
|
46
|
+
try {
|
|
47
|
+
sessionStorage.removeItem(key());
|
|
48
|
+
} catch {
|
|
49
|
+
/* no storage: nothing was carried */
|
|
50
|
+
}
|
|
51
|
+
return calls;
|
|
52
|
+
},
|
|
53
|
+
/** Adds `calls` to what is carried. False when they cannot be: they stay this page's. */
|
|
54
|
+
save(calls) {
|
|
55
|
+
try {
|
|
56
|
+
if (closed || !mayStore()) return false;
|
|
57
|
+
sessionStorage.setItem(
|
|
58
|
+
key(),
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
savedAt: now(),
|
|
61
|
+
calls: [...read(), ...calls].slice(-MAX_CARRIED),
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
/** This page is off for good: nothing it made is carried. */
|
|
70
|
+
close() {
|
|
71
|
+
closed = true;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|