@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,230 @@
1
+ /**
2
+ * Remote settings: everything tenant-level, fetched from the API rather than configured in code.
3
+ *
4
+ * GET {apiHost}/v1/projects/{writeKey}/settings
5
+ *
6
+ * The path analytics-next already uses for cdn.segment.com, so the same response also drives the
7
+ * Segment path when its `cdnURL` points at the apiHost. An unknown or disabled key answers
8
+ * `{ hp: { enabled: false } }`, never 404.
9
+ *
10
+ * The last good answer is cached, only under an analytics grant (it is a device write), and a page
11
+ * with a cache starts from it and revalidates in the background. Until the grant the HTTP cache
12
+ * (`max-age=10` and an `ETag`) saves the round trip or answers a `304`.
13
+ */
14
+
15
+ import { storageKey, storageName } from './storage-scope.js';
16
+
17
+ const isObject = (v) =>
18
+ v !== null && typeof v === 'object' && !Array.isArray(v);
19
+ const isStringArray = (v) =>
20
+ Array.isArray(v) && v.every((s) => typeof s === 'string');
21
+
22
+ export const DISABLED = Object.freeze({ enabled: false });
23
+
24
+ /**
25
+ * Whether a tracking plan lets the track event `name` through. Segment's `plan.track` semantics: an
26
+ * event marked `enabled: false` is dropped at the source, and `__default` covers events the plan
27
+ * does not list.
28
+ */
29
+ export function planAllows(plan, name) {
30
+ const track = plan?.track;
31
+ if (!isObject(track)) return true;
32
+ const rule = Object.hasOwn(track, name) ? track[name] : track.__default;
33
+ return rule?.enabled !== false;
34
+ }
35
+
36
+ export function settingsUrl(apiHost, writeKey) {
37
+ return `${resolveApiHost(apiHost)}/v1/projects/${encodeURIComponent(writeKey)}/settings`;
38
+ }
39
+
40
+ /**
41
+ * Where the SDK talks to when the integrator names no host: the one host every client uses, for
42
+ * test and live keys alike. The key alone selects the tenant, the property and test or live, as
43
+ * with Stripe and LaunchDarkly; a host is only for a first-party proxy or our own nonprod testing.
44
+ */
45
+ export const DEFAULT_API_HOST = 'https://hp.thgingenuity.com';
46
+
47
+ /** The integrator's `apiHost` as an origin, DEFAULT_API_HOST when none is given, null when invalid. */
48
+ export function resolveApiHost(host) {
49
+ return host === undefined || host === null || host === ''
50
+ ? DEFAULT_API_HOST
51
+ : apiOrigin(host);
52
+ }
53
+
54
+ /**
55
+ * An API host as an origin. The settings give a bare host (`api.example.com`, the OpenAPI server
56
+ * variable); an integrator may write either. Anything else is not a host.
57
+ */
58
+ export function apiOrigin(host) {
59
+ if (typeof host !== 'string') return null;
60
+ const value = host.trim().replace(/\/+$/, '');
61
+ if (/^https?:\/\/[^/\s]+$/i.test(value)) return value;
62
+ if (/^[a-z0-9.-]+(:\d+)?$/i.test(value)) return `https://${value}`;
63
+ return null;
64
+ }
65
+
66
+ /** The purposes the property asks for, or null when the settings do not say (report them all). */
67
+ function readPurposes(raw) {
68
+ if (!isObject(raw)) return null;
69
+ const asked = Object.fromEntries(
70
+ Object.entries(raw).filter(([, value]) => typeof value === 'boolean'),
71
+ );
72
+ return Object.keys(asked).length ? asked : null;
73
+ }
74
+
75
+ const isFraction = (value) =>
76
+ typeof value === 'number' && value >= 0 && value <= 1;
77
+
78
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
79
+
80
+ /**
81
+ * Console logging (3.18): on for everyone, or for a session opened with `?hp_debug=<token>`, checked
82
+ * against the token's hash. Off when the settings do not say.
83
+ */
84
+ function readDebug(raw) {
85
+ if (!isObject(raw)) return { enabled: false, tokenSha256: null };
86
+ return {
87
+ enabled: raw.enabled === true,
88
+ tokenSha256:
89
+ typeof raw.tokenSha256 === 'string' && SHA256_HEX.test(raw.tokenSha256)
90
+ ? raw.tokenSha256
91
+ : null,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * The personalisation holdout per property, `{ share, ramp, salt, readoutSlots }`, or null when the settings carry
97
+ * none. An entry that is not a share in [0, 1] with a non-empty salt, or whose ramp is neither null
98
+ * nor in [0, 1], is dropped rather than guessed at: a wrong salt or ramp would assign different
99
+ * shoppers here than at the edge. A ramp absent from older settings is null: no ramp.
100
+ */
101
+ function readHoldouts(raw) {
102
+ if (!isObject(raw)) return null;
103
+ const holdouts = Object.fromEntries(
104
+ Object.entries(raw)
105
+ .filter(
106
+ ([, h]) =>
107
+ isObject(h) &&
108
+ isFraction(h.share) &&
109
+ (h.ramp == null || isFraction(h.ramp)) &&
110
+ typeof h.salt === 'string' &&
111
+ h.salt !== '',
112
+ )
113
+ .map(([propertyId, h]) => [
114
+ propertyId,
115
+ {
116
+ share: h.share,
117
+ ramp: h.ramp ?? null,
118
+ salt: h.salt,
119
+ // The slots split 50/50 between the bandit and their default; none when absent or malformed.
120
+ ...(isStringArray(h.readoutSlots) &&
121
+ h.readoutSlots.length > 0 && { readoutSlots: h.readoutSlots }),
122
+ },
123
+ ]),
124
+ );
125
+ return Object.keys(holdouts).length ? holdouts : null;
126
+ }
127
+
128
+ /** An RFC 6265 cookie name, as the control plane validates it. */
129
+ const COOKIE_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,128}$/;
130
+
131
+ /**
132
+ * Where device ids come from (3.6): `{ source: 'site_cookie', cookie }` or `{ source: 'hp_cookie' }`.
133
+ * Null when the settings do not say, which is what every source meant before 3.6 (the cookie named
134
+ * in the integration's code), and for anything malformed rather than a guess at what was meant.
135
+ */
136
+ function readAnonymousId(raw) {
137
+ if (!isObject(raw)) return null;
138
+ if (raw.source === 'hp_cookie') return { source: 'hp_cookie' };
139
+ if (raw.source !== 'site_cookie') return null;
140
+ if (raw.cookie == null) return { source: 'site_cookie', cookie: null };
141
+ return typeof raw.cookie === 'string' && COOKIE_NAME.test(raw.cookie)
142
+ ? { source: 'site_cookie', cookie: raw.cookie }
143
+ : null;
144
+ }
145
+
146
+ /**
147
+ * The settings response as the SDK uses it, or DISABLED for anything that does not switch it on.
148
+ * Unknown fields are ignored, so the API can add to the response without breaking a deployed SDK.
149
+ * `propertyId` is null for a source scoped to several properties: the page then names its own.
150
+ */
151
+ export function readSettings(body) {
152
+ const hp = isObject(body) ? body.hp : null;
153
+ if (!isObject(hp) || hp.enabled !== true) return DISABLED;
154
+ return {
155
+ enabled: true,
156
+ propertyId:
157
+ typeof hp.propertyId === 'string' && hp.propertyId !== ''
158
+ ? hp.propertyId
159
+ : null,
160
+ environment: typeof hp.environment === 'string' ? hp.environment : null,
161
+ // Strict is the only mode, so only which purposes are reported can vary.
162
+ consentPurposes: readPurposes(hp.consent?.purposes),
163
+ queryParamAllowlist: isStringArray(hp.queryParamAllowlist)
164
+ ? hp.queryParamAllowlist
165
+ : null,
166
+ anonymousId: readAnonymousId(hp.anonymousId),
167
+ apiHost: apiOrigin(hp.apiHost),
168
+ holdouts: readHoldouts(hp.holdouts),
169
+ // The share of visitors sent for (3.18). Anything that is not a share sends for everyone.
170
+ sampleRate: isFraction(hp.sampleRate) ? hp.sampleRate : 1,
171
+ debug: readDebug(hp.debug),
172
+ plan:
173
+ isObject(body.plan) && isObject(body.plan.track)
174
+ ? { track: body.plan.track }
175
+ : null,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * The settings for `writeKey`: DISABLED when the API says so, null when it could not be asked (an
181
+ * outage keeps whatever the cache last held rather than switching a working site off).
182
+ */
183
+ export async function fetchSettings(
184
+ apiHost,
185
+ writeKey,
186
+ fetchImpl = globalThis.fetch,
187
+ ) {
188
+ try {
189
+ const res = await fetchImpl(settingsUrl(apiHost, writeKey), {
190
+ credentials: 'omit',
191
+ headers: { accept: 'application/json' },
192
+ });
193
+ if (!res.ok) return null;
194
+ return readSettings(await res.json());
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ const cacheKey = (writeKey) => storageKey(storageName('settings'), writeKey);
201
+
202
+ /** The last good settings this browser kept for `writeKey`, or null. */
203
+ export function cachedSettings(writeKey) {
204
+ try {
205
+ const raw = localStorage.getItem(cacheKey(writeKey));
206
+ if (!raw) return null;
207
+ const settings = JSON.parse(raw);
208
+ return settings?.enabled === true ? settings : null;
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ export function cacheSettings(writeKey, settings) {
215
+ try {
216
+ if (settings?.enabled === true)
217
+ localStorage.setItem(cacheKey(writeKey), JSON.stringify(settings));
218
+ else localStorage.removeItem(cacheKey(writeKey));
219
+ } catch {
220
+ /* no storage: the next page fetches again */
221
+ }
222
+ }
223
+
224
+ export function forgetSettings(writeKey) {
225
+ try {
226
+ localStorage.removeItem(cacheKey(writeKey));
227
+ } catch {
228
+ /* nothing was kept */
229
+ }
230
+ }
package/src/shape.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Event shaping shared by both delivery paths (the built-in transport and analytics-next).
3
+ */
4
+
5
+ /**
6
+ * The normalised page taxonomy, mirroring the enum in schemas/common/context.json.
7
+ *
8
+ * Duplicated here rather than imported because the SDK ships without the schema package and
9
+ * must stay dependency-free in a browser. assert_page_types_match keeps the two in step.
10
+ */
11
+ export const PAGE_TYPES = new Set([
12
+ 'home',
13
+ 'list',
14
+ 'product',
15
+ 'search',
16
+ 'basket',
17
+ 'checkout',
18
+ 'order_confirmation',
19
+ 'login',
20
+ 'register',
21
+ 'account',
22
+ 'info',
23
+ 'blog',
24
+ 'other',
25
+ ]);
26
+
27
+ /**
28
+ * Drop keys whose value is null or undefined, recursively: absent means "does not apply", which
29
+ * is what almost every storefront null means.
30
+ *
31
+ * For `properties` only. Context nulls are load-bearing (`consent.analytics: null` means the CMP
32
+ * has not answered). `products: null` is kept, so a failed product mapping still fails validation
33
+ * rather than looking like an empty result.
34
+ */
35
+ const NEVER_PRUNE = new Set(['products']);
36
+
37
+ /** Restore an exempt key only when it was null; an array stays pruned. */
38
+ const isNullish = (v) => v === null || v === undefined;
39
+
40
+ /**
41
+ * A Date becomes the ISO string JSON would make of it. A cycle is left in place, not followed: the
42
+ * transport drops an event it cannot serialise, where following it would overflow the stack.
43
+ */
44
+ function pruneNulls(value, ancestors = new WeakSet()) {
45
+ if (value instanceof Date) return value.toJSON();
46
+ if (value === null || typeof value !== 'object') return value;
47
+ if (ancestors.has(value)) return value;
48
+ ancestors.add(value);
49
+ const out = Array.isArray(value)
50
+ ? value.map((v) => pruneNulls(v, ancestors))
51
+ : Object.fromEntries(
52
+ Object.entries(value)
53
+ .filter(([, v]) => !isNullish(v))
54
+ .map(([k, v]) => [k, pruneNulls(v, ancestors)]),
55
+ );
56
+ ancestors.delete(value);
57
+ return out;
58
+ }
59
+
60
+ /** Properties as they go on the wire: nulls pruned, `products: null` kept visible. */
61
+ export function pruneProperties(properties) {
62
+ if (!properties) return properties;
63
+ const pruned = pruneNulls(properties);
64
+ for (const key of NEVER_PRUNE) {
65
+ if (key in properties && isNullish(properties[key]))
66
+ pruned[key] = properties[key];
67
+ }
68
+ return pruned;
69
+ }
70
+
71
+ export function detectNavigationTrigger() {
72
+ try {
73
+ const nav = performance.getEntriesByType('navigation')[0];
74
+ if (!nav) return null;
75
+ return ['navigate', 'reload', 'back_forward', 'prerender'].includes(
76
+ nav.type,
77
+ )
78
+ ? nav.type
79
+ : 'navigate';
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Values platforms put in an id field to mean "no id". Kept as an id, one shared by every visitor
87
+ * would merge them all into one person in the identity graph; the gateway strips them too.
88
+ */
89
+ const PLACEHOLDER_IDS = new Set([
90
+ '',
91
+ 'unknown',
92
+ 'undefined',
93
+ 'null',
94
+ 'none',
95
+ 'nil',
96
+ 'nan',
97
+ 'anonymous',
98
+ 'guest',
99
+ '0',
100
+ '-1',
101
+ ]);
102
+
103
+ /** An id, or null when the value identifies nobody. */
104
+ export function realId(value) {
105
+ if (value == null) return null;
106
+ const text = String(value).trim();
107
+ return PLACEHOLDER_IDS.has(text.toLowerCase()) ? null : text;
108
+ }
109
+
110
+ /** A random UUID: event messageIds and the SDK's own device id. */
111
+ export function uuid() {
112
+ if (typeof crypto !== 'undefined' && crypto.randomUUID)
113
+ return crypto.randomUUID();
114
+ // Fallback for older Safari. Uniqueness matters (it is the idempotency key); cryptographic
115
+ // quality does not.
116
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
117
+ const r = (Math.random() * 16) | 0;
118
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
119
+ });
120
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Social proof for the storefront: how many other shoppers touched a product recently.
3
+ *
4
+ * The one part of this package that reads rather than writes. Failure is silence everywhere: a
5
+ * badge is decoration, so every path resolves to "no badge" rather than rejecting.
6
+ */
7
+
8
+ import { parseConsentCookie, servingHeaders } from './consent.js';
9
+ import { getCookie } from './context.js';
10
+ import { resolveApiHost } from './settings.js';
11
+ import { storageName } from './storage-scope.js';
12
+
13
+ const DEFAULT_TTL_MS = 60_000;
14
+
15
+ /**
16
+ * The cap the API enforces, restated so the SDK chunks: a truncated answer would be
17
+ * indistinguishable from "not enough shoppers to say".
18
+ */
19
+ const MAX_PER_REQUEST = 50;
20
+
21
+ const chunk = (items, size) => {
22
+ const out = [];
23
+ for (let i = 0; i < items.length; i += size)
24
+ out.push(items.slice(i, i + size));
25
+ return out;
26
+ };
27
+
28
+ /**
29
+ * A social proof client for one storefront: `GET {apiHost}/v1/social-proof` on the public API,
30
+ * authenticated with the page's publishable key like every serving read.
31
+ *
32
+ * @param {object} options
33
+ * @param {string} [options.apiHost] As for bootstrap(): optional, for a first-party proxy.
34
+ * @param {string} options.writeKey The page's publishable key. It names the organisation and the
35
+ * properties it may read, so the property is not a parameter.
36
+ * @param {string} [options.propertyId] Only for a key scoped to several properties: which one.
37
+ * @param {number} [options.ttlMs] How long to reuse an answer. The numbers move on a pipeline
38
+ * schedule of tens of minutes.
39
+ * @param {typeof fetch} [options.fetchImpl]
40
+ * @param {() => object|null} [options.getConsent] The visitor's consent state, sent as `hp-consent`
41
+ * like every serving request. Default: read back from the consent cookie the SDK writes; pass
42
+ * `() => analytics.consent` to use the live state instead.
43
+ */
44
+ export function createSocialProof({
45
+ apiHost,
46
+ writeKey,
47
+ propertyId,
48
+ ttlMs = DEFAULT_TTL_MS,
49
+ fetchImpl,
50
+ getConsent,
51
+ }) {
52
+ const base = resolveApiHost(apiHost);
53
+ if (!base)
54
+ throw new Error(
55
+ 'createSocialProof: apiHost, when given, must be a host or an http(s) origin',
56
+ );
57
+ if (!writeKey) throw new Error('createSocialProof: writeKey is required');
58
+
59
+ const doFetch = fetchImpl ?? globalThis.fetch?.bind(globalThis);
60
+ const scope = propertyId
61
+ ? `&property_id=${encodeURIComponent(propertyId)}`
62
+ : '';
63
+ /** productId -> { at, value }. Per-page, deliberately: this is not worth a storage quota. */
64
+ const cache = new Map();
65
+
66
+ const fresh = (entry) => entry && Date.now() - entry.at < ttlMs;
67
+ const consent =
68
+ getConsent ?? (() => parseConsentCookie(getCookie(storageName('consent'))));
69
+
70
+ async function request(query) {
71
+ if (!doFetch) return null;
72
+ try {
73
+ const res = await doFetch(`${base}/v1/social-proof?${query}${scope}`, {
74
+ // No credentials: the answer is the same for every visitor, and a cookie would make it
75
+ // uncacheable.
76
+ credentials: 'omit',
77
+ headers: {
78
+ accept: 'application/json',
79
+ authorization: `Bearer ${writeKey}`,
80
+ ...servingHeaders(consent()),
81
+ },
82
+ });
83
+ if (!res.ok) return null;
84
+ return await res.json();
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ return {
91
+ /**
92
+ * Counts and a resolved badge for each product, keyed by product id. Products below the
93
+ * warehouse's visitor floor are absent.
94
+ */
95
+ async forProducts(productIds, { keyType = 'product' } = {}) {
96
+ const wanted = [
97
+ ...new Set((productIds ?? []).map(String).filter(Boolean)),
98
+ ];
99
+ const result = new Map();
100
+ const missing = [];
101
+
102
+ for (const id of wanted) {
103
+ const hit = cache.get(id);
104
+ if (fresh(hit)) {
105
+ if (hit.value) result.set(id, hit.value);
106
+ } else missing.push(id);
107
+ }
108
+
109
+ await Promise.all(
110
+ chunk(missing, MAX_PER_REQUEST).map(async (batch) => {
111
+ const body = await request(
112
+ `key_type=${encodeURIComponent(keyType)}&products=${batch.map(encodeURIComponent).join(',')}`,
113
+ );
114
+ const found = new Set();
115
+ for (const product of body?.products ?? []) {
116
+ found.add(product.productId);
117
+ cache.set(product.productId, { at: Date.now(), value: product });
118
+ result.set(product.productId, product);
119
+ }
120
+ // Cache the absences too. Without this, every product below the floor is re-requested
121
+ // on every render — which on a listing page is most of the long tail, every time.
122
+ for (const id of batch) {
123
+ if (!found.has(id)) cache.set(id, { at: Date.now(), value: null });
124
+ }
125
+ }),
126
+ );
127
+
128
+ return result;
129
+ },
130
+
131
+ /** The catalogue's trending head, for a "Trending now" rail. Empty array on any failure. */
132
+ async trending(limit = 24, { keyType = 'product' } = {}) {
133
+ const body = await request(
134
+ `key_type=${encodeURIComponent(keyType)}&trending=${Number(limit) || 24}`,
135
+ );
136
+ return body?.products ?? [];
137
+ },
138
+ };
139
+ }
@@ -0,0 +1,4 @@
1
+ // Generated from the spec by scripts/build-types.mjs. DO NOT EDIT: run `pnpm run build:types`.
2
+
3
+ /** The event specification this SDK emits, stamped on every event as context.spec.version. */
4
+ export const SPEC_VERSION = '1.10';
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Browser storage: one prefix, configurable names, and keys scoped to the tenant that owns them.
3
+ *
4
+ * Every key starts `thg_hp_` so a storage audit can name the SDK's footprint in one line; names are
5
+ * configurable for integrators with their own rules. Web storage is keyed by origin and one origin
6
+ * can serve several tenants, so every key is built here with the tenant in it: unscoped, one
7
+ * tenant's retry buffer would be replayed into another's ingest.
8
+ */
9
+
10
+ /** Every name the SDK stores anything under, by what it holds. */
11
+ export const DEFAULT_STORAGE_NAMES = Object.freeze({
12
+ /** Cookie: the visitor's consent decision, for the site's own servers to forward. */
13
+ consent: 'thg_hp_consent',
14
+ /** localStorage, per property: batches waiting for the ingest to answer. */
15
+ retry: 'thg_hp_retry',
16
+ /** localStorage per property and session (sessionStorage until a session is bound). */
17
+ sessionSignals: 'thg_hp_session_signals',
18
+ /** sessionStorage, per property: the identify already sent this session. */
19
+ identified: 'thg_hp_identified',
20
+ /** sessionStorage, per property: the previous page view's messageId. */
21
+ previousPage: 'thg_hp_prev_page',
22
+ /** sessionStorage, per property: the decisions in force, latest per slot (3.7). */
23
+ decisions: 'thg_hp_decisions',
24
+ /** localStorage, per write key: the last good remote settings. */
25
+ settings: 'thg_hp_settings',
26
+ /**
27
+ * sessionStorage, per property: events held for an unanswered CMP, carried to the next page.
28
+ * Only with `holdAcrossPages` (3.5).
29
+ */
30
+ held: 'thg_hp_held',
31
+ /**
32
+ * sessionStorage, per write key: calls made through `hp` before the SDK started, carried to the
33
+ * next page when the page navigates first (3.9).
34
+ */
35
+ carried: 'thg_hp_carried',
36
+ /**
37
+ * localStorage, per origin: the visit as the recommendations API takes it, kept for 30 minutes
38
+ * after the last event, so `hp.recommendationContext()` answers on the next page before the SDK
39
+ * has started there (3.11). Only while personalisation is granted.
40
+ */
41
+ recommendationContext: 'thg_hp_recommendation_context',
42
+ /**
43
+ * Cookie, one per browser: Hyper-Personalisation's device id, for a source set to `hp_cookie`
44
+ * (3.6). 3.5's localStorage id under `<name>:<property>` is adopted into it and removed.
45
+ */
46
+ anonymousId: 'thg_hp_anonymous_id',
47
+ /**
48
+ * Cookie, one per browser: the session id the SDK keeps when the host has none, expiring 30
49
+ * minutes after the last event (3.15). Only under a grant.
50
+ */
51
+ session: 'thg_hp_session',
52
+ /**
53
+ * Cookie, for the browser session: the preview token a trader opened the site with
54
+ * (`?hp_preview=`), for the site's server to forward to `/v1/assignments` (3.15).
55
+ */
56
+ preview: 'thg_hp_preview',
57
+ /**
58
+ * sessionStorage, for the browser session: the debug token a developer opened the site with
59
+ * (`?hp_debug=`), checked against the settings on every page (3.18).
60
+ */
61
+ debug: 'thg_hp_debug',
62
+ });
63
+
64
+ /**
65
+ * The names the SDK used before the prefix, read once, moved and deleted. Only web storage: the
66
+ * consent cookie was already `thg_hp_consent`.
67
+ */
68
+ const LEGACY_NAMES = {
69
+ retry: 'cdp.retry',
70
+ sessionSignals: 'cdp_session_signals',
71
+ identified: 'cdp_identified',
72
+ previousPage: 'cdp_prev_page_message_id',
73
+ };
74
+
75
+ let names = { ...DEFAULT_STORAGE_NAMES };
76
+ let migrated = false;
77
+
78
+ /**
79
+ * Rename what the SDK stores. Unnamed kinds keep their default, and calling with nothing restores
80
+ * every default. One SDK per page: the names are page-wide.
81
+ *
82
+ * @param {Partial<Record<keyof typeof DEFAULT_STORAGE_NAMES, string>>} [overrides]
83
+ */
84
+ export function configureStorageNames(overrides) {
85
+ const next = { ...DEFAULT_STORAGE_NAMES };
86
+ for (const [kind, name] of Object.entries(overrides ?? {})) {
87
+ if (!(kind in DEFAULT_STORAGE_NAMES))
88
+ throw new Error(`storageNames: unknown kind "${kind}"`);
89
+ if (typeof name !== 'string' || !/^[\w.-]+$/.test(name)) {
90
+ throw new Error(
91
+ `storageNames.${kind} must be a non-empty cookie-safe name`,
92
+ );
93
+ }
94
+ next[kind] = name;
95
+ }
96
+ names = next;
97
+ }
98
+
99
+ /** @param {keyof typeof DEFAULT_STORAGE_NAMES} kind */
100
+ export function storageName(kind) {
101
+ return names[kind];
102
+ }
103
+
104
+ /** "No tenant supplied": a literal segment, so no unscoped key is a prefix of a scoped one. */
105
+ const NO_TENANT = '_';
106
+
107
+ const segment = (tenant) =>
108
+ tenant == null || tenant === '' ? NO_TENANT : String(tenant);
109
+
110
+ /**
111
+ * A storage key for `name`, owned by `tenant` and optionally further scoped by `suffix`.
112
+ *
113
+ * @param {string} name The base key, e.g. storageName('retry').
114
+ * @param {string|null} [tenant] The property/tenant id. Absent means a single-tenant host.
115
+ * @param {string|null} [suffix] A further scope within the tenant, e.g. a session id.
116
+ */
117
+ export function storageKey(name, tenant, suffix) {
118
+ const base = `${name}:${segment(tenant)}`;
119
+ return suffix == null || suffix === '' ? base : `${base}:${suffix}`;
120
+ }
121
+
122
+ /**
123
+ * The prefix matching every key `name` owns for `tenant`, for sweeping old scopes. The tenant
124
+ * comes first in a key so a sweep can never reach another tenant's.
125
+ */
126
+ export function storagePrefix(name, tenant) {
127
+ return `${name}:${segment(tenant)}:`;
128
+ }
129
+
130
+ /**
131
+ * Move every legacy `cdp*` key to its current name, once per page, in both stores.
132
+ *
133
+ * The scope suffix (`:<tenant>[:<session>]`) is kept, so each tenant's data lands under its own new
134
+ * key. A value already under the new name wins: it is newer than anything the old SDK left.
135
+ * A rename writes nothing the visitor has not already allowed to be stored, and a refusal still
136
+ * clears the renamed keys exactly as it would have cleared the old ones.
137
+ */
138
+ export function migrateLegacyStorage() {
139
+ if (migrated) return;
140
+ migrated = true;
141
+ for (const get of [() => localStorage, () => sessionStorage]) {
142
+ let store;
143
+ try {
144
+ store = get();
145
+ if (!store) continue;
146
+ for (const key of Object.keys(store)) {
147
+ for (const [kind, legacy] of Object.entries(LEGACY_NAMES)) {
148
+ if (key !== legacy && !key.startsWith(`${legacy}:`)) continue;
149
+ const renamed = names[kind] + key.slice(legacy.length);
150
+ if (store.getItem(renamed) === null)
151
+ store.setItem(renamed, store.getItem(key));
152
+ store.removeItem(key);
153
+ }
154
+ }
155
+ } catch {
156
+ /* no storage, or a quota: the old keys are simply left where they were */
157
+ }
158
+ }
159
+ }
160
+
161
+ /** Test seam: let the next migrateLegacyStorage() run again. */
162
+ export function resetStorageMigration() {
163
+ migrated = false;
164
+ }