@thg-agentic/hyper-personalisation-web 3.24.1 → 3.26.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @thg-agentic/hyper-personalisation-web
2
2
 
3
3
  The browser SDK for THG Hyper-Personalisation, a customer data platform. It implements the event
4
- specification (spec 1.18, stamped on every event as `context.spec.version`) for any storefront: it
4
+ specification (spec 1.20, stamped on every event as `context.spec.version`) for any storefront: it
5
5
  sends events in the Segment wire format, applies consent strictly whichever CMP the site uses, reads
6
6
  the GA4 `dataLayer` the site already pushes to, and has no runtime dependencies.
7
7
 
@@ -63,7 +63,7 @@ collects, is a settings change and never a deploy:
63
63
  | `consent` | **Required.** The page's CMP: `{ provider: 'didomi' \| 'onetrust' \| 'cookie' \| 'forwarded' \| 'static', ...adapterOptions }`, or a consent adapter of your own. A preset may supply defaults for a provider. |
64
64
  | `identityCookies` | `{ anonymousId, sessionId }`: the names of your own first-party device and session cookies. `sessionId` is always read from here; without it the SDK keeps its own session (see [Session ids](#session-ids)). `anonymousId` is read only when the settings name no device cookie: see [Device ids](#device-ids). |
65
65
  | `autocapture` | `false`, or per tracker `{ promotions, productLists, recommendations, exposures }`, each `false` or its options. All on by default: they report only what the page tags. |
66
- | `identity` | The signed-in visitor, `{ userId, emailSha256 }`. `userId` goes on every event; the `identify` is sent once per session and user. |
66
+ | `identity` | The signed-in visitor, `{ userId, emailSha256, token }`. `userId` goes on every event; the `identify` is sent once per session and user, and again once a `token` arrives. `token` is the identity token your server signs for the user on this page view (3.26, see [Verified sign-ins](#verified-sign-ins)). |
67
67
  | `getCurrency`, `getShipToCountry`, `getLocale` | Read per event; see below. |
68
68
  | `preset` | A platform preset, passed in by the host. |
69
69
  | `pageType` | `context.page.type` of this page's page view. |
@@ -78,14 +78,23 @@ collects, is a settings change and never a deploy:
78
78
 
79
79
  What `bootstrap` does:
80
80
 
81
- - **Resolves to the client**, or `null` when the key is not enabled, the settings cannot be fetched
82
- on a first visit, or the options are invalid. Invalid options log one console warning listing
81
+ - **Resolves to the client**, or `null` when the key is not enabled or the options are invalid. Invalid options log one console warning listing
83
82
  every problem. It never rejects, and a second call on the same page returns the first client.
84
83
  - **Settings.** The last good answer is cached in `localStorage`, only once analytics is granted,
85
84
  and forgotten on a refusal. A page with a cache starts from it at once and asks the API again for
86
85
  the next page; a first visit waits for the API (which answers `Cache-Control: max-age=10`
87
- and an `ETag`, so the browser revalidates for a `304`). A key the API switches off clears the cache, so the next page is
88
- off too.
86
+ and an `ETag`, so the browser revalidates for a `304`), for at most 3 seconds (3.24.2). A key the
87
+ API switches off clears the cache, so the next page is off too. Onsite forms are the exception: they start from the fresh answer when it comes within a
88
+ second, else from the cache, so a form set live, changed or stopped shows on the next page.
89
+ - **An API it cannot reach never turns collection off** (3.25). A first visit whose settings fail
90
+ (a 5xx, 408 or 429, a timeout, an answer that is not settings; a 4xx is the key refused, and the
91
+ page is off) starts on built-in defaults: collection
92
+ only, with no property (the ingest stamps the key's, so a key scoped to several properties needs
93
+ the page's `propertyId`), campaign parameters only in URLs, the device id from
94
+ `identityCookies.anonymousId` else the SDK's own cookie, no sampling, no tracking plan and no
95
+ onsite forms. Consent is still the CMP's, strictly. They are never cached, so the next page asks
96
+ the API again, and what the ingest cannot take waits in the retry buffer (see
97
+ [Choosing a delivery path](#choosing-a-delivery-path)).
89
98
  - **Consent**, identity, one page view, the dataLayer bridge and a once-per-session `identify`, as
90
99
  configured. A page restored from the back/forward cache runs no script again, so `bootstrap`
91
100
  reads consent again, then sends its `back_forward` page view on `pageshow` and reports its lists
@@ -96,8 +105,7 @@ What `bootstrap` does:
96
105
  messageId and time it was made at, and are sent in order once it has. The page view is stamped
97
106
  when the page was navigated to, so none of them precedes it (3.18). A page that navigates
98
107
  first (a sign-in that redirects at once) carries them in sessionStorage, and the next page's
99
- `bootstrap` sends them once, before its own page view (since 3.9). So does a page whose settings
100
- request never answered: the API down, or the request cancelled by the navigation (3.9.1). The carry is a device write,
108
+ `bootstrap` sends them once, before its own page view (since 3.9). The carry is a device write,
101
109
  so it is made only once this device has granted analytics, or unanswered with `holdAcrossPages`;
102
110
  the next page's consent decides whether they are sent, and a refusal drops them. Once started,
103
111
  the SDK hands its queue to `sendBeacon` at `pagehide` itself.
@@ -212,6 +220,7 @@ Since 3.19 (spec 1.12), a search box's suggestions are their own event:
212
220
  | `queryParamAllowlist` | none | Keep only these query params (plus campaign params) in `context.page`, and in a clicked link's `href`, which leaves another site's link as origin and path only (3.22.2). Patterns are anchored and case-insensitive. |
213
221
  | `queryParamBlocklist` | `[]` | Strip these params. Ignored when an allowlist is given. |
214
222
  | `sensitiveParamAllowlist` | `[]` | Whatever else is configured, a param is always dropped from every reported URL when its name contains `email`, `token`, `password`, `secret`, `auth`, `session`, `phone`, `address`, `card`, `customerid` and the like (see `SENSITIVE_PARAM_FRAGMENTS` in `src/context.js`), or when its value is an email address or a card-length number — campaign params and the referrer included. This exempts names from the first rule, for a false positive such as an `author` filter; nothing exempts a value. |
223
+ | `identityToken` | none | The identity token your server signed for the user `getUserId` names, read once: on each `identify` as `context.identity_token` and with each onsite decision, until `reset()`, a sign-out or another user's `identify` (3.26, see [Verified sign-ins](#verified-sign-ins)). |
215
224
  | `getAnonymousId`, `getUserId`, `getSessionId` | none | Identity accessors. Without `getSessionId` the SDK keeps its own session (see [Session ids](#session-ids)); a missing or empty device id gives way to the SDK's own (see [Device ids](#device-ids)). |
216
225
  | `getExperiments`, `getAudiences`, `getDecisions`, `getRecommendations`, `getExtensions` | none | Read per event into the matching `context` field. Without `getDecisions`, `context.decisions` is what `recordExposure` last recorded per slot. |
217
226
  | `pageType` | `null` | `context.page.type` before the first `page()` call. |
@@ -500,8 +509,8 @@ separate chunk and starts it; otherwise it is never fetched. A page on `createAn
500
509
  itself: `import { startOnsite } from '@thg-agentic/hyper-personalisation-web/onsite'` then
501
510
  `startOnsite(client, options)`. On the CDN, `hp.min.js` loads `onsite.min.js` (and that loads
502
511
  `onsite-render.min.js` once a trigger fires, `onsite-wheel.min.js` for a wheel,
503
- `onsite-answers.min.js` for a date or number question) from beside itself; a self-hosted copy keeps
504
- all four beside `hp.min.js`. The runtime watches triggers, caps and consent and asks for the
512
+ `onsite-answers.min.js` for a date or number question, `onsite-audiences.min.js` for a form limited
513
+ to audiences) from beside itself; a self-hosted copy keeps all five beside `hp.min.js`. The runtime watches triggers, caps and consent and asks for the
505
514
  decision; the renderer and its stylesheet are fetched only when a trigger fires, alongside the
506
515
  decision request, so a page whose forms never fire never downloads them. If the renderer cannot
507
516
  load, the form is not shown (`hp.onsite.debug()` says `the renderer did not load`) and the page goes
@@ -550,20 +559,40 @@ on desktop arms after `delay_ms` (5 s) and once the pointer has been below the t
550
559
  viewport, and fires when it leaves through the top edge, moving up. On mobile it is a fast upward
551
560
  flick (120px at 0.5 px/ms) after 30% of the page, or a return to the tab after 10 s away. Pages
552
561
  (include/exclude globs on the path, `*` matching anything, in any case), devices (`mobile` is a coarse pointer
553
- or under 768px), dates and caps are checked first; then `POST /v1/decisions` is asked for that one
562
+ or under 768px), the campaign the visit arrived from (`arrived_from`), dates and caps are checked
563
+ first; then `POST /v1/decisions` is asked for that one
554
564
  slot. A held-out visitor, or no decision, sees nothing. One overlay per page view: of the forms that
555
565
  fire within 50 ms, the highest `priority`; once one has shown (or the visitor was held out of it),
556
566
  closing it lets no other form in on that page view. A `manual` form, which the site opens on the
557
567
  shopper's click (its own sign-up button), is the shopper asking: it opens whatever its caps, the gap
558
568
  and the page view's allowance say, as Klaviyo's custom-triggered forms do, though never over a
559
- modal on screen (a popup, or a teaser opened from its tab); its pages, devices, dates and audiences
560
- still hold. Asked for twice while it opens, it opens once. With the property's `overlay_gap_minutes`, no popup, sheet or full-screen form
569
+ modal on screen (a popup, or a teaser opened from its tab); its pages, devices, campaign, dates and
570
+ audiences still hold. Asked for twice while it opens, it opens once. With the property's `overlay_gap_minutes`, no popup, sheet or full-screen form
561
571
  shows until that long after the last one this session (1440 is once per session); bars, teasers and
562
572
  inline forms are never held back. A form with `audiences` needs personalisation consent.
563
573
 
574
+ **Arrived from.** A form with `arrived_from` shows only to a visit that landed from a matching
575
+ campaign: `[{ field, match, values }]`, `field` one of `source`, `medium`, `campaign`, `content`,
576
+ `term` (the link's `utm_*` tags), `match` `is` or `contains`, compared trimmed and in any case.
577
+ Every rule must hold; any of a rule's values meets it. The campaign is the landing page's, as GA4's
578
+ session source is: the client keeps the first page of the visit with `utm_*` tags in one store,
579
+ `thg_hp_visit_campaign:<property>` (localStorage, so every tab of the visit reads it; the tags,
580
+ when the visit was last active and its session id once kept on the device, never a device or
581
+ person id, the same strictly necessary basis as the onsite counters). Later pages without tags, or
582
+ with others, keep it until the visit ends as the SDK's session does: 30 minutes without a page or
583
+ an event, or a new session id. Each tag is read up to 100 characters. The tags are read when the
584
+ client is created, so a later `replaceState` cannot lose them. A sign-out or a withdrawn consent
585
+ forgets it; a first refusal does not, as the forms judge it without consent. The session id is
586
+ written only while analytics is granted, and without localStorage the campaign holds for the page. It needs no consent; a visit with no campaign meets no rule
587
+ (`not eligible: not from its campaign`), and an `arrived_from` this SDK cannot read never matches.
588
+ The decision request for such a form carries it as `visit_campaign` (never logged), and the public API answers no
589
+ form unless the rules hold, so an SDK older than 3.26 never shows one. A preview link reads its own
590
+ URL's tags: the console's Preview on site link adds them. Click ids
591
+ (`fbclid`, `gclid`) are not a campaign.
592
+
564
593
  | Option | |
565
594
  |---|---|
566
- | `audiences()` | The visitor's session audiences, when the page evaluates them: a form targeting none of them is skipped. Without it the server decides. |
595
+ | `audiences()` | The visitor's session audiences, when the page evaluates them: a form targeting none of them is skipped. Without it HP's server decides, from the visitor's stored audiences and the in-visit audiences whose rules hold in this visit (the runtime evaluates them from `GET /v1/audiences/datafile` and sends them with the decision). |
567
596
  | `subscribed()` | True for a visitor the page knows is subscribed, for forms with `exclude_subscribed`. |
568
597
  | `servingHost`, `gatewayHost` | Where decisions and claims go (default: the host the SDK was pointed at) and capture goes (default: where events go). |
569
598
  | `debug` | Log why each form did or did not show. Also on with `?hp_debug=onsite`. |
@@ -634,11 +663,26 @@ The sign-up itself is sent whatever the analytics answer: it is the visitor's ma
634
663
  close and submitted, plus one subscribed flag and the last claimed code (localStorage), and a
635
664
  kept teaser tab (sessionStorage).
636
665
 
666
+ **Prizes that cannot be claimed.** A form with prizes claims as soon as the sign-up is taken. If
667
+ the claim fails (the offers API unreachable, an error, or no answer in 8 seconds), the sign-up
668
+ still counts: a spin offers one retry ("You're signed up, but we couldn't load your prize. Please
669
+ try again."), then thanks the shopper and says the prize could not be loaded, as does a `code`
670
+ block. The shopper stays subscribed and the form is done with them (`already submitted`), so they
671
+ are never asked for their address twice. No code was drawn, so the destinations get the sign-up
672
+ without one (ADR 0064, decision 5).
673
+
674
+ **Testing a live form again.** A browser that has seen a form keeps to its caps. To see it as a
675
+ new visitor, open a private window, or delete `thg_hp_onsite:<property>` from both localStorage
676
+ (shows ever, last close, submitted, subscribed, the last code) and sessionStorage (shows this
677
+ session, the gap between popups, a teaser's tab, the A/B version), then reload;
678
+ `thg_hp_settings:<writeKey>` in localStorage is the cached settings. `?hp_debug=onsite` logs why
679
+ each form did or did not show, and a preview link (`?hp_preview=`) counts nothing.
680
+
637
681
  **Budgets**, enforced by `tests/onsite-chunks.test.mjs`, each about 5% over its chunk:
638
682
  `onsite.min.js` under 5.875 KiB gzipped (about 5.6 KiB), `onsite-render.min.js` under 14 KiB (about
639
683
  13.7 KiB), so a page that shows a form loads about 19 KiB of onsite code and one whose forms never
640
684
  fire about 5.6 KiB; `onsite-wheel.min.js` under 8 KiB (about 1.3 KiB), `onsite-answers.min.js` under
641
- 2 KiB (about 1.5 KiB). The core carries only the loader.
685
+ 2 KiB (about 1.5 KiB), `onsite-audiences.min.js` under 1.25 KiB (about 1.2 KiB). The core carries only the loader.
642
686
 
643
687
  Try it locally: `node tools/local/serve.mjs`, then
644
688
  `http://localhost:8099/onsite.html?hp_debug=onsite` (offline mocks by default, `?mode=live` for
@@ -660,12 +704,13 @@ With a `consent` adapter the SDK is strict:
660
704
  is never a grant, however long it takes.
661
705
  - **Page closed before an answer:** nothing is sent. Switching to another tab is not closing the
662
706
  page: held events wait for the visitor to come back.
663
- - **A navigation before an answer** (a sign-in that redirects) loses what the page held, unless
664
- you opt in with `holdAcrossPages: true` (since 3.5). Then held events are also kept in
665
- sessionStorage under `thg_hp_held:<property>` and sent when the next page's answer is a grant,
666
- stamped with it. A refusal deletes them, and so does a tab left unanswered for 30 minutes. This
667
- writes event data to the device before the visitor answers, which strict mode otherwise never
668
- does, so turn it on only where your consent policy allows that.
707
+ - **A navigation before an answer** (a visitor who answers the banner on their second page, a
708
+ sign-in that redirects) loses what the page held, landing page included, unless you opt in with
709
+ `holdAcrossPages: true` (since 3.5). Then held events are also kept in sessionStorage under
710
+ `thg_hp_held:<writeKey>` and sent when a later page's answer is a grant, stamped with it. A
711
+ refusal deletes them, and so does a tab left unanswered for 30 minutes. This writes event data
712
+ to the device before the visitor answers, which strict mode otherwise never does, so turn it on
713
+ only where your consent policy allows that.
669
714
  - **The retry buffer:** a batch is kept in localStorage only while analytics is granted, and one
670
715
  kept on an earlier page is replayed only once this page's answer is a grant. A refusal clears it.
671
716
  - **Consent changes:** each change is recorded as a `Consent Updated` event. A decision that
@@ -880,6 +925,29 @@ base64). `bootstrap`'s `identity.emailSha256` and `identify`'s `email_sha256` (s
880
925
  either, and `normaliseSha256(value)` converts either to the lowercase hex the spec wants. It re-encodes the same digest and never
881
926
  re-hashes, so pass the hash, never the email.
882
927
 
928
+ ## Verified sign-ins
929
+
930
+ A publishable key is public, so a `userId` the browser sends is only a claim: the identity graph
931
+ records it and never joins the device to that customer unless the integrator vouches for it (ADR
932
+ 0067). Your server signs an identity token for each signed-in page view, a JWT `ES256` (header
933
+ `typ: "JWT"`) over `{ sub: <userId>, aud: "hyper-personalisation", exp }` (at most 24 hours) with a
934
+ P-256 key whose public half is registered on the source, and the page hands it over:
935
+
936
+ ```js
937
+ bootstrap(writeKey, { consent, identity: { userId: customer.id, token } });
938
+ // or, calling identify yourself:
939
+ analytics.identify({ userId: customer.id }, { identity_token: token });
940
+ ```
941
+
942
+ The token goes on the `identify` as `context.identity_token`, and with each onsite decision, where
943
+ it is what lets a promo reminder's code be shown. The SDK keeps one token, for the user it was
944
+ signed for: `reset()`, a `Signed Out` or another user's `identify` drops it, and the same user's
945
+ `identify` without one keeps it; a single-page app passes a fresh one on `identify` before the
946
+ last expires. A session identifies again once, when a token first appears for
947
+ the user, and never because a page (one served from a shared cache) has none. The ingest checks it,
948
+ stamps `context.identity_verified` and never stores it. Steps, key generation and rotation:
949
+ [Verify signed-in shoppers](../../apps/docs/src/content/docs/guides/verify-signed-in-shoppers.mdx).
950
+
883
951
  ## Handing off to server producers
884
952
 
885
953
  Server-side producers (for example an order service using the Java SDK) read two first-party cookies:
@@ -907,8 +975,14 @@ delivery differs.
907
975
  - Dependency-free.
908
976
  - Batched, with a retry buffer in localStorage: each batch is stored before it is sent and
909
977
  removed when the ingest accepts it (or rejects it for good), so an outage costs a delay, not the
910
- events. The buffer is per tenant and capped at 20 batches and 1 MB, oldest dropped first.
911
- - On page unload, sends by `sendBeacon` in chunks that fit.
978
+ events. A batch is retried with exponential backoff on its page, then on every later page of the
979
+ site for 24 hours, with each event's `messageId`, so one that had landed is removed by the
980
+ warehouse's deduplication on `(org, source, messageId)`. The buffer is per write key (3.25; per
981
+ property before; what 3.24 kept per property is moved over on the first page) and capped at 20
982
+ batches and 1 MB, oldest dropped first.
983
+ - On page unload, sends by `sendBeacon` in chunks that fit. A beacon's answer is never seen, so
984
+ while the ingest is failing (a send failed on this page, or the settings could not be fetched)
985
+ each beaconed chunk is also kept in the retry buffer for the next page (3.25).
912
986
  - Never throws into the page. An event JSON cannot serialise (a BigInt, a circular object, a
913
987
  throwing `toJSON`) is dropped on its own, logged under `debug`, and the queue behind it carries
914
988
  on. A `Date` in properties is sent as its ISO string.
@@ -1002,13 +1076,13 @@ SDK per page.
1002
1076
  | Kind | Default name | Where | Scope |
1003
1077
  |---|---|---|---|
1004
1078
  | `consent` | `thg_hp_consent` | cookie | the consent decision, for your servers and `forwarded` pages |
1005
- | `retry` | `thg_hp_retry:<property>` | localStorage | batches waiting for the ingest; only under a grant |
1079
+ | `retry` | `thg_hp_retry:<writeKey>` | localStorage | batches waiting for the ingest; only under a grant |
1006
1080
  | `sessionSignals` | `thg_hp_session_signals:<property>:<session>` | localStorage (sessionStorage until a session is bound) | in-session audience counters |
1007
1081
  | `identified` | `thg_hp_identified:<property>` | sessionStorage | the identify already sent this session |
1008
1082
  | `previousPage` | `thg_hp_prev_page:<property>` | sessionStorage | the previous page view's messageId; only under a grant |
1009
1083
  | `decisions` | `thg_hp_decisions:<property>` | sessionStorage | the decisions in force, latest per slot; only under a grant |
1010
1084
  | `settings` | `thg_hp_settings:<writeKey>` | localStorage | the last good settings; only under a grant |
1011
- | `held` | `thg_hp_held:<property>` | sessionStorage | events held for an unanswered CMP; only with `holdAcrossPages` |
1085
+ | `held` | `thg_hp_held:<writeKey>` | sessionStorage | events held for an unanswered CMP; only with `holdAcrossPages` |
1012
1086
  | `carried` | `thg_hp_carried:<writeKey>` | sessionStorage | `hp` calls made before the SDK started, on a page that navigated first (3.9); only under a grant, or with `holdAcrossPages` |
1013
1087
  | `recommendationContext` | `thg_hp_recommendation_context` | localStorage | The visit's wire form, for `hp.recommendationContext()` on the next page before the SDK starts there; 30 minutes from the last event, only while personalisation is granted (3.11) |
1014
1088
  | `anonymousId` | `thg_hp_anonymous_id` | cookie, one per browser | Hyper-Personalisation's device id, for a source set to `hp_cookie`; only under a grant (see [Device ids](#device-ids)) |
@@ -1016,6 +1090,7 @@ SDK per page.
1016
1090
  | `preview` | `thg_hp_preview` | session cookie | a trader's preview token from `?hp_preview=` (3.15, see [Preview](#preview)) |
1017
1091
  | `debug` | `thg_hp_debug` | sessionStorage | a developer's debug token from `?hp_debug=` (3.18), checked against the settings on every page |
1018
1092
  | `onsite` | `thg_hp_onsite:<property>` | localStorage, sessionStorage | onsite forms' opaque counters per slot and a subscribed flag; never contact data (see [Onsite forms](#onsite-forms)) |
1093
+ | `visitCampaign` | `thg_hp_visit_campaign:<property>` | localStorage | the campaign tags the visit landed with, when it was last active and its session id once kept; ended with the session (see [Onsite forms](#onsite-forms)) |
1019
1094
  | — | `thg_hp_conformance_page_type` | sessionStorage | the previous page's type, only while the development-only conformance checker runs |
1020
1095
 
1021
1096
  Keys are scoped by property because one origin can serve several tenants. The names the SDK used