@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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +825 -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 +476 -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/decisions.js +90 -0
  22. package/src/device-storage.js +201 -0
  23. package/src/experiments.js +220 -0
  24. package/src/ga4.js +124 -0
  25. package/src/hash.js +138 -0
  26. package/src/hp.js +135 -0
  27. package/src/index.js +818 -0
  28. package/src/preview.js +44 -0
  29. package/src/recommendation-context.js +101 -0
  30. package/src/segment.js +375 -0
  31. package/src/session/category.js +40 -0
  32. package/src/session/record.js +163 -0
  33. package/src/session/rules.js +195 -0
  34. package/src/session/signals.js +146 -0
  35. package/src/session-id.js +62 -0
  36. package/src/session.js +28 -0
  37. package/src/settings.js +211 -0
  38. package/src/shape.js +111 -0
  39. package/src/social-proof.js +139 -0
  40. package/src/spec-version.js +4 -0
  41. package/src/storage-scope.js +159 -0
  42. package/src/transport.js +436 -0
  43. package/src/version.js +2 -0
  44. package/types/auto.d.ts +51 -0
  45. package/types/bootstrap.d.ts +154 -0
  46. package/types/consent.d.ts +126 -0
  47. package/types/datalayer.d.ts +76 -0
  48. package/types/events.d.ts +680 -0
  49. package/types/hp.d.ts +64 -0
  50. package/types/index.d.ts +610 -0
  51. package/types/segment.d.ts +68 -0
