@thg-agentic/hyper-personalisation-web 3.19.0 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -183,6 +183,64 @@ export function normaliseSha256(value) {
183
183
  return hex === EMPTY_SHA256 ? null : hex;
184
184
  }
185
185
 
186
+ /** The identify traits the spec defines (core/identify, `additionalProperties: false`), by type. */
187
+ export const IDENTIFY_TRAITS = Object.freeze({
188
+ email_sha256: 'sha256',
189
+ phone_sha256: 'sha256',
190
+ accepts_marketing: 'boolean',
191
+ gender: 'string',
192
+ created_at: 'date-time',
193
+ });
194
+
195
+ const RFC3339 =
196
+ /^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d+)?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)$/i;
197
+
198
+ /** An RFC 3339 date-time naming a real day: 2026-02-30 is not one, though Date.parse rolls it on. */
199
+ function isDateTime(value) {
200
+ if (typeof value !== 'string' || !RFC3339.test(value)) return false;
201
+ const [year, month, day] = value.slice(0, 10).split('-').map(Number);
202
+ const date = new Date(Date.UTC(year, month - 1, day));
203
+ return (
204
+ date.getUTCMonth() === month - 1 &&
205
+ date.getUTCDate() === day &&
206
+ !Number.isNaN(Date.parse(value))
207
+ );
208
+ }
209
+
210
+ /** One trait as the spec takes it, or undefined when it is not one: then it is not sent. */
211
+ function identifyTrait(kind, value) {
212
+ if (value === null) return null;
213
+ if (kind === 'sha256') return normaliseSha256(value) ?? undefined;
214
+ if (kind === 'date-time') {
215
+ if (value instanceof Date)
216
+ return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
217
+ return isDateTime(value) ? value : undefined;
218
+ }
219
+ return typeof value === kind ? value : undefined;
220
+ }
221
+
222
+ /**
223
+ * Only the traits the spec defines, each of its type: a raw email or phone never leaves the page, and
224
+ * an identify is never quarantined for an extra key. Digests in hex or base64 are sent as hex.
225
+ */
226
+ function identifyTraits(traits, debug) {
227
+ const kept = {};
228
+ const dropped = [];
229
+ for (const [key, value] of Object.entries(traits)) {
230
+ const kind = Object.hasOwn(IDENTIFY_TRAITS, key)
231
+ ? IDENTIFY_TRAITS[key]
232
+ : null;
233
+ const trait = kind ? identifyTrait(kind, value) : undefined;
234
+ if (trait === undefined) dropped.push(key);
235
+ else kept[key] = trait;
236
+ }
237
+ if (debug && dropped.length)
238
+ console.warn(
239
+ `[hp] identify: not sent, not spec traits: ${dropped.join(', ')}`,
240
+ );
241
+ return kept;
242
+ }
243
+
186
244
  // On the main entry so integrators find them: without an assigned variant a rail is unmeasurable.
187
245
  export {
188
246
  assignRecommendationVariant,
@@ -375,6 +433,10 @@ export function createAnalytics(options) {
375
433
  function compose(partial, ctxOverrides, force) {
376
434
  const made = replaying;
377
435
  replaying = null;
436
+ // The plan is keyed as the gateway keys it: a track's name, otherwise the call's type. A
437
+ // withdrawal is the record of the refusal, so no plan drops it.
438
+ const key = partial.type === 'track' ? partial.event : partial.type;
439
+ if (!force && !planAllows(cfg.plan, key)) return null;
378
440
  let event = {
379
441
  messageId: made?.messageId ?? uuid(),
380
442
  timestamp: made?.timestamp ?? new Date().toISOString(),
@@ -514,9 +576,7 @@ export function createAnalytics(options) {
514
576
  }
515
577
 
516
578
  const track = (name, properties, ctx) =>
517
- planAllows(cfg.plan, name)
518
- ? emit({ type: 'track', event: name, properties: properties ?? {} }, ctx)
519
- : null;
579
+ emit({ type: 'track', event: name, properties: properties ?? {} }, ctx);
520
580
 
521
581
  // One exposure event per serving response, however often the page reuses it.
522
582
  const exposedTokens = new Set();
@@ -588,6 +648,8 @@ export function createAnalytics(options) {
588
648
  // forgets the signals an earlier page kept under the permission.
589
649
  if (state?.personalisation !== true)
590
650
  forgetKeptVisit(previous?.personalisation === true);
651
+ // Events collected under the grant are not sent after it is withdrawn: only the withdrawal.
652
+ if (withdrawal) transport.discardQueued?.();
591
653
  emit(
592
654
  {
593
655
  type: 'track',
@@ -682,9 +744,19 @@ export function createAnalytics(options) {
682
744
  );
683
745
  }
684
746
 
747
+ /** After a sign-out is sent under the signed-in identity: clear it for whoever uses the device next. */
748
+ function signOut(event) {
749
+ if (userId) forgetVisitor();
750
+ userId = null;
751
+ identifiedUserId = null;
752
+ optionsUserId = null;
753
+ return event;
754
+ }
755
+
685
756
  return guardMethods({
686
757
  /** Escape hatch for events not yet in the core spec. Prefer a named method. */
687
- track,
758
+ track: (event, p, c) =>
759
+ event === 'Signed Out' ? signOut(track(event, p, c)) : track(event, p, c),
688
760
 
689
761
  page(props, ctx) {
690
762
  props ??= {};
@@ -744,13 +816,10 @@ export function createAnalytics(options) {
744
816
  userId = next;
745
817
  if (next && source !== IDENTITY_OPTION) identifiedUserId = next;
746
818
  }
747
- // A digest in hex or base64 is sent as hex; anything that is not one is not sent.
748
- if (typeof rest.email_sha256 === 'string') {
749
- const email = normaliseSha256(rest.email_sha256);
750
- if (email) rest.email_sha256 = email;
751
- else delete rest.email_sha256;
752
- }
753
- return emit({ type: 'identify', traits: rest }, ctx);
819
+ return emit(
820
+ { type: 'identify', traits: identifyTraits(rest, cfg.debug) },
821
+ ctx,
822
+ );
754
823
  },
755
824
 
756
825
  /**
@@ -885,14 +954,7 @@ export function createAnalytics(options) {
885
954
  signedIn: (p, c) => track('Signed In', p, c),
886
955
  signedUp: (p, c) => track('Signed Up', p, c),
887
956
  /** Sent under the signed-in identity, which is then cleared for whoever uses the device next. */
888
- signedOut(c) {
889
- const event = track('Signed Out', {}, c);
890
- if (userId) forgetVisitor();
891
- userId = null;
892
- identifiedUserId = null;
893
- optionsUserId = null;
894
- return event;
895
- },
957
+ signedOut: (c) => signOut(track('Signed Out', {}, c)),
896
958
  newsletterSubscribed: (p, c) => track('Newsletter Subscribed', p, c),
897
959
  /** An on-site opt-out. The ESP reports its own as Email Unsubscribed. */
898
960
  newsletterUnsubscribed: (p, c) => track('Newsletter Unsubscribed', p, c),
package/src/segment.js CHANGED
@@ -33,7 +33,7 @@ import { hostConsent, reportingPurposes } from './consent.js';
33
33
  import { createConsentGate } from './consent-gate.js';
34
34
  import { buildContext, CONTEXT_KEYS } from './context.js';
35
35
  import { deviceIdFrom } from './device-storage.js';
36
- import { planAllows, readSettings, resolveApiHost } from './settings.js';
36
+ import { eventsHost, planAllows, readSettings } from './settings.js';
37
37
 
38
38
  export { DEFAULT_API_HOST } from './settings.js';
39
39
 
@@ -134,12 +134,20 @@ export function hpLoadOptions({ writeKey, apiHost }) {
134
134
  disableClientPersistence: true,
135
135
  updateCDNSettings(cdnSettings) {
136
136
  const integrations = { ...cdnSettings.integrations };
137
- if (!integrations['Segment.io']?.apiHost) {
138
- const origin =
139
- readSettings(cdnSettings).apiHost ?? resolveApiHost(apiHost);
137
+ const block = integrations['Segment.io'];
138
+ // The settings' block is kept only where the settings' host would be: no apiHost set, and https.
139
+ const configured =
140
+ apiHost !== undefined && apiHost !== null && apiHost !== '';
141
+ if (
142
+ configured ||
143
+ !block?.apiHost ||
144
+ (block.protocol ?? 'https') !== 'https'
145
+ ) {
146
+ const origin = eventsHost(apiHost, readSettings(cdnSettings).apiHost);
140
147
  if (!origin) return cdnSettings;
141
148
  const { protocol, host } = new URL(origin);
142
149
  integrations['Segment.io'] = {
150
+ ...block,
143
151
  apiKey: writeKey,
144
152
  apiHost: `${host}/v1`,
145
153
  protocol: protocol.replace(':', ''),
@@ -156,8 +164,8 @@ export function hpLoadOptions({ writeKey, apiHost }) {
156
164
 
157
165
  /**
158
166
  * @param {object} options Same names and meanings as createAnalytics().
159
- * @param {string} [options.propertyId] For a key scoped to several properties, or without settings:
160
- * the `hp` block's propertyId wins when it has one.
167
+ * @param {string} [options.propertyId] The page's property. It wins over the `hp` block's, which is
168
+ * the source's default property, as on Android, iOS and React Native.
161
169
  * @param {string} [options.channel]
162
170
  * @param {string} [options.market]
163
171
  * @param {string} [options.environment]
@@ -181,7 +189,6 @@ export function hpLoadOptions({ writeKey, apiHost }) {
181
189
  * @param {object} [options.anonymousIdSource] Only without settings: the `hp` block's wins.
182
190
  * @param {() => string|null} [options.getPageType]
183
191
  * @param {() => string|null} [options.getSessionId]
184
- * @param {() => string|null} [options.getRenderId]
185
192
  * @param {() => object|null} [options.getExtensions]
186
193
  * @param {() => object[]|null} [options.getExperiments]
187
194
  * @param {() => object[]|null} [options.getAudiences]
@@ -211,7 +218,7 @@ export function cdpPlugins(options = {}) {
211
218
  if (cdnSettings?.hp !== undefined) {
212
219
  const settings = readSettings(cdnSettings);
213
220
  if (settings.enabled) {
214
- cfg.propertyId = settings.propertyId ?? cfg.propertyId;
221
+ cfg.propertyId = cfg.propertyId ?? settings.propertyId;
215
222
  cfg.environment = settings.environment;
216
223
  if (settings.queryParamAllowlist)
217
224
  cfg.queryParamAllowlist = settings.queryParamAllowlist;
@@ -244,7 +251,8 @@ export function cdpPlugins(options = {}) {
244
251
  delete context[WITHDRAWN_AS];
245
252
 
246
253
  if (off) throw cancellation(ctx, 'not enabled for this write key');
247
- if (ctx.event.type === 'track' && !planAllows(cfg.plan, ctx.event.event)) {
254
+ const key = ctx.event.type === 'track' ? ctx.event.event : ctx.event.type;
255
+ if (!forced && !planAllows(cfg.plan, key)) {
248
256
  throw cancellation(ctx, 'disabled by the tracking plan');
249
257
  }
250
258
 
@@ -1,4 +1,4 @@
1
- import { bump, categoryKey } from './category.js';
1
+ import { bump, categoryKey, pathPrefix } from './category.js';
2
2
  import { readSignals, writeSignals } from './signals.js';
3
3
 
4
4
  const RECENT_LIMIT = 20;
@@ -20,6 +20,40 @@ const ENGAGEMENT_EVENTS = new Set([
20
20
  */
21
21
  const CATEGORY_EVENTS = new Set(['Product Viewed', 'Product Added']);
22
22
 
23
+ /** The most listing categories a visit counts views of: more than any visit browses. */
24
+ const LIST_CATEGORY_LIMIT = 50;
25
+
26
+ /**
27
+ * One interaction with a browse key, and beside it the category of the listing it was reached from
28
+ * (null for none), so each trail entry is judged on its own listing, as gold judges each event.
29
+ * Returns the key used.
30
+ */
31
+ function browsed(s, value) {
32
+ const key = bump(s.recentBrowse, value);
33
+ if (!key) return null;
34
+ // Older visits have no categories: the two lists stay aligned at the head, where entries enter.
35
+ s.recentBrowseCategories = [
36
+ s.browsingListing?.category ?? null,
37
+ ...s.recentBrowseCategories,
38
+ ].slice(0, s.recentBrowse.length);
39
+ return key;
40
+ }
41
+
42
+ /** One listing view of a category, the counts kept to the categories most viewed. */
43
+ function countListView(s, category) {
44
+ const counts = s.listViewsByCategory;
45
+ counts[category] = (counts[category] ?? 0) + 1;
46
+ const categories = Object.keys(counts);
47
+ if (categories.length > LIST_CATEGORY_LIMIT) {
48
+ const kept = {};
49
+ for (const c of categories
50
+ .sort((a, b) => counts[b] - counts[a])
51
+ .slice(0, LIST_CATEGORY_LIMIT))
52
+ kept[c] = counts[c];
53
+ s.listViewsByCategory = kept;
54
+ }
55
+ }
56
+
23
57
  /**
24
58
  * Fold one CDP event into the session signals.
25
59
  *
@@ -37,38 +71,64 @@ export function recordSessionEvent(eventName, properties) {
37
71
  switch (eventName) {
38
72
  case 'Product Viewed':
39
73
  s.productViews += 1;
40
- bump(s.recentBrowse, s.browsingCategory);
74
+ browsed(s, s.browsingCategory);
41
75
  break;
42
76
  case 'Product List Viewed':
43
77
  // Only a category list is browsing, or a category page viewed. A rail or carousel names its
44
78
  // widget (no slash, or a `cdp-` id), and counting it would let personalisation measure itself.
79
+ // A listing must name its own `category`, as the warehouse requires. Each trail entry keeps
80
+ // that listing's category, as gold judges each event on its own: whether the catalogue files
81
+ // products under it (a category page, not a merchandising list) is decided against the
82
+ // datafile's `browse_categories` when audiences are resolved.
45
83
  {
46
84
  const list = properties?.list_id;
85
+ const category = properties?.category;
47
86
  const isCategoryPath =
48
87
  typeof list === 'string' &&
49
88
  list.includes('/') &&
50
89
  !list.startsWith('cdp-');
51
- if (isCategoryPath) {
52
- s.listViews += 1;
53
- s.browsingCategory = bump(s.recentBrowse, list) ?? s.browsingCategory;
90
+ if (!isCategoryPath) break;
91
+ const listed =
92
+ typeof category === 'string'
93
+ ? pathPrefix(categoryKey(category)) || null
94
+ : null;
95
+ const key = listed ? pathPrefix(categoryKey(list)) : '';
96
+ if (!key) {
97
+ // A page that is not recorded: what is viewed from it is not the last page's browsing.
98
+ s.browsingCategory = null;
99
+ s.browsingListing = null;
100
+ break;
54
101
  }
102
+ s.browsingListing = { key, category: listed };
103
+ browsed(s, key);
104
+ s.browsingCategory = key;
105
+ countListView(s, listed);
106
+ s.listViews += 1;
55
107
  }
56
108
  break;
57
109
  case 'Product List Filtered': {
58
- // Storefronts whose menus link to facet URLs navigate by filtering.
110
+ // Storefronts whose menus link to facet URLs navigate by filtering: a facet of the listing
111
+ // being browsed, which carries that listing's category. A facet of a listing that was not
112
+ // recorded is not browsing either.
59
113
  const change = properties?.change;
60
114
  const list =
61
115
  typeof properties?.list_id === 'string' ? properties.list_id : '';
62
116
  const facet = typeof change?.value === 'string' ? change.value : '';
63
- if (facet && change?.action !== 'removed') {
64
- s.browsingCategory =
65
- bump(s.recentBrowse, `${list}/${facet}`) ?? s.browsingCategory;
117
+ const listing = s.browsingListing;
118
+ if (
119
+ facet &&
120
+ change?.action !== 'removed' &&
121
+ listing &&
122
+ pathPrefix(categoryKey(list)) === listing.key
123
+ ) {
124
+ const key = browsed(s, `${list}/${facet}`);
125
+ if (key) s.browsingCategory = key;
66
126
  }
67
127
  break;
68
128
  }
69
129
  case 'Product Added': {
70
130
  s.cartAdds += 1;
71
- bump(s.recentBrowse, s.browsingCategory);
131
+ browsed(s, s.browsingCategory);
72
132
  for (const p of products) {
73
133
  const id = p.product_id;
74
134
  if (typeof id !== 'string' || !id) continue;
@@ -131,15 +131,38 @@ export function resolveAudiences(
131
131
  sessionAudiences,
132
132
  signals = readSignals(),
133
133
  qualifiedFor = [],
134
+ browseCategories = null,
134
135
  ) {
135
136
  return resolveAudienceMemberships(
136
137
  precomputed,
137
138
  sessionAudiences,
138
139
  signals,
139
140
  qualifiedFor,
141
+ browseCategories,
140
142
  ).map((m) => m.id);
141
143
  }
142
144
 
145
+ /**
146
+ * The visit as the warehouse counts browsing: only the listings whose own `category` the catalogue
147
+ * files products under (the datafile's `browse_categories`), so a merchandising list (best sellers,
148
+ * new in, a sale) is never browsing interest. Each trail entry is judged on the listing it came
149
+ * from. Without the list (a datafile that predates it, or a serving store with no catalogue yet),
150
+ * the visit as recorded.
151
+ */
152
+ export function catalogueBrowse(signals, browseCategories) {
153
+ if (!Array.isArray(browseCategories)) return signals;
154
+ const held = new Set(browseCategories);
155
+ const categories = signals.recentBrowseCategories ?? [];
156
+ const recentBrowse = (signals.recentBrowse ?? []).filter((_, i) =>
157
+ held.has(categories[i]),
158
+ );
159
+ const listViews = Object.entries(signals.listViewsByCategory ?? {}).reduce(
160
+ (sum, [category, n]) => (held.has(category) ? sum + n : sum),
161
+ 0,
162
+ );
163
+ return { ...signals, recentBrowse, listViews };
164
+ }
165
+
143
166
  /**
144
167
  * The same resolution, with where each membership came from, for `context.audiences`.
145
168
  *
@@ -151,7 +174,9 @@ export function resolveAudienceMemberships(
151
174
  sessionAudiences,
152
175
  signals = readSignals(),
153
176
  qualifiedFor = [],
177
+ browseCategories = null,
154
178
  ) {
179
+ const visit = catalogueBrowse(signals, browseCategories);
155
180
  const active = new Set(precomputed);
156
181
  const bySlug = new Map();
157
182
  // Qualifying on history is not membership of a mixed audience, so this stays out of `active`.
@@ -176,7 +201,7 @@ export function resolveAudienceMemberships(
176
201
  if (!required.every(met)) continue;
177
202
  if (audience.mixed && !qualified.has(audience.slug)) continue;
178
203
  if (!audience.rules || audience.rules.length === 0) continue;
179
- if (sessionRulesHold(audience, signals)) {
204
+ if (sessionRulesHold(audience, visit)) {
180
205
  active.add(audience.slug);
181
206
  // Mixed is its own source: half of it was decided by a batch run, not by this visit.
182
207
  bySlug.set(audience.slug, audience.mixed ? 'mixed' : 'session');
@@ -95,6 +95,12 @@ function emptySignals() {
95
95
  // read as interest. The threshold that makes them meaningful lives server-side in `fatigue.ts`.
96
96
  impressions: {},
97
97
  browsingCategory: null,
98
+ // Beside each `recentBrowse` entry, the category of the listing it came from (two levels,
99
+ // `categoryKey` spelling, null for none); the listing being browsed; and listing views per
100
+ // category. What `browse_categories` is checked against when audiences are resolved.
101
+ recentBrowseCategories: [],
102
+ browsingListing: null,
103
+ listViewsByCategory: {},
98
104
  startedAt: 0,
99
105
  };
100
106
  }
package/src/session.js CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  } from './session/category.js';
15
15
  export { recordSessionEvent } from './session/record.js';
16
16
  export {
17
+ catalogueBrowse,
17
18
  evaluateSessionRule,
18
19
  measureField,
19
20
  resolveAudienceMemberships,
package/src/settings.js CHANGED
@@ -51,6 +51,19 @@ export function resolveApiHost(host) {
51
51
  : apiOrigin(host);
52
52
  }
53
53
 
54
+ /**
55
+ * Where events go. An `apiHost` the integrator set is a first-party proxy and carries events too;
56
+ * the settings' host applies only when none was set, and only over https (as the app SDKs do).
57
+ */
58
+ export function eventsHost(configured, fromSettings) {
59
+ const hasConfigured =
60
+ configured !== undefined && configured !== null && configured !== '';
61
+ if (hasConfigured) return resolveApiHost(configured);
62
+ return fromSettings?.startsWith('https://')
63
+ ? fromSettings
64
+ : resolveApiHost(undefined);
65
+ }
66
+
54
67
  /**
55
68
  * An API host as an origin. The settings give a bare host (`api.example.com`, the OpenAPI server
56
69
  * variable); an integrator may write either. Anything else is not a host.
@@ -143,6 +156,16 @@ function readAnonymousId(raw) {
143
156
  : null;
144
157
  }
145
158
 
159
+ /**
160
+ * Whether a 2xx body is a settings answer at all: an `hp` block saying on or off. Anything else (a
161
+ * captive portal's page, a proxy's error) is no answer, so the last good settings and what was
162
+ * carried are kept, as on Android, iOS and React Native.
163
+ */
164
+ export function isSettingsAnswer(body) {
165
+ const hp = isObject(body) ? body.hp : null;
166
+ return isObject(hp) && typeof hp.enabled === 'boolean';
167
+ }
168
+
146
169
  /**
147
170
  * The settings response as the SDK uses it, or DISABLED for anything that does not switch it on.
148
171
  * Unknown fields are ignored, so the API can add to the response without breaking a deployed SDK.
@@ -191,7 +214,8 @@ export async function fetchSettings(
191
214
  headers: { accept: 'application/json' },
192
215
  });
193
216
  if (!res.ok) return null;
194
- return readSettings(await res.json());
217
+ const body = await res.json();
218
+ return isSettingsAnswer(body) ? readSettings(body) : null;
195
219
  } catch {
196
220
  return null;
197
221
  }
@@ -1,4 +1,4 @@
1
1
  // Generated from the spec by scripts/build-types.mjs. DO NOT EDIT: run `pnpm run build:types`.
2
2
 
3
3
  /** The event specification this SDK emits, stamped on every event as context.spec.version. */
4
- export const SPEC_VERSION = '1.12';
4
+ export const SPEC_VERSION = '1.15';
package/src/transport.js CHANGED
@@ -95,6 +95,12 @@ export class Transport {
95
95
  this.inflight = null;
96
96
  // Ids of persisted entries this instance is already sending, so a replay never races them.
97
97
  this.sending = new Set();
98
+ /** Bumped by a refusal: batches cut before it are not sent or retried after it. */
99
+ this.epoch = 0;
100
+ /** Persisted ids of the batches cut since the last refusal: the withdrawal's among them. */
101
+ this.sinceRefusal = new Set();
102
+ /** End backoff waits early, so a refusal is not held behind a retry. */
103
+ this.wakers = new Set();
98
104
  this.replayAllowed = !this.o.deferReplay;
99
105
  this.draining = null;
100
106
 
@@ -123,12 +129,31 @@ export class Transport {
123
129
  this.replayAllowed = false;
124
130
  if (!this.storage) return;
125
131
  try {
126
- this.storage.removeItem(this.o.storageKey);
132
+ // A batch cut since the refusal is the withdrawal: it keeps its saved copy until delivered.
133
+ const kept = this.#readPersisted().filter((entry) =>
134
+ this.sinceRefusal.has(entry?.id),
135
+ );
136
+ if (kept.length)
137
+ this.storage.setItem(this.o.storageKey, JSON.stringify(kept));
138
+ else this.storage.removeItem(this.o.storageKey);
127
139
  } catch {
128
140
  /* nothing was readable, so nothing will be replayed either */
129
141
  }
130
142
  }
131
143
 
144
+ /**
145
+ * Drop every event queued in memory, and stop sending or retrying the batches already cut from
146
+ * it. For a refusal: what was collected under the permission is not sent after it is withdrawn.
147
+ * The batch already on the wire cannot be recalled.
148
+ */
149
+ discardQueued() {
150
+ this.queue = [];
151
+ this.epoch += 1;
152
+ this.sinceRefusal.clear();
153
+ this.#clearTimer();
154
+ for (const wake of this.wakers) wake();
155
+ }
156
+
132
157
  /**
133
158
  * Queued as its JSON form, made once here: an event JSON cannot serialise (a BigInt, a cycle, a
134
159
  * throwing toJSON) is dropped on its own, rather than throwing from every later flush and holding
@@ -239,12 +264,22 @@ export class Transport {
239
264
  ).map((batch) => {
240
265
  const id = this.#persist(batch);
241
266
  // Claimed now, not when its turn comes, so a replay started meanwhile does not send it too.
242
- if (id) this.sending.add(id);
267
+ if (id) {
268
+ this.sending.add(id);
269
+ this.sinceRefusal.add(id);
270
+ }
243
271
  return { batch, id };
244
272
  });
245
273
  this.queue = [];
274
+ const epoch = this.epoch;
246
275
  this.inflight = (async () => {
247
- for (const { batch, id } of batches) await this.#send(batch, 0, id);
276
+ for (const { batch, id } of batches) {
277
+ if (epoch !== this.epoch) {
278
+ this.#settle(id);
279
+ continue;
280
+ }
281
+ await this.#send(batch, 0, id, epoch);
282
+ }
248
283
  })().finally(() => {
249
284
  this.inflight = null;
250
285
  if (this.queue.length >= this.o.flushAt) this.flush();
@@ -297,7 +332,7 @@ export class Transport {
297
332
  * The body is rebuilt on every attempt: the ingest corrects clock skew against `sentAt`, so a
298
333
  * stale `sentAt` would shift every timestamp in the batch by the time spent waiting.
299
334
  */
300
- async #send(batch, attempt, persisted = null) {
335
+ async #send(batch, attempt, persisted = null, epoch = this.epoch) {
301
336
  if (persisted) this.sending.add(persisted);
302
337
  const body = this.#body(batch);
303
338
  let wait = 0;
@@ -316,9 +351,10 @@ export class Transport {
316
351
  this.#drainPersisted();
317
352
  return true;
318
353
  }
319
- // 4xx (except 429) means the payload is wrong; retrying cannot fix it and
354
+ // 4xx (except 408 and 429) means the payload is wrong; retrying cannot fix it and
320
355
  // would loop forever. Drop and surface.
321
- const retryable = res.status === 429 || res.status >= 500;
356
+ const retryable =
357
+ res.status === 408 || res.status === 429 || res.status >= 500;
322
358
  if (retryable) wait = retryAfterMs(res.headers?.get?.('retry-after'));
323
359
  if (!retryable) {
324
360
  this.#settle(persisted);
@@ -343,14 +379,28 @@ export class Transport {
343
379
  Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS) * (0.5 + Math.random()),
344
380
  Math.min(wait, MAX_BACKOFF_MS),
345
381
  );
346
- await new Promise((r) => setTimeout(r, delay));
347
- return this.#send(batch, attempt + 1, persisted);
382
+ if (epoch === this.epoch)
383
+ await new Promise((r) => {
384
+ const wake = () => {
385
+ clearTimeout(timer);
386
+ this.wakers.delete(wake);
387
+ r();
388
+ };
389
+ const timer = setTimeout(wake, delay);
390
+ this.wakers.add(wake);
391
+ });
392
+ if (epoch !== this.epoch) {
393
+ this.#settle(persisted);
394
+ return false;
395
+ }
396
+ return this.#send(batch, attempt + 1, persisted, epoch);
348
397
  }
349
398
  }
350
399
 
351
400
  #settle(id) {
352
401
  if (!id) return;
353
402
  this.sending.delete(id);
403
+ this.sinceRefusal.delete(id);
354
404
  this.#unpersist(id);
355
405
  }
356
406
 
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '3.19.0';
1
+ export const VERSION = '3.22.0';
2
2
  export const LIB = '@thg-agentic/hyper-personalisation-web';
@@ -45,7 +45,7 @@ export interface SettingsResponse {
45
45
  plan?: { track?: Record<string, { enabled?: boolean }> };
46
46
  hp?: {
47
47
  enabled: boolean;
48
- /** Null for a source scoped to several properties: the page passes `propertyId`. */
48
+ /** The source's default property, or null. A `propertyId` the page passes wins over it. */
49
49
  propertyId?: string | null;
50
50
  environment?: 'production' | 'staging' | 'development';
51
51
  /** Strict is the only mode. `purposes`: the ones this property asks for, and so reports. */