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