@@ -0,0 +1,163 @@
1
+ import { bump, categoryKey } from './category.js';
2
+ import { readSignals, writeSignals } from './signals.js';
3
+
4
+ const RECENT_LIMIT = 20;
5
+ /** Larger than RECENT_LIMIT: one listing page is thirty impressions. */
6
+ const IMPRESSION_LIMIT = 200;
7
+
8
+ /** Product engagement the visitor chose. Anything else carrying products is an impression. */
9
+ const ENGAGEMENT_EVENTS = new Set([
10
+ 'Product Viewed',
11
+ 'Product Clicked',
12
+ 'Product Added',
13
+ 'Product Added to Wishlist',
14
+ ]);
15
+
16
+ /** Engagement the warehouse's category affinity does not count, so a session measure must not. */
17
+ const UNCOUNTED_ENGAGEMENT = new Set([
18
+ 'Product Clicked',
19
+ 'Product Added to Wishlist',
20
+ ]);
21
+
22
+ /**
23
+ * Fold one CDP event into the session signals.
24
+ *
25
+ * Driven by the events the SDK already sends, so a session rule is only ever about something the
26
+ * warehouse will also see.
27
+ */
28
+ export function recordSessionEvent(eventName, properties) {
29
+ const s = readSignals();
30
+ if (!s.startedAt) s.startedAt = Date.now();
31
+
32
+ const products = Array.isArray(properties?.products)
33
+ ? properties.products
34
+ : [];
35
+
36
+ switch (eventName) {
37
+ case 'Product Viewed':
38
+ s.productViews += 1;
39
+ bump(s.recentBrowse, s.browsingCategory);
40
+ break;
41
+ case 'Product List Viewed':
42
+ s.listViews += 1;
43
+ // Only a category list is browsing. A rail or carousel names its widget (no slash, or a
44
+ // `cdp-` id), and counting it would let personalisation measure itself.
45
+ {
46
+ const list = properties?.list_id;
47
+ const isCategoryPath =
48
+ typeof list === 'string' &&
49
+ list.includes('/') &&
50
+ !list.startsWith('cdp-');
51
+ if (isCategoryPath) {
52
+ s.browsingCategory = bump(s.recentBrowse, list) ?? s.browsingCategory;
53
+ }
54
+ }
55
+ break;
56
+ case 'Product List Filtered': {
57
+ // Storefronts whose menus link to facet URLs navigate by filtering.
58
+ const change = properties?.change;
59
+ const list =
60
+ typeof properties?.list_id === 'string' ? properties.list_id : '';
61
+ const facet = typeof change?.value === 'string' ? change.value : '';
62
+ if (facet && change?.action !== 'removed') {
63
+ s.browsingCategory =
64
+ bump(s.recentBrowse, `${list}/${facet}`) ?? s.browsingCategory;
65
+ }
66
+ break;
67
+ }
68
+ case 'Product Added': {
69
+ s.cartAdds += 1;
70
+ bump(s.recentBrowse, s.browsingCategory);
71
+ for (const p of products) {
72
+ const id = p.product_id;
73
+ if (typeof id !== 'string' || !id) continue;
74
+ s.basketProducts = [
75
+ id,
76
+ ...s.basketProducts.filter((x) => x !== id),
77
+ ].slice(0, RECENT_LIMIT);
78
+ }
79
+ break;
80
+ }
81
+ case 'Product Removed': {
82
+ for (const p of products) {
83
+ const id = p.product_id;
84
+ if (typeof id === 'string' && id) {
85
+ s.basketProducts = s.basketProducts.filter((x) => x !== id);
86
+ }
87
+ }
88
+ break;
89
+ }
90
+ case 'Products Searched': {
91
+ s.searches += 1;
92
+ const query = properties?.query;
93
+ if (typeof query === 'string' && query.trim()) {
94
+ // Normalised as the corpus model normalises query tokens.
95
+ const q = query.trim().toLowerCase().slice(0, 64);
96
+ s.recentSearches = [
97
+ q,
98
+ ...s.recentSearches.filter((x) => x !== q),
99
+ ].slice(0, 5);
100
+ }
101
+ break;
102
+ }
103
+ case 'Product Clicked':
104
+ case 'Product Added to Wishlist':
105
+ break;
106
+ default:
107
+ return s;
108
+ }
109
+
110
+ // Being shown a product is not having seen it: impressions are counted, and engagement clears
111
+ // the count. Folding impressions into `recentProducts` makes a rail demote its own tiles.
112
+ const isEngagement = ENGAGEMENT_EVENTS.has(eventName);
113
+ if (!isEngagement) {
114
+ for (const p of products) {
115
+ const master = p.product_id;
116
+ if (typeof master !== 'string' || !master) continue;
117
+ s.impressions[master] = (s.impressions[master] ?? 0) + 1;
118
+ }
119
+ const ids = Object.keys(s.impressions);
120
+ if (ids.length > IMPRESSION_LIMIT) {
121
+ const trimmed = {};
122
+ for (const id of ids
123
+ .sort((a, b) => s.impressions[b] - s.impressions[a])
124
+ .slice(0, IMPRESSION_LIMIT)) {
125
+ trimmed[id] = s.impressions[id];
126
+ }
127
+ s.impressions = trimmed;
128
+ }
129
+ } else {
130
+ for (const p of products) {
131
+ if (typeof p.product_id === 'string' && p.product_id) {
132
+ delete s.impressions[p.product_id];
133
+ }
134
+ }
135
+ }
136
+
137
+ // Once per event, not per product: a listing of thirty items is one statement of interest.
138
+ const seen = new Set();
139
+ for (const p of products) {
140
+ // The master id, as the warehouse aggregates; a variant id changes with the size picked.
141
+ const master = p.product_id;
142
+ if (isEngagement && typeof master === 'string' && master) {
143
+ s.recentProducts = [
144
+ master,
145
+ ...s.recentProducts.filter((id) => id !== master),
146
+ ].slice(0, RECENT_LIMIT);
147
+ }
148
+ if (
149
+ !UNCOUNTED_ENGAGEMENT.has(eventName) &&
150
+ typeof p.category === 'string' &&
151
+ p.category
152
+ ) {
153
+ const key = categoryKey(p.category);
154
+ if (key && !seen.has(key)) {
155
+ seen.add(key);
156
+ bump(s.recentCategories, key);
157
+ }
158
+ }
159
+ }
160
+
161
+ writeSignals(s);
162
+ return s;
163
+ }
@@ -0,0 +1,195 @@
1
+ import { categoryKey } from './category.js';
2
+ import { readSignals } from './signals.js';
3
+
4
+ /**
5
+ * Operators under every spelling the audience store holds (`gte` from the builder,
6
+ * `greater_than_or_equal` from the seeds). An unknown operator fails the rule closed.
7
+ */
8
+ const OPERATORS = {
9
+ gte: (a, b) => a >= b,
10
+ greater_than_or_equal: (a, b) => a >= b,
11
+ '>=': (a, b) => a >= b,
12
+ lte: (a, b) => a <= b,
13
+ less_than_or_equal: (a, b) => a <= b,
14
+ '<=': (a, b) => a <= b,
15
+ eq: (a, b) => a === b,
16
+ equals: (a, b) => a === b,
17
+ '=': (a, b) => a === b,
18
+ };
19
+
20
+ /**
21
+ * Every operator this evaluator can apply, including `between`. A contract test keeps the session
22
+ * trait catalogue inside this set, since an unsupported operator fails closed silently.
23
+ */
24
+ export const SESSION_OPERATORS = new Set([
25
+ ...Object.keys(OPERATORS),
26
+ 'between',
27
+ ]);
28
+
29
+ /** Recent interactions with this category or anything beneath it. */
30
+ function interactions(trail, prefix) {
31
+ const key = categoryKey(prefix);
32
+ if (!key) return 0;
33
+ // `damen/mode` must not match `damen/modern`, so a prefix only counts at a separator.
34
+ return trail.filter((entry) => entry === key || entry.startsWith(`${key}/`))
35
+ .length;
36
+ }
37
+
38
+ /**
39
+ * This category's share of recent attention, as a fraction between 0 and 1: the same unit as the
40
+ * warehouse's `affinity_share`. A share falls as attention moves, where a count only ever grows.
41
+ */
42
+ function share(trail, prefix) {
43
+ if (trail.length === 0) return 0;
44
+ return interactions(trail, prefix) / trail.length;
45
+ }
46
+
47
+ const MEASURES = {
48
+ categoryViews: (s, arg) => interactions(s.recentCategories, arg),
49
+ categoryShare: (s, arg) => share(s.recentCategories, arg),
50
+ browseViews: (s, arg) => interactions(s.recentBrowse, arg),
51
+ browseShare: (s, arg) => share(s.recentBrowse, arg),
52
+ };
53
+
54
+ /**
55
+ * What a rule's field is worth right now, or null if this build does not know the field.
56
+ *
57
+ * A field is a counter name or `measure.dimension` (`browseShare.damen/schuhe`). Exported so a
58
+ * storefront can explain a personalisation. Unknown fields fail the rule closed.
59
+ */
60
+ export function measureField(field, signals) {
61
+ const separator = field.indexOf('.');
62
+ if (separator > 0) {
63
+ const fn = MEASURES[field.slice(0, separator)];
64
+ const arg = field.slice(separator + 1);
65
+ return fn && arg ? fn(signals, arg) : null;
66
+ }
67
+ const value = signals[field];
68
+ return typeof value === 'number' ? value : null;
69
+ }
70
+
71
+ export function evaluateSessionRule(rule, signals) {
72
+ const actual = measureField(rule.field, signals);
73
+ if (actual === null) return false;
74
+ if (rule.operator === 'between') {
75
+ // A `between` missing either bound fails closed.
76
+ return (
77
+ typeof rule.value === 'number' &&
78
+ typeof rule.value2 === 'number' &&
79
+ actual >= rule.value &&
80
+ actual <= rule.value2
81
+ );
82
+ }
83
+ const compare = OPERATORS[rule.operator];
84
+ if (!compare || typeof rule.value !== 'number') return false;
85
+ return compare(actual, rule.value);
86
+ }
87
+
88
+ /**
89
+ * Whether an audience's session rules hold, combined group-then-join: the same reduction the SQL
90
+ * compiler performs, so both evaluators reach the same verdict.
91
+ */
92
+ export function sessionRulesHold(audience, signals) {
93
+ const rules = audience.rules ?? [];
94
+ if (rules.length === 0) return false;
95
+
96
+ const byGroup = new Map();
97
+ for (const rule of rules) {
98
+ const group = rule.group ?? 0;
99
+ const bucket = byGroup.get(group);
100
+ if (bucket) bucket.push(rule);
101
+ else byGroup.set(group, [rule]);
102
+ }
103
+
104
+ const groupOperators = audience.groupOperators ?? [];
105
+ const groupResults = [];
106
+ for (const [group, groupRules] of byGroup) {
107
+ groupResults.push(
108
+ groupOperators[group] === 'OR'
109
+ ? groupRules.some((r) => evaluateSessionRule(r, signals))
110
+ : groupRules.every((r) => evaluateSessionRule(r, signals)),
111
+ );
112
+ }
113
+
114
+ return audience.groupJoin === 'OR'
115
+ ? groupResults.some(Boolean)
116
+ : groupResults.every(Boolean);
117
+ }
118
+
119
+ /**
120
+ * The audiences this visitor is in right now: precomputed membership from the edge, plus any
121
+ * session audience whose rules hold.
122
+ *
123
+ * A MIXED audience also requires its precomputed half (its slug in `qualifiedFor`), so a session
124
+ * rule can never admit someone the batch half has not qualified.
125
+ *
126
+ * `qualifiedFor` also names an untargetable audience the visitor is in that a targetable one
127
+ * requires: it satisfies `requires`, and like everything untargetable is never returned.
128
+ */
129
+ export function resolveAudiences(
130
+ precomputed,
131
+ sessionAudiences,
132
+ signals = readSignals(),
133
+ qualifiedFor = [],
134
+ ) {
135
+ return resolveAudienceMemberships(
136
+ precomputed,
137
+ sessionAudiences,
138
+ signals,
139
+ qualifiedFor,
140
+ ).map((m) => m.id);
141
+ }
142
+
143
+ /**
144
+ * The same resolution, with where each membership came from, for `context.audiences`.
145
+ *
146
+ * Computed now because this is the only moment the answer is knowable. `algorithm_version` is null
147
+ * for a rule: it names a model revision, and a hand-written rule has none.
148
+ */
149
+ export function resolveAudienceMemberships(
150
+ precomputed,
151
+ sessionAudiences,
152
+ signals = readSignals(),
153
+ qualifiedFor = [],
154
+ ) {
155
+ const active = new Set(precomputed);
156
+ const bySlug = new Map();
157
+ // Qualifying on history is not membership of a mixed audience, so this stays out of `active`.
158
+ const qualified = new Set(qualifiedFor);
159
+ const sessionSlugs = new Set(sessionAudiences.map((a) => a.slug));
160
+ // A qualified slug with no session rules of its own is an untargetable precomputed audience the
161
+ // visitor is in: it meets a `requires`, though nobody may target it.
162
+ const met = (slug) =>
163
+ active.has(slug) || (qualified.has(slug) && !sessionSlugs.has(slug));
164
+ // An untargetable session audience is evaluated for a `requires` and never reported.
165
+ const untargetable = new Set(
166
+ sessionAudiences.filter((a) => a.targetable === false).map((a) => a.slug),
167
+ );
168
+
169
+ // Iterated to a fixed point, because one session audience may require another listed after it.
170
+ let admitted = true;
171
+ while (admitted) {
172
+ admitted = false;
173
+ for (const audience of sessionAudiences) {
174
+ if (active.has(audience.slug)) continue;
175
+ const required = audience.requires ?? [];
176
+ if (!required.every(met)) continue;
177
+ if (audience.mixed && !qualified.has(audience.slug)) continue;
178
+ if (!audience.rules || audience.rules.length === 0) continue;
179
+ if (sessionRulesHold(audience, signals)) {
180
+ active.add(audience.slug);
181
+ // Mixed is its own source: half of it was decided by a batch run, not by this visit.
182
+ bySlug.set(audience.slug, audience.mixed ? 'mixed' : 'session');
183
+ admitted = true;
184
+ }
185
+ }
186
+ }
187
+
188
+ return [...active]
189
+ .filter((id) => !untargetable.has(id))
190
+ .map((id) => ({
191
+ id,
192
+ source: bySlug.get(id) ?? 'warehouse',
193
+ algorithm_version: null,
194
+ }));
195
+ }
@@ -0,0 +1,146 @@
1
+ import { storageKey, storageName, storagePrefix } from '../storage-scope.js';
2
+
3
+ /** Read at use, not import, so a renamed `storageNames.sessionSignals` applies. */
4
+ const baseKey = () => storageName('sessionSignals');
5
+
6
+ /*
7
+ * A session is browser-wide, as the host's session cookie and the warehouse's session tables are,
8
+ * so once the host supplies its session id the counters live in localStorage keyed by it. Unset,
9
+ * they stay in tab-local sessionStorage.
10
+ */
11
+ let scope = null;
12
+
13
+ const scopedKey = () => scope ?? baseKey();
14
+
15
+ function store() {
16
+ try {
17
+ return scope ? localStorage : sessionStorage;
18
+ } catch {
19
+ // Accessing either throws outright in some embedded webviews.
20
+ return undefined;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Bind the counters to a session, and drop this tenant's other sessions.
26
+ *
27
+ * `propertyId` scopes the key to a tenant: one origin can serve several tenants, and a tenant-blind
28
+ * key would carry one brand's browsing into another's visit. A host serving several tenants on one
29
+ * origin must pass it.
30
+ */
31
+ export function setSessionScope(sessionId, propertyId) {
32
+ const tenant = propertyId == null ? null : String(propertyId);
33
+ const next = sessionId ? storageKey(baseKey(), tenant, sessionId) : null;
34
+ if (next === scope) return;
35
+
36
+ // Events recorded before the first scope (the SDK can boot after the first Product Viewed) are
37
+ // carried into the scoped key, unless that key already holds a visit in progress.
38
+ const pending = scope === null ? readUnscoped() : null;
39
+
40
+ scope = next;
41
+ if (!scope) return;
42
+
43
+ try {
44
+ const keep = scopedKey();
45
+ // Only this tenant's sessions: another tenant on the same origin may have a live visit.
46
+ const mine = storagePrefix(baseKey(), tenant);
47
+ for (const key of Object.keys(localStorage)) {
48
+ if (key.startsWith(mine) && key !== keep) localStorage.removeItem(key);
49
+ }
50
+ if (pending && !localStorage.getItem(keep)) {
51
+ localStorage.setItem(keep, JSON.stringify(pending));
52
+ // Dropped once adopted, so a later re-scope cannot replay it into a different session.
53
+ sessionStorage.removeItem(baseKey());
54
+ }
55
+ } catch {
56
+ // Quota, private browsing, or a disabled store: nothing to sweep and nothing to carry.
57
+ }
58
+ }
59
+
60
+ /** The tab-local signals recorded before scoping, or null when the visit has done nothing yet. */
61
+ function readUnscoped() {
62
+ try {
63
+ const raw = sessionStorage.getItem(baseKey());
64
+ if (!raw) return null;
65
+ const parsed = JSON.parse(raw);
66
+ const busy =
67
+ parsed &&
68
+ (parsed.productViews > 0 ||
69
+ parsed.listViews > 0 ||
70
+ parsed.recentProducts?.length ||
71
+ parsed.recentBrowse?.length);
72
+ return busy ? parsed : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /** A fresh object every call: `bump()` mutates the arrays in place, so they must not be shared. */
79
+ function emptySignals() {
80
+ return {
81
+ productViews: 0,
82
+ listViews: 0,
83
+ cartAdds: 0,
84
+ searches: 0,
85
+ recentProducts: [],
86
+ recentCategories: [],
87
+ recentBrowse: [],
88
+ basketProducts: [],
89
+ recentSearches: [],
90
+ // Shown-and-ignored counts, kept apart from `recentProducts` so a rail's own impressions never
91
+ // read as interest. The threshold that makes them meaningful lives server-side in `fatigue.ts`.
92
+ impressions: {},
93
+ browsingCategory: null,
94
+ startedAt: 0,
95
+ };
96
+ }
97
+
98
+ /*
99
+ * Every event re-reads before it writes, which is how sibling tabs stay in step. Two tabs writing
100
+ * in the same instant can lose one increment (last write wins); acceptable for threshold rules.
101
+ */
102
+ export function readSignals() {
103
+ const s = store();
104
+ if (!s) return emptySignals();
105
+ try {
106
+ const raw = s.getItem(scopedKey());
107
+ if (!raw) return { ...emptySignals(), startedAt: Date.now() };
108
+ return { ...emptySignals(), ...JSON.parse(raw) };
109
+ } catch {
110
+ return { ...emptySignals(), startedAt: Date.now() };
111
+ }
112
+ }
113
+
114
+ export function writeSignals(signals) {
115
+ const s = store();
116
+ if (!s) return;
117
+ try {
118
+ s.setItem(scopedKey(), JSON.stringify(signals));
119
+ } catch {
120
+ // Quota or a disabled store: the session rules simply do not fire.
121
+ }
122
+ }
123
+
124
+ export function getSessionSignals() {
125
+ return readSignals();
126
+ }
127
+
128
+ /**
129
+ * Forget this visit's signals: the consent-withdrawal path.
130
+ *
131
+ * Clears both stores and every scope, for every tenant on this origin. Unlike the sweep in
132
+ * `setSessionScope` this is deliberately not tenant-scoped: consent is withdrawn on the device.
133
+ */
134
+ export function clearSessionSignals() {
135
+ for (const get of [() => sessionStorage, () => localStorage]) {
136
+ try {
137
+ const s = get();
138
+ s.removeItem(baseKey());
139
+ for (const key of Object.keys(s)) {
140
+ if (key.startsWith(`${baseKey()}:`)) s.removeItem(key);
141
+ }
142
+ } catch {
143
+ // Nothing to clear if the store is unavailable.
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The session id the SDK keeps when the host names none (3.15): a first-party cookie holding a
3
+ * random id, ended by 30 minutes without an event, as Amplitude and GA4 sessionise. A host that
4
+ * has its own session (`getSessionId`, `identityCookies.sessionId`) keeps it: the SDK never mints a
5
+ * second one beside it.
6
+ *
7
+ * The cookie is shared by the site's tabs and readable by its servers. Under strict consent it is
8
+ * written only once analytics is granted; until then the id lives in memory, and it is deleted on
9
+ * a refusal, like the device cookie.
10
+ */
11
+
12
+ import { getCookie } from './context.js';
13
+ import { writeCookie } from './device-storage.js';
14
+ import { uuid } from './shape.js';
15
+
16
+ /** Inactivity that ends a session. */
17
+ export const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
18
+
19
+ /**
20
+ * @param {object} options
21
+ * @param {string} options.name The cookie name, storageName('session').
22
+ * @param {string|null} [options.domain]
23
+ * @param {() => boolean} options.mayStore Strict consent: analytics is granted.
24
+ * @param {() => number} [options.now]
25
+ * @param {() => string} [options.mint]
26
+ */
27
+ export function createSessionId({
28
+ name,
29
+ domain = null,
30
+ mayStore,
31
+ now = () => Date.now(),
32
+ mint = uuid,
33
+ }) {
34
+ let id = null;
35
+ let lastSeen = 0;
36
+
37
+ const persist = () => {
38
+ if (id !== null && mayStore())
39
+ writeCookie(name, id, SESSION_TIMEOUT_MS / 1000, domain);
40
+ };
41
+
42
+ return {
43
+ /** The current session, started afresh after 30 minutes without one; each call is activity. */
44
+ get() {
45
+ const at = now();
46
+ // Read whatever consent says: an earlier page wrote it under a grant this page has yet to hear.
47
+ const stored = getCookie(name);
48
+ if (stored) id = stored;
49
+ else if (id === null || at - lastSeen >= SESSION_TIMEOUT_MS) id = mint();
50
+ lastSeen = at;
51
+ persist();
52
+ return id;
53
+ },
54
+ /** Consent was granted: write the session started before it. */
55
+ persist,
56
+ /** Consent was refused or withdrawn. */
57
+ forget() {
58
+ writeCookie(name, '', 0, domain);
59
+ id = null;
60
+ },
61
+ };
62
+ }
package/src/session.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * In-session audience membership, evaluated in the browser.
3
+ *
4
+ * The warehouse answers "who is this person"; this answers "what are they doing right now" from
5
+ * the events the SDK already emits. A MIXED audience is the intersection of the two. Counters
6
+ * live as long as the visit, and the host decides which visit that is (`setSessionScope`).
7
+ */
8
+
9
+ export { recommendationContextFrom } from './recommendation-context.js';
10
+ export {
11
+ AFFINITY_WINDOW,
12
+ categoryKey,
13
+ pathPrefix,
14
+ } from './session/category.js';
15
+ export { recordSessionEvent } from './session/record.js';
16
+ export {
17
+ evaluateSessionRule,
18
+ measureField,
19
+ resolveAudienceMemberships,
20
+ resolveAudiences,
21
+ SESSION_OPERATORS,
22
+ sessionRulesHold,
23
+ } from './session/rules.js';
24
+ export {
25
+ clearSessionSignals,
26
+ getSessionSignals,
27
+ setSessionScope,
28
+ } from './session/signals.js';