@xenia-martech/sdk-js 1.2.0-dev.3 → 1.2.0-dev.5

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 (78) hide show
  1. package/README.md +106 -2
  2. package/dist/{actions-Bhji05Pe.d.cts → actions-C18Kv-4w.d.cts} +35 -0
  3. package/dist/{actions-Bhji05Pe.d.ts → actions-C18Kv-4w.d.ts} +35 -0
  4. package/dist/{api-ByoECRhR.d.ts → api-0IycTYJR.d.cts} +1 -1
  5. package/dist/{api-BqSvWgBZ.d.cts → api-COXWLL5L.d.ts} +1 -1
  6. package/dist/{chunk-TRRMMPBH.cjs → chunk-2DR5AVPX.cjs} +10 -45
  7. package/dist/chunk-2DR5AVPX.cjs.map +1 -0
  8. package/dist/chunk-BQ5PJCZ7.js +179 -0
  9. package/dist/chunk-BQ5PJCZ7.js.map +1 -0
  10. package/dist/{chunk-INM2SPH4.js → chunk-GWRDZIVA.js} +4 -36
  11. package/dist/chunk-GWRDZIVA.js.map +1 -0
  12. package/dist/{chunk-TGG5Y4QD.js → chunk-LW5AEJ6K.js} +5 -179
  13. package/dist/chunk-LW5AEJ6K.js.map +1 -0
  14. package/dist/chunk-N5M4PI7C.cjs +192 -0
  15. package/dist/chunk-N5M4PI7C.cjs.map +1 -0
  16. package/dist/chunk-OMMIRSPK.js +336 -0
  17. package/dist/chunk-OMMIRSPK.js.map +1 -0
  18. package/dist/chunk-QEYONKWY.js +42 -0
  19. package/dist/chunk-QEYONKWY.js.map +1 -0
  20. package/dist/{chunk-YZG6JR4E.cjs → chunk-RWE2OCQL.cjs} +5 -190
  21. package/dist/chunk-RWE2OCQL.cjs.map +1 -0
  22. package/dist/chunk-WHGK55JP.cjs +46 -0
  23. package/dist/chunk-WHGK55JP.cjs.map +1 -0
  24. package/dist/chunk-YLMMTKQL.cjs +358 -0
  25. package/dist/chunk-YLMMTKQL.cjs.map +1 -0
  26. package/dist/client/index.cjs +304 -26
  27. package/dist/client/index.cjs.map +1 -1
  28. package/dist/client/index.d.cts +15 -4
  29. package/dist/client/index.d.ts +15 -4
  30. package/dist/client/index.js +304 -26
  31. package/dist/client/index.js.map +1 -1
  32. package/dist/client/react/index.cjs +714 -26
  33. package/dist/client/react/index.cjs.map +1 -1
  34. package/dist/client/react/index.d.cts +32 -3
  35. package/dist/client/react/index.d.ts +32 -3
  36. package/dist/client/react/index.js +715 -29
  37. package/dist/client/react/index.js.map +1 -1
  38. package/dist/cms/contentful.d.cts +2 -1
  39. package/dist/cms/contentful.d.ts +2 -1
  40. package/dist/consent-Cemn91j3.d.ts +248 -0
  41. package/dist/consent-DgWdjsSy.d.cts +248 -0
  42. package/dist/{cookie-names-Bxd-VNyJ.d.ts → cookie-names-BvUaW3uo.d.ts} +1 -1
  43. package/dist/{cookie-names-RXVfGYPE.d.cts → cookie-names-CnL65Oly.d.cts} +1 -1
  44. package/dist/edge/index.cjs +280 -0
  45. package/dist/edge/index.cjs.map +1 -0
  46. package/dist/edge/index.d.cts +61 -0
  47. package/dist/edge/index.d.ts +61 -0
  48. package/dist/edge/index.js +191 -0
  49. package/dist/edge/index.js.map +1 -0
  50. package/dist/index.cjs +41 -40
  51. package/dist/index.d.cts +5 -4
  52. package/dist/index.d.ts +5 -4
  53. package/dist/index.js +2 -1
  54. package/dist/next/index.cjs +331 -15
  55. package/dist/next/index.cjs.map +1 -1
  56. package/dist/next/index.d.cts +180 -6
  57. package/dist/next/index.d.ts +180 -6
  58. package/dist/next/index.js +320 -9
  59. package/dist/next/index.js.map +1 -1
  60. package/dist/personalize-content-c2DDAV6g.d.ts +140 -0
  61. package/dist/personalize-content-ghZPKlf1.d.cts +140 -0
  62. package/dist/server/index.cjs +34 -32
  63. package/dist/server/index.cjs.map +1 -1
  64. package/dist/server/index.d.cts +6 -5
  65. package/dist/server/index.d.ts +6 -5
  66. package/dist/server/index.js +5 -3
  67. package/dist/server/index.js.map +1 -1
  68. package/dist/{personalize-content-Bf6YZdkZ.d.cts → types-DgVHS4Qw.d.cts} +7 -138
  69. package/dist/{personalize-content-Bf6YZdkZ.d.ts → types-DgVHS4Qw.d.ts} +7 -138
  70. package/dist/xenia-sdk.iife.js +3 -3
  71. package/dist/xenia-sdk.iife.js.map +1 -1
  72. package/dist/{xenia-server-MlI3Khya.d.cts → xenia-server-C3O-cWQT.d.cts} +3 -2
  73. package/dist/{xenia-server-CcjQenru.d.ts → xenia-server-DV8Sjmoo.d.ts} +3 -2
  74. package/package.json +14 -1
  75. package/dist/chunk-INM2SPH4.js.map +0 -1
  76. package/dist/chunk-TGG5Y4QD.js.map +0 -1
  77. package/dist/chunk-TRRMMPBH.cjs.map +0 -1
  78. package/dist/chunk-YZG6JR4E.cjs.map +0 -1
package/README.md CHANGED
@@ -81,6 +81,7 @@ Ships **client-native** (`/client`), **React** (`/client/react`), **server-nativ
81
81
  - [Webhook actions](#webhook-actions)
82
82
  - [Theming with CSS variables](#theming-with-css-variables)
83
83
  - [Personalization](#personalization)
84
+ - [Edge personalization](#edge-personalization)
84
85
  - [Analytics & conversions](#analytics--conversions)
85
86
  - [Recipes — end-to-end patterns](#recipes--end-to-end-patterns)
86
87
  - [API reference](#api-reference)
@@ -138,7 +139,8 @@ Building a plain HTML site (no npm, no bundler)? Skip the install and pull the p
138
139
  | `@xenia-martech/sdk-js/client` | browser, **framework-agnostic** | `createXeniaClient()` — identity, auto-tracking, action rendering. No React. Works from Vue, Angular, Svelte, or plain JS. |
139
140
  | `@xenia-martech/sdk-js/client/react` | browser + React | `<XeniaProvider>` + hooks (`useTrack`, `usePersonalization`, `useWebhook`, …), built on top of `/client`. |
140
141
  | `@xenia-martech/sdk-js/server` | Node / SSR / RSC | Server-side events, webhooks, personalization, and token minting. Stateless — identity passed explicitly. |
141
- | `@xenia-martech/sdk-js/next` | Next.js 15+ (App Router) | `xeniaProxy()` for `proxy.ts`/`middleware.ts` and `xeniaServerFromNext()` — the visitor cookie and URL context wired for you. Needs `next` (optional peer). |
142
+ | `@xenia-martech/sdk-js/edge` | any (Node, edge functions, workers) | [Edge personalization](#edge-personalization): the manifest/decide client, page matching, signed variant URLs, webhook verification, request facts, page trail and consent for a storefront's proxy. No Next.js, React or DOM dependency. |
143
+ | `@xenia-martech/sdk-js/next` | Next.js 15+ (App Router) | `xeniaProxy()` for `proxy.ts`/`middleware.ts` and `xeniaServerFromNext()` — the visitor cookie and URL context wired for you. `xeniaEdgeProxy()`, `createManifestRoute()` and `createRevalidateRoute()` for [edge personalization](#nextjs-one-call). Needs `next` (optional peer). |
142
144
  | `@xenia-martech/sdk-js/cms/contentful` | any | `contentfulAdapter()` for [CMS page personalization](#cms-page-personalization). |
143
145
  | `@xenia-martech/sdk-js/iife` | `<script>` tag | Prebuilt `window.Xenia` for sites without a bundler. |
144
146
 
@@ -434,7 +436,7 @@ Passed to `XeniaProvider`, `createXeniaClient`, `createXeniaServer`, or `Xenia.i
434
436
  | `token` | `string` | — | SDK Bearer token, sent as `Authorization: Bearer <token>` on every request. Required for `resolve()`/`resolveBatch()`; needed for events/webhooks only when a trigger node has its own auth token. |
435
437
  | `getToken` | `() => Promise<string> \| string` | — | Async token provider; cached until shortly before expiry. Overrides `token`. |
436
438
  | `visitorId` | `string` | — | Seeds (browser) or supplies (server) the SDK-owned visitor anchor. Server-side, this is how you bind the client to a request — see [Recipes](#recipes--end-to-end-patterns). |
437
- | `visitorCookie` | `boolean \| VisitorCookieOptions` | `true` | **Browser only.** Persists the visitor id to a first-party `<prefix>_vid` cookie (`xenia_vid` by default) so a same-origin server can read it via `getVisitorId()` and attribute a server-side event to a browser-entered journey. `false` disables it; an object tunes `domain`/`sameSite`/`secure`/`maxAgeDays` (defaults: `Lax`, 365 days). |
439
+ | `visitorCookie` | `boolean \| VisitorCookieOptions` | `true` | **Browser only.** Persists the visitor id to a first-party `<prefix>_vid` cookie (`xenia_vid` by default) so a same-origin server can read it via `getVisitorId()` and attribute a server-side event to a browser-entered journey. `false` disables it; an object tunes `domain`/`sameSite`/`secure`/`maxAgeDays` (defaults: `Lax`, 365 days). `authoritative: true` makes the cookie win over the id stored in the browser, so when a server changes the visitor (a profile switch, a QA persona) the browser follows instead of writing its old id back. |
438
440
  | `prefix` | `string` | `"xenia"` | Storage keys, the `data-<prefix>` attribute name, **and the visitor/consent cookie names** (`<prefix>_vid`, `<prefix>_consent`). If you change it, pass the same prefix to the server helpers — `getVisitorId(header, prefix)` — or the server looks for a cookie the browser never wrote. |
439
441
  | `timeout` | `number` | `8000` | Per-request timeout (ms). |
440
442
  | `retry` | `{ attempts?, baseDelayMs? }` | `{2, 300}` | Retry policy; honors `Retry-After`. |
@@ -453,6 +455,11 @@ Passed to `XeniaProvider`, `createXeniaClient`, `createXeniaServer`, or `Xenia.i
453
455
  | `autoApplyActions` | `boolean` | `true` | Apply `xeniaActions` from webhook responses automatically. |
454
456
  | `persistResponses` | `boolean` | `true` | Persist saved webhook responses to `localStorage`. |
455
457
  | `respectDnt` | `boolean` | `true` | Honor `navigator.doNotTrack`/`globalPrivacyControl` by no-op'ing analytics-only `track()` calls entirely (not even queued). Journey triggers always fire regardless. |
458
+ | `pageViews` | `boolean` | `false` | **Browser.** Sends `pageView` `{ title, searchTerm?, category? }` on the first page and every client-side navigation (`history.pushState`/`replaceState`, Back/Forward), once the new page's title is in (3 s at most). `<meta name="xenia-title">` replaces the title, `<meta name="xenia-category">` adds a category; `searchTerm` comes from `utm_term` or `q`. |
459
+ | `pageLeave` | `boolean` | `false` | **Browser.** Sends `page_leave` `{ path, durationMs }` — the page's visible time — when it is left or first hidden. |
460
+ | `trail` | `boolean` | `false` | **Browser.** Keeps the `<prefix>_trail` cookie [edge personalization](#edge-personalization) sends with `/decide`: client-side navigations, and the time on a page as the visitor leaves it. Only with analytics consent and no DNT/GPC. |
461
+ | `contentClicks` | `boolean \| { root }` | `false` | **Browser.** Sends `content_click` `{ label, category? }` for a click on an image (or a card holding one image) inside `root` (default `main`): the image's alt text, and the first path segment of a same-site link around it. |
462
+ | `navClicks` | `boolean \| { root }` | `false` | **Browser.** Sends `nav_click` `{ category, label }` for a same-site link inside `root` (default `header`). Content and nav clicks also bump the `<prefix>_clicks` cookie an edge proxy keys its decision cache on, so the next page is decided again. |
456
463
  | `messageTemplate` | `(ctx: MessageRenderContext) => string` | — | Replace the built-in card markup (as an HTML string) for every `banner`/`popup`/`modal`/`inline` action that doesn't supply its own `html`. Applies system-wide. |
457
464
  | `messageComponent` | `(container: HTMLElement, ctx: MessageMountContext) => void \| (() => void)` | — | Framework-native alternative to `messageTemplate` — mounts a real component (React, Vue, …) into `container` instead of an HTML string. Wins over `messageTemplate` when both are set. See [Framework-native components](#framework-native-components). |
458
465
  | `messageStyles` | `string` | — | Extra CSS appended to the message overlay's (shadow-scoped) stylesheet. |
@@ -802,6 +809,103 @@ The Webhook Response node's body carries a `xeniaContentSwaps` array — `{ iden
802
809
 
803
810
  ---
804
811
 
812
+ ## Edge personalization
813
+
814
+ Personalize server-rendered pages without Xenia slowing them down. The storefront's proxy knows **which pages** any journey targets (the manifest) and calls Xenia's `POST /api/personalization/edge/decide` **only for those**, within a hard time budget, caching each visitor's decision for the session. Every failure — a timeout, an error, no manifest yet — serves base content; Xenia can't slow down or break a page. `/edge` uses only `fetch`, `AbortSignal`, Web Crypto and `Map`, so it runs in Node, edge functions and workers alike. It needs a **server**-type SDK token (never expose it to the browser).
815
+
816
+ ```ts
817
+ import {
818
+ createXeniaEdge, deviceOf, edgeConsent, encodeVariant, isBot, isPageLoad, landingContext,
819
+ localTime, readTrail, swapGroups, trailCookieValue, viewerGeo, withView,
820
+ } from "@xenia-martech/sdk-js/edge";
821
+
822
+ const edge = createXeniaEdge({ baseUrl, token: process.env.XENIA_SDK_TOKEN!, budgetMs: 150 });
823
+
824
+ // in your proxy, per page request:
825
+ if (isBot(req.headers.get("user-agent"))) return next();
826
+ const consent = edgeConsent(req.headers); // see the table below
827
+ const keys = await edge.match(host, path); // [] → not targeted, don't call Xenia
828
+ const { geo } = viewerGeo(req, { trust: ["x-vercel-ip-country"] }); // only the headers YOUR host sets
829
+ const trail = withView(readTrail(cookies.get("xenia_trail")), path);
830
+ const outcome = await edge.decide(keys ?? [], {
831
+ host, path, visitorId,
832
+ facts: { ...localTime(geo, new Date()), device: deviceOf(req.headers), path, ...(geo ? { geo } : {}) },
833
+ ...(consent.analytics ? { trail } : {}),
834
+ });
835
+ const groups = swapGroups(outcome.decisions);
836
+ if (groups.length) rewrite(`/xv/${await encodeVariant(groups, secret, extra)}${path}`); // cached per variant
837
+ ```
838
+
839
+ ### Next.js: one call
840
+
841
+ In Next.js, `xeniaEdgeProxy()` from `/next` runs that whole flow — bots and non-GET skipped, the visitor cookie (as `xeniaProxy()`), landing campaign and trail cookies, standard facts, consent, `/decide`, the rewrite and a `Server-Timing` header — and takes hooks for what is your site's own:
842
+
843
+ ```ts
844
+ // proxy.ts
845
+ import { createXeniaEdge } from "@xenia-martech/sdk-js/edge";
846
+ import { xeniaEdgeProxy } from "@xenia-martech/sdk-js/next";
847
+
848
+ const edge = createXeniaEdge({
849
+ baseUrl: process.env.XENIA_URL!,
850
+ token: process.env.XENIA_SDK_TOKEN!,
851
+ manifestUrl: `${process.env.URL}/api/xenia/manifest`, // your CDN copy (below)
852
+ });
853
+
854
+ export const proxy = (request: NextRequest) =>
855
+ xeniaEdgeProxy(request, {
856
+ edge,
857
+ variantSecret: process.env.XENIA_VARIANT_SECRET!, // the /xv/<token>/… page decodes with it
858
+ skip: (path) => path.startsWith("/account"), // no variant page: never decided
859
+ facts: (ctx) => ({ signed_in: Boolean(ctx.request.cookies.get("session")) }),
860
+ });
861
+ ```
862
+
863
+ | Hook | For |
864
+ |---|---|
865
+ | `skip(path)` | Pages with no variant page — their views still join the trail. |
866
+ | `prepare(ctx)` | Change the visitor, geo, landing or earlier trail before facts are built (profiles, QA personas). |
867
+ | `facts(ctx)` / `cacheFacts(ctx)` | Your own facts on top of the standard ones, and what else should give a visitor a fresh decision. |
868
+ | `budgetMs(ctx)` | A bigger budget for a decision that calls a back office. |
869
+ | `alongside(ctx, keys)` | Work started before `/decide` and awaited after it; its result reaches the hooks below as `side`. |
870
+ | `filterDecisions`, `extra`, `rewrite` | Drop decisions, carry data in the variant token, or render somewhere else (`null` = the page itself). |
871
+ | `geoHeaders` | The location headers your host sets (see `viewerGeo`'s `trust`). Default none. |
872
+ | `onDecision(info)` | Every exit (`skipped`, `not-targeted`, `no-consent`, `decided`, `error`) with the context, facts, outcome and response — debug output, extra cookies. |
873
+ | `onError(error)` | Anything that threw — a hook, the edge client. The page is served with base content either way. |
874
+
875
+ Two route factories serve the rest: `createManifestRoute({ token, baseUrl, revalidate })` for the public manifest copy (`export const GET`, with `export const revalidate = …` beside it), and `createRevalidateRoute({ secret, paths: ["/api/xenia/manifest"], onManifestChanged })` for Xenia's storefront webhook (`export const POST`), which regenerates the manifest the moment a journey changes.
876
+
877
+ The page behind the rewrite reads the swaps back with `decodeVariant(token, secret)` — `null` for a token that is malformed or not signed with your secret, so a crafted URL can never render arbitrary entries — and applies them to its CMS content. The token holds only the swaps (plus your optional `extra`), identical for every visitor in the same variant, so each variant renders once and is then served from your ISR/CDN cache.
878
+
879
+ | Export | What it does |
880
+ |---|---|
881
+ | `createXeniaEdge(config)` | `match(host, path)` from the manifest (stale-while-revalidate, ETag; seed it with `initialManifest` so a cold start never waits), `decide(keys, input)` with `budgetMs` (default 150 ms), a per-visitor decision cache (`memoryDecisionCache()` by default, any `DecisionCache` for a shared store) and in-flight de-duplication. `manifestUrl` reads a storefront's own CDN copy instead. |
882
+ | `matchesAny(patterns, host, path)` | The manifest's page patterns: `/women`, `/women/*`, `/*`, `uk.example.com/women`. |
883
+ | `fetchManifest(opts)` / `trimManifest(m)` | Xenia's manifest, and the public copy a storefront may serve from its CDN (version + page patterns only). |
884
+ | `verifyWebhook({ headers, body }, secret)` | Checks Xenia's storefront webhook (`X-Xenia-Signature`), refusing anything older than 5 minutes. |
885
+ | `swapGroups` / `encodeVariant` / `decodeVariant` | The signed variant token. `extra` that would make the token too long is left out. |
886
+ | `viewerGeo`, `deviceOf`, `isBot`, `isPrefetch`, `isPageLoad`, `landingContext`, `localTime`, `seasonOf` | Request facts journey rules read. Geo must come from your edge, never Xenia's: `/decide` is server to server. `viewerGeo` always uses Netlify's runtime context and a runtime-attached `request.geo`, but reads a location **header** only when you list it in `trust` — a visitor can send any header, so name only the ones your host sets and overwrites (`x-vercel-ip-country` on Vercel, `cf-ipcountry` behind Cloudflare, `cloudfront-viewer-country` on CloudFront, `x-nf-geo`/`x-country` on Netlify, `x-xn-geo` from your own edge function). |
887
+ | `readTrail` / `withView` / `trailCookieValue` | The visitor's recent page views (`xenia_trail`), sent with `/decide` so profile rules see views whose events haven't landed yet. |
888
+ | `edgeConsent(headers)` | What the edge flow may do (below). |
889
+
890
+ **Consent.** `edgeConsent` reads the `xenia_consent` cookie the browser client's `consent()` writes:
891
+
892
+ | Signal | Effect |
893
+ |---|---|
894
+ | no consent cookie | no choice made: decide, send trail and landing facts, keep the trail and landing cookies |
895
+ | `personalization: false` | don't call Xenia; serve base content |
896
+ | `analytics: false` | still decide, but keep no trail or click cookies and send no trail |
897
+ | `Sec-GPC: 1` or `DNT: 1` | treated as `analytics: false` (pass `respectDnt: false` to ignore them) |
898
+
899
+ **Browser signals.** `<XeniaProvider pageViews pageLeave trail navClicks contentClicks>` (or the same `createXeniaClient` options) sends what a visitor profile reads — page views, time on page, header navigation and content clicks — and keeps the trail cookie the proxy forwards, so a decision already sees the page the visitor just left.
900
+
901
+ **Debugging and QA.** `xeniaEdgeProxy({ debug, qa })` — turn both on only in development or on a QA deploy (e.g. `debug: process.env.NODE_ENV === "development"`):
902
+
903
+ - `debug: true` writes what was decided for each of the last few pages to the `xenia_debug` cookie. Render `<XeniaDebugPanel />` from `/client/react` behind the same flag: a round Xenia launcher in the corner opens a small white card, like Next.js's dev tools menu, with the status, page, source and location, the decision per content key (click a key for the journey path it took), and the visitor profile. `position` picks the corner (default bottom-right, away from Next.js's own dev tools).
904
+ - Your own data: return `custom` from `debugInfo(info)` — `{ Moment: "thursday 12:00", Household: ["Emma: blue, shoes", "John: no taste"] }` — and the card shows each key as a row (a list one line per item). `renderExtra(page)` remains for anything else.
905
+ - `qa: true` lets a tester place the visitor in a city (`?xn_geo=london`, from `QA_CITIES` or your own `qa: { cities }`), decide as of a day (`?xn_date=2026-12-24`), and forget both with `?xn_geo=off`; under `next dev`, `?xn_country=GB` too. The overrides last the visit.
906
+
907
+ **Impressions and clicks.** Stamp a section Xenia personalized with `data-xenia-slot="<field>"`, `data-xenia-content-entry-id`, `data-xenia-base-entry-id`, `data-xenia-bucket="personalized"` and (when known) `data-xenia-content-key`; the browser client reports `personalization_impression` (half the section in view, or half the screen for a taller one) and `personalization_click` (with the clicked link's or button's `label`; `slotClicks: "controls"` counts only those clicks, not clicks on a section's copy or images). A `display: contents` wrapper works — its first child is watched. Exposures are recorded by `/decide` itself; don't report them again.
908
+
805
909
  ## Analytics & conversions
806
910
 
807
911
  `track(eventName, props?, opts?)` with the default `opts.trigger: false` records a canonical analytics event — batched client-side and posted to the unified `POST /api/events/v1`. It records a fact for analytics/attribution/reporting and does **not** advance a journey. Set `opts.trigger: true` to *also* match/advance a journey from the same call.
@@ -149,6 +149,12 @@ interface VisitorCookieOptions {
149
149
  secure?: boolean;
150
150
  /** Cookie lifetime in days. Default 365. */
151
151
  maxAgeDays?: number;
152
+ /**
153
+ * The cookie wins over the id stored in this browser: when a server (e.g. an edge proxy) changes
154
+ * the visitor — a profile switch, a QA persona — the browser follows instead of writing its old id
155
+ * back. Default false: the stored id wins and the cookie only seeds a first visit.
156
+ */
157
+ authoritative?: boolean;
152
158
  }
153
159
  /** Well-known event fields understood by the backend event-trigger node. */
154
160
  interface EventIdentity {
@@ -998,6 +1004,33 @@ interface XeniaClientOptions extends XeniaConfig {
998
1004
  * triggers (`track(..., { trigger: true })`) always fire. Default true.
999
1005
  */
1000
1006
  respectDnt?: boolean;
1007
+ /**
1008
+ * Report a `pageView` (`{ title, searchTerm?, category? }`) on the first page and every client-side
1009
+ * navigation, once the new page's title is in. `<meta name="xenia-title">` / `xenia-category`
1010
+ * override the title / add a category. For single-page apps. Default false.
1011
+ */
1012
+ pageViews?: boolean;
1013
+ /** Report `page_leave` (`{ path, durationMs }`, visible time) when a page is left or first hidden. Default false. */
1014
+ pageLeave?: boolean;
1015
+ /**
1016
+ * Keep the page trail cookie (`<prefix>_trail`) an edge proxy sends with `/decide`: client-side
1017
+ * navigations, and the time on a page as the visitor leaves it. Only with analytics consent and no
1018
+ * DNT/GPC. Default false.
1019
+ */
1020
+ trail?: boolean;
1021
+ /** Report `content_click` `{ label, category? }` for clicks on images (or cards holding one) inside `root` (default `main`). Default false. */
1022
+ contentClicks?: boolean | {
1023
+ root?: string;
1024
+ };
1025
+ /** Report `nav_click` `{ category, label }` for same-site links inside `root` (default `header`). Default false. */
1026
+ navClicks?: boolean | {
1027
+ root?: string;
1028
+ };
1029
+ /**
1030
+ * Which clicks inside a `[data-xenia-slot]` section are a `personalization_click`: `any` (default)
1031
+ * or only clicks on its links and buttons (`controls`).
1032
+ */
1033
+ slotClicks?: "any" | "controls";
1001
1034
  }
1002
1035
  /** Events emitted by the client for observability. */
1003
1036
  type ClientEvent = "event" | "response" | "actions" | "error" | "identify";
@@ -1014,6 +1047,8 @@ declare class XeniaClient {
1014
1047
  private readonly renderer;
1015
1048
  private readonly analyticsQueue;
1016
1049
  private tracker;
1050
+ private stopPageTracking;
1051
+ private stopEngagement;
1017
1052
  /** The in-flight (or finished) boot; shared so concurrent `init()` calls start one tracker. */
1018
1053
  private booting;
1019
1054
  /** Bumped by `destroy()`, so a boot still waiting on the device id knows to stop. */
@@ -149,6 +149,12 @@ interface VisitorCookieOptions {
149
149
  secure?: boolean;
150
150
  /** Cookie lifetime in days. Default 365. */
151
151
  maxAgeDays?: number;
152
+ /**
153
+ * The cookie wins over the id stored in this browser: when a server (e.g. an edge proxy) changes
154
+ * the visitor — a profile switch, a QA persona — the browser follows instead of writing its old id
155
+ * back. Default false: the stored id wins and the cookie only seeds a first visit.
156
+ */
157
+ authoritative?: boolean;
152
158
  }
153
159
  /** Well-known event fields understood by the backend event-trigger node. */
154
160
  interface EventIdentity {
@@ -998,6 +1004,33 @@ interface XeniaClientOptions extends XeniaConfig {
998
1004
  * triggers (`track(..., { trigger: true })`) always fire. Default true.
999
1005
  */
1000
1006
  respectDnt?: boolean;
1007
+ /**
1008
+ * Report a `pageView` (`{ title, searchTerm?, category? }`) on the first page and every client-side
1009
+ * navigation, once the new page's title is in. `<meta name="xenia-title">` / `xenia-category`
1010
+ * override the title / add a category. For single-page apps. Default false.
1011
+ */
1012
+ pageViews?: boolean;
1013
+ /** Report `page_leave` (`{ path, durationMs }`, visible time) when a page is left or first hidden. Default false. */
1014
+ pageLeave?: boolean;
1015
+ /**
1016
+ * Keep the page trail cookie (`<prefix>_trail`) an edge proxy sends with `/decide`: client-side
1017
+ * navigations, and the time on a page as the visitor leaves it. Only with analytics consent and no
1018
+ * DNT/GPC. Default false.
1019
+ */
1020
+ trail?: boolean;
1021
+ /** Report `content_click` `{ label, category? }` for clicks on images (or cards holding one) inside `root` (default `main`). Default false. */
1022
+ contentClicks?: boolean | {
1023
+ root?: string;
1024
+ };
1025
+ /** Report `nav_click` `{ category, label }` for same-site links inside `root` (default `header`). Default false. */
1026
+ navClicks?: boolean | {
1027
+ root?: string;
1028
+ };
1029
+ /**
1030
+ * Which clicks inside a `[data-xenia-slot]` section are a `personalization_click`: `any` (default)
1031
+ * or only clicks on its links and buttons (`controls`).
1032
+ */
1033
+ slotClicks?: "any" | "controls";
1001
1034
  }
1002
1035
  /** Events emitted by the client for observability. */
1003
1036
  type ClientEvent = "event" | "response" | "actions" | "error" | "identify";
@@ -1014,6 +1047,8 @@ declare class XeniaClient {
1014
1047
  private readonly renderer;
1015
1048
  private readonly analyticsQueue;
1016
1049
  private tracker;
1050
+ private stopPageTracking;
1051
+ private stopEngagement;
1017
1052
  /** The in-flight (or finished) boot; shared so concurrent `init()` calls start one tracker. */
1018
1053
  private booting;
1019
1054
  /** Bumped by `destroy()`, so a boot still waiting on the device id knows to stop. */
@@ -1,4 +1,4 @@
1
- import { L as XeniaConfig, J as JsonValue, W as WebhookResponse, F as WidgetSubmitResponse, N as XeniaEvent, c as AnalyticsIngestResponse, I as IdentifyRequest, m as IdentifyResponse, f as ConsentState, w as ResolveOptions, x as ResolveResult, R as ResolveBatchItem, u as ResolveBatchResult, v as ResolveEntriesResult } from './personalize-content-Bf6YZdkZ.js';
1
+ import { x as XeniaConfig, J as JsonValue, W as WebhookResponse, v as WidgetSubmitResponse, y as XeniaEvent, c as AnalyticsIngestResponse, I as IdentifyRequest, j as IdentifyResponse, d as ConsentState, p as ResolveOptions, q as ResolveResult, R as ResolveBatchItem, n as ResolveBatchResult, o as ResolveEntriesResult } from './types-DgVHS4Qw.cjs';
2
2
 
3
3
  /**
4
4
  * Low-level, isomorphic Xenia API client. Wraps the raw endpoints; identity,
@@ -1,4 +1,4 @@
1
- import { L as XeniaConfig, J as JsonValue, W as WebhookResponse, F as WidgetSubmitResponse, N as XeniaEvent, c as AnalyticsIngestResponse, I as IdentifyRequest, m as IdentifyResponse, f as ConsentState, w as ResolveOptions, x as ResolveResult, R as ResolveBatchItem, u as ResolveBatchResult, v as ResolveEntriesResult } from './personalize-content-Bf6YZdkZ.cjs';
1
+ import { x as XeniaConfig, J as JsonValue, W as WebhookResponse, v as WidgetSubmitResponse, y as XeniaEvent, c as AnalyticsIngestResponse, I as IdentifyRequest, j as IdentifyResponse, d as ConsentState, p as ResolveOptions, q as ResolveResult, R as ResolveBatchItem, n as ResolveBatchResult, o as ResolveEntriesResult } from './types-DgVHS4Qw.js';
2
2
 
3
3
  /**
4
4
  * Low-level, isomorphic Xenia API client. Wraps the raw endpoints; identity,
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunkYZG6JR4E_cjs = require('./chunk-YZG6JR4E.cjs');
3
+ var chunkRWE2OCQL_cjs = require('./chunk-RWE2OCQL.cjs');
4
4
 
5
5
  // src/core/event-payload.ts
6
6
  var RESERVED = /* @__PURE__ */ new Set([
@@ -70,7 +70,7 @@ var XeniaServer = class {
70
70
  /** Ambient request context attached to every call — see {@link XeniaConfig.context}. */
71
71
  context;
72
72
  constructor(config) {
73
- this.api = new chunkYZG6JR4E_cjs.XeniaApi(config);
73
+ this.api = new chunkRWE2OCQL_cjs.XeniaApi(config);
74
74
  this.visitorId = config.visitorId;
75
75
  this.context = config.context;
76
76
  }
@@ -83,7 +83,7 @@ var XeniaServer = class {
83
83
  async track(eventName, opts = {}) {
84
84
  const event = {
85
85
  eventName,
86
- eventId: opts.eventId ? chunkYZG6JR4E_cjs.toEventId(opts.eventId) : chunkYZG6JR4E_cjs.generateUlid(),
86
+ eventId: opts.eventId ? chunkRWE2OCQL_cjs.toEventId(opts.eventId) : chunkRWE2OCQL_cjs.generateUlid(),
87
87
  eventVersion: opts.eventVersion ?? 1,
88
88
  occurredAtMs: opts.occurredAtMs ?? Date.now(),
89
89
  source: opts.source ?? "xenia-server-sdk",
@@ -164,7 +164,7 @@ var XeniaServer = class {
164
164
  * Pass `opts.identity`/`opts.context` like `resolve()`.
165
165
  */
166
166
  async personalizeContent(page, opts) {
167
- return chunkYZG6JR4E_cjs.personalizeContent(page, { ...opts, resolve: this.entryResolver(opts) });
167
+ return chunkRWE2OCQL_cjs.personalizeContent(page, { ...opts, resolve: this.entryResolver(opts) });
168
168
  }
169
169
  /**
170
170
  * Fetch a page through `opts.cms` and personalize it — the app passes only the page's identity
@@ -172,7 +172,7 @@ var XeniaServer = class {
172
172
  * null` with `error`, a missing page `content: null`.
173
173
  */
174
174
  async personalizePage(ref, opts) {
175
- return chunkYZG6JR4E_cjs.personalizePage(ref, { ...opts, resolve: this.entryResolver(opts) });
175
+ return chunkRWE2OCQL_cjs.personalizePage(ref, { ...opts, resolve: this.entryResolver(opts) });
176
176
  }
177
177
  entryResolver(opts) {
178
178
  const resolveOpts = this.withVisitor({
@@ -199,13 +199,13 @@ var XeniaServer = class {
199
199
  * nothing was passed — preserving today's behavior for callers using neither.
200
200
  */
201
201
  withVisitor(opts) {
202
- const identity = chunkYZG6JR4E_cjs.compact({
202
+ const identity = chunkRWE2OCQL_cjs.compact({
203
203
  ...opts?.identity,
204
204
  visitorId: this.visitorId ?? opts?.identity?.visitorId
205
205
  });
206
206
  const merged = {
207
- ...chunkYZG6JR4E_cjs.compact(this.context ?? {}),
208
- ...chunkYZG6JR4E_cjs.compact(opts?.context ?? {}),
207
+ ...chunkRWE2OCQL_cjs.compact(this.context ?? {}),
208
+ ...chunkRWE2OCQL_cjs.compact(opts?.context ?? {}),
209
209
  ...identity
210
210
  };
211
211
  if (Object.keys(merged).length === 0 && !this.visitorId) return opts;
@@ -217,42 +217,7 @@ function createXeniaServer(config) {
217
217
  return new XeniaServer(config);
218
218
  }
219
219
 
220
- // src/server/cookies.ts
221
- function parseCookies(header) {
222
- const out = {};
223
- if (!header) return out;
224
- for (const part of header.split(";")) {
225
- const idx = part.indexOf("=");
226
- if (idx === -1) continue;
227
- const key = part.slice(0, idx).trim();
228
- const value = part.slice(idx + 1).trim();
229
- if (key) out[key] = decodeURIComponent(value);
230
- }
231
- return out;
232
- }
233
- function getVisitorId(cookieHeader, prefix) {
234
- return parseCookies(cookieHeader)[chunkYZG6JR4E_cjs.visitorCookieName(prefix)] ?? null;
235
- }
236
- function getConsent(cookieHeader, prefix) {
237
- const raw = parseCookies(cookieHeader)[chunkYZG6JR4E_cjs.consentCookieName(prefix)];
238
- if (!raw) return { analytics: false, marketing: false, personalization: false };
239
- try {
240
- const parsed = JSON.parse(raw);
241
- return parsed;
242
- } catch {
243
- const scopes = new Set(raw.split(",").map((s) => s.trim()));
244
- return {
245
- analytics: scopes.has("analytics"),
246
- marketing: scopes.has("marketing"),
247
- personalization: scopes.has("personalization")
248
- };
249
- }
250
- }
251
-
252
220
  exports.XeniaServer = XeniaServer;
253
221
  exports.createXeniaServer = createXeniaServer;
254
- exports.getConsent = getConsent;
255
- exports.getVisitorId = getVisitorId;
256
- exports.parseCookies = parseCookies;
257
- //# sourceMappingURL=chunk-TRRMMPBH.cjs.map
258
- //# sourceMappingURL=chunk-TRRMMPBH.cjs.map
222
+ //# sourceMappingURL=chunk-2DR5AVPX.cjs.map
223
+ //# sourceMappingURL=chunk-2DR5AVPX.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/event-payload.ts","../src/server/xenia-server.ts"],"names":["compact","XeniaApi","toEventId","generateUlid","personalizeContent","personalizePage"],"mappings":"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","file":"chunk-2DR5AVPX.cjs","sourcesContent":["import type { EventContext, EventIdentity, EventPayload, JsonValue } from \"./types\";\n\n/** Reserved keys that map to first-class fields (not passthrough data). */\nconst RESERVED = new Set([\n \"eventId\",\n \"eventType\",\n \"journeyId\",\n \"deviceId\",\n \"visitorId\",\n \"customerId\",\n \"email\",\n \"utmSource\",\n \"utmMedium\",\n \"utmCampaign\",\n \"utmContent\",\n \"utmId\",\n \"url\",\n \"referrer\",\n \"path\",\n \"query\",\n]);\n\nexport interface BuildEventInput {\n eventId: string;\n eventType?: string;\n journeyId?: string;\n identity?: EventIdentity;\n context?: EventContext;\n /** Arbitrary extra properties merged as passthrough data. */\n props?: Record<string, JsonValue | undefined>;\n}\n\n/** Drop `undefined` values so the JSON body stays clean. */\nfunction compact(obj: Record<string, JsonValue | undefined>): Record<string, JsonValue> {\n const out: Record<string, JsonValue> = {};\n for (const [k, v] of Object.entries(obj)) {\n if (v !== undefined) out[k] = v;\n }\n return out;\n}\n\n/**\n * Build the request body for the webhook `/flow` call (`XeniaApi.webhook`).\n *\n * Merge order (later wins): passthrough props < context < identity < core.\n * Field names match the backend `EventTriggerRequest` DTO exactly.\n *\n * @internal Used only by the webhook request/response path — the unified event\n * surface (`track()` → `POST /api/events/v1`) builds {@link XeniaEvent} instead.\n */\nexport function buildEventPayload(input: BuildEventInput): EventPayload {\n const passthrough: Record<string, JsonValue> = {};\n const reserved: Record<string, JsonValue> = {};\n if (input.props) {\n for (const [k, v] of Object.entries(input.props)) {\n if (v === undefined) continue;\n // A prop whose key matches a reserved field (e.g. `email`, `customerId`,\n // `utmSource`) is promoted to that first-class field; everything else is\n // custom passthrough data. Previously reserved-named props were dropped\n // outright — so `track(\"sign_up\", { email })` never sent `email` at all.\n if (RESERVED.has(k)) reserved[k] = v;\n else passthrough[k] = v;\n }\n }\n\n const payload = {\n ...passthrough,\n // Reserved-named props fill first-class fields (e.g. `email`, `customerId`)\n // the SDK didn't auto-collect. Auto-collected context/identity still win for\n // what they reliably know (real page url, visitor/device id), so a stray prop\n // can't override the true page context — see merge order below.\n ...reserved,\n ...compact((input.context ?? {}) as Record<string, JsonValue | undefined>),\n ...compact((input.identity ?? {}) as Record<string, JsonValue | undefined>),\n eventId: input.eventId,\n ...(input.eventType ? { eventType: input.eventType } : {}),\n ...(input.journeyId ? { journeyId: input.journeyId } : {}),\n };\n\n return payload as EventPayload;\n}\n","import { compact, XeniaApi } from \"../core/api\";\nimport { buildEventPayload } from \"../core/event-payload\";\nimport { generateUlid, toEventId } from \"../core/id\";\nimport { personalizeContent, personalizePage } from \"../core/personalize-content\";\nimport type { EntryResolver, PageRef, PagePersonalizeOptions, PersonalizeContentResult } from \"../core/personalize-content\";\nimport type {\n AnalyticsContext,\n AnalyticsEventName,\n CallerIdentity,\n AnalyticsIngestResponse,\n ConsentState,\n EventContext,\n EventIdentity,\n IdentifyRequest,\n IdentifyResponse,\n JsonValue,\n ResolveBatchItem,\n ResolveBatchResult,\n ResolveOptions,\n ResolveResult,\n WebhookResponse,\n XeniaConfig,\n XeniaEvent,\n} from \"../core/types\";\n\n/**\n * Extra per-call identity/context for server-side webhook calls — an override on top of whatever the\n * client already carries.\n *\n * There's no ambient `window` on the server to auto-collect `path`/`query`/`url`/UTM params the way\n * the browser client does, so bind them ONCE per request via {@link XeniaConfig.context} (see\n * `contextFromUrl`) and every call attaches them automatically. These per-call fields are for the\n * remainder: values known only at this call site, or a deliberate override of the ambient ones.\n */\nexport interface ServerWebhookOptions<F = never> extends WithFallback<F> {\n method?: \"GET\" | \"POST\";\n journeyId?: string;\n identity?: EventIdentity;\n context?: EventContext;\n}\n\n/**\n * Opt-in \"never throw\" for calls a page renders from. When `fallback` is set (even to `null`), any\n * failure (timeout, network error, non-2xx, open circuit) resolves to it instead of throwing, so a\n * caller can render its default content without a `try/catch`. Errors are swallowed silently: leave\n * it unset where you need to see them.\n */\nexport interface WithFallback<F> {\n fallback?: F;\n}\n\n/** {@link ResolveOptions} plus {@link WithFallback}. */\nexport type ServerResolveOptions<F = never> = ResolveOptions & WithFallback<F>;\n\n/** Runs `call`, or resolves to `opts.fallback` on any error when one was given. */\nasync function orFallback<T, F>(call: () => Promise<T>, opts?: WithFallback<F>): Promise<T | F> {\n if (!opts || !(\"fallback\" in opts)) return call();\n try {\n return await call();\n } catch {\n return opts.fallback as F;\n }\n}\n\n/**\n * Options for a server-side unified event (`POST /api/events/v1`). There's no\n * ambient request context on the server — pass whatever's known at the call\n * site (e.g. pathname/user-agent from the current request, session id from a\n * cookie) via `identity`/`context`.\n */\nexport interface ServerTrackOptions {\n /** When true, also match/advance a journey (never sampled/shed). Default false. */\n trigger?: boolean;\n /** Stable dedup key (e.g. `\"ord-1001\"`) for idempotent conversions. */\n eventId?: string;\n eventVersion?: number;\n occurredAtMs?: number;\n /** Origin tag. Default `\"xenia-server-sdk\"`. */\n source?: string;\n /** Identity fields for this event (except the SDK-owned `xeniaVisitorId`,\n * which comes from `config.visitorId` and can't be set per call). */\n identity?: CallerIdentity;\n context?: AnalyticsContext;\n properties?: Record<string, JsonValue | undefined>;\n consent?: ConsentState;\n}\n\n/**\n * Server-native Xenia client. Same method names as the browser client, but\n * every call is stateless: identity and context are passed explicitly rather\n * than read from the DOM/localStorage. Uses global `fetch` (Node ≥18).\n */\nexport class XeniaServer {\n readonly api: XeniaApi;\n /** SDK-owned visitor anchor for this (per-request) client — see {@link XeniaConfig.visitorId}. */\n readonly visitorId?: string;\n /** Ambient request context attached to every call — see {@link XeniaConfig.context}. */\n private readonly context?: EventContext;\n\n constructor(config: XeniaConfig) {\n this.api = new XeniaApi(config);\n this.visitorId = config.visitorId;\n this.context = config.context;\n }\n\n /**\n * Send a unified event server-side (`POST /api/events/v1`) and return the\n * ingest response (including per-result `triggered`). `opts.trigger: true`\n * also matches/advances a journey; pass `opts.eventId` (e.g. `\"ord-1001\"`)\n * as a stable dedup key for idempotent conversions.\n */\n async track(\n eventName: AnalyticsEventName,\n opts: ServerTrackOptions = {},\n ): Promise<AnalyticsIngestResponse> {\n const event: XeniaEvent = {\n eventName,\n eventId: opts.eventId ? toEventId(opts.eventId) : generateUlid(),\n eventVersion: opts.eventVersion ?? 1,\n occurredAtMs: opts.occurredAtMs ?? Date.now(),\n source: opts.source ?? \"xenia-server-sdk\",\n identity: {\n ...opts.identity,\n // SDK-owned, set LAST so it can't be overridden per call. Comes from\n // `config.visitorId` (bind it per request from the `xenia_vid` cookie).\n xeniaVisitorId: this.visitorId,\n },\n context: opts.context,\n properties: opts.properties,\n consent: opts.consent,\n trigger: opts.trigger === true ? true : undefined,\n };\n return this.api.sendEvents([event]);\n }\n\n /**\n * Send a webhook request and await the synchronous response. Merges\n * `opts.identity`/`opts.context` into the payload the same way the browser\n * client's auto-attached fields do (`path`, `query`, `url`, `referrer`,\n * `deviceId`, `visitorId`, etc.) — see `ServerWebhookOptions`. Pass `opts.fallback` to get it\n * back instead of an error (see {@link WithFallback}).\n */\n async webhook<F = never>(\n triggerId: string,\n data?: Record<string, JsonValue | undefined>,\n opts: ServerWebhookOptions<F> = {},\n ): Promise<WebhookResponse | F> {\n const payload = buildEventPayload({\n eventId: triggerId,\n journeyId: opts.journeyId,\n // SDK-owned visitor anchor wins: config.visitorId (bind the `xenia_vid`\n // cookie per request) is attached automatically. Falls back to a\n // per-call visitorId only when none is bound.\n identity: { ...opts.identity, visitorId: this.visitorId ?? opts.identity?.visitorId },\n // Ambient request context (config.context, bound once per request) under a per-call\n // override — same precedence resolve() uses, so both surfaces carry the same fields.\n context: { ...this.context, ...opts.context },\n props: data,\n });\n return orFallback(() => this.api.webhook(triggerId, payload as JsonValue, opts.method ?? \"POST\"), opts);\n }\n\n /**\n * Bind visitor→user server-side — `POST /api/events/v1/identify`. `xeniaVisitorId` defaults to\n * the bound `config.visitorId`, so a per-request client only passes what it learned (e.g. an email\n * from a submitted form).\n */\n async identify(\n body: Omit<IdentifyRequest, \"xeniaVisitorId\"> & { xeniaVisitorId?: string },\n ): Promise<IdentifyResponse> {\n const xeniaVisitorId = body.xeniaVisitorId ?? this.visitorId;\n if (!xeniaVisitorId) {\n throw new Error(\"identify() needs a visitor: bind config.visitorId or pass xeniaVisitorId\");\n }\n return this.api.identify({ ...body, xeniaVisitorId });\n }\n\n /**\n * Resolve in-entry content personalization for one content key\n * (`POST /api/personalization/resolve`). Requires a configured SDK token\n * (`token`/`getToken`) — resolve is auth-required. With `merge: true` the\n * returned `content` is server-spliced.\n *\n * Pass `opts.identity`/`opts.context` to attach the same SDK-provided\n * fields (`deviceId`, `customerId`, `email`, `path`, `url`, `referrer`,\n * `utm*`, …) the browser client collects ambiently — there's no ambient\n * `window`/`document` server-side, so a server caller must supply whatever\n * it knows (e.g. from the current request) explicitly. See\n * {@link ResolveOptions}. Pass `opts.fallback` to get it back instead of an\n * error (see {@link WithFallback}).\n */\n async resolve<F = never>(contentKey: string, opts?: ServerResolveOptions<F>): Promise<ResolveResult | F> {\n return orFallback(() => this.api.resolve(contentKey, this.withVisitor(opts)), opts);\n }\n\n /**\n * Resolve several content keys in one round-trip (`POST /api/personalization/resolve-batch`).\n * `opts.fallback` as in {@link resolve}.\n */\n async resolveBatch<F = never>(\n items: ResolveBatchItem[],\n opts?: ServerResolveOptions<F>,\n ): Promise<ResolveBatchResult[] | F> {\n return orFallback(() => this.api.resolveBatch(items, this.withVisitor(opts)), opts);\n }\n\n /**\n * Personalize a fetched CMS page server-side — see `XeniaClient.personalizeContent`. Never\n * throws: on error or timeout the page renders with whatever was decided (base content otherwise).\n * Pass `opts.identity`/`opts.context` like `resolve()`.\n */\n async personalizeContent<T>(page: T, opts: PagePersonalizeOptions): Promise<PersonalizeContentResult<T>> {\n return personalizeContent(page, { ...opts, resolve: this.entryResolver(opts) });\n }\n\n /**\n * Fetch a page through `opts.cms` and personalize it — the app passes only the page's identity\n * (entry id, or `{ contentType, fields: { slug } }`). Never throws: a failed fetch is `content:\n * null` with `error`, a missing page `content: null`.\n */\n async personalizePage<T = unknown>(ref: PageRef, opts: PagePersonalizeOptions): Promise<PersonalizeContentResult<T | null>> {\n return personalizePage<T>(ref, { ...opts, resolve: this.entryResolver(opts) });\n }\n\n private entryResolver(opts: PagePersonalizeOptions): EntryResolver {\n const resolveOpts = this.withVisitor({\n data: opts.data, locale: opts.locale, identity: opts.identity, context: opts.context,\n });\n return async (ids) =>\n (await this.api.resolveEntries(ids, { ...resolveOpts, contentKey: opts.contentKey })).decisions ?? [];\n }\n\n /**\n * Merge the ambient `config.context` and `opts.identity`/`opts.context` into\n * the resolve `data`, then stamp the SDK-owned visitor id\n * (`config.visitorId`) under both the reserved `_xenia_vid` key (read by the\n * backend for holdout/exposure bucketing) and the flat `visitorId` field —\n * mirrors `webhook()`'s merge order/precedence exactly: caller `data` <\n * ambient `config.context` < caller `context`/`identity` < SDK-owned\n * `visitorId`/`_xenia_vid`, set LAST so neither can be overridden per call.\n *\n * `config.context` is what makes the SDK-provided fields automatic on the\n * server: bound once per request, every `resolve()` carries `path`/`query`/\n * `url`/`referrer`/UTMs with no per-call wiring, matching what the browser\n * client collects from `window` on its own. A no-op when nothing is bound and\n * nothing was passed — preserving today's behavior for callers using neither.\n */\n private withVisitor(opts?: ResolveOptions): ResolveOptions | undefined {\n const identity = compact({\n ...opts?.identity,\n visitorId: this.visitorId ?? opts?.identity?.visitorId,\n });\n const merged = {\n ...compact((this.context ?? {}) as Record<string, JsonValue | undefined>),\n ...compact((opts?.context ?? {}) as Record<string, JsonValue | undefined>),\n ...identity,\n };\n if (Object.keys(merged).length === 0 && !this.visitorId) return opts;\n if (this.visitorId) merged._xenia_vid = this.visitorId;\n return { ...opts, data: { ...(opts?.data ?? {}), ...merged } };\n }\n}\n\n/** Create a server-native client. */\nexport function createXeniaServer(config: XeniaConfig): XeniaServer {\n return new XeniaServer(config);\n}\n"]}
@@ -0,0 +1,179 @@
1
+ // src/core/http-client.ts
2
+ var DEFAULT_BASE_URL = "https://engine.xeniamartech.com/xenia";
3
+ function isBrowser() {
4
+ return typeof window !== "undefined" && typeof document !== "undefined";
5
+ }
6
+ function buildUrl(url, queryParams) {
7
+ if (!queryParams || Object.keys(queryParams).length === 0) return url;
8
+ const params = new URLSearchParams(queryParams);
9
+ const sep = url.includes("?") ? "&" : "?";
10
+ return `${url}${sep}${params.toString()}`;
11
+ }
12
+ function delay(ms) {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+ var HttpError = class extends Error {
16
+ constructor(status, data, message) {
17
+ super(message ?? `Request failed with status ${status}`);
18
+ this.status = status;
19
+ this.data = data;
20
+ this.name = "HttpError";
21
+ }
22
+ status;
23
+ data;
24
+ };
25
+ var CircuitOpenError = class extends Error {
26
+ constructor(endpoint) {
27
+ super(`Circuit open for ${endpoint}: failing fast until the cooldown passes`);
28
+ this.endpoint = endpoint;
29
+ this.name = "CircuitOpenError";
30
+ }
31
+ endpoint;
32
+ };
33
+ var breakers = /* @__PURE__ */ new Map();
34
+ var HttpClient = class {
35
+ baseUrl;
36
+ defaultHeaders;
37
+ timeout;
38
+ retry;
39
+ debug;
40
+ breaker;
41
+ constructor(opts) {
42
+ this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
43
+ this.defaultHeaders = opts.defaultHeaders ?? {};
44
+ this.timeout = opts.timeout ?? 8e3;
45
+ this.retry = {
46
+ attempts: opts.retry?.attempts ?? 2,
47
+ baseDelayMs: opts.retry?.baseDelayMs ?? 300
48
+ };
49
+ this.debug = opts.debug ?? false;
50
+ const cb = opts.circuitBreaker === true ? {} : opts.circuitBreaker || null;
51
+ this.breaker = cb && { failureThreshold: cb.failureThreshold ?? 3, cooldownMs: cb.cooldownMs ?? 3e4 };
52
+ }
53
+ async request(opts) {
54
+ if (!this.breaker) return this.send(opts);
55
+ const key = buildUrl(this.baseUrl + opts.path, opts.query);
56
+ const b = breakers.get(key) ?? { failures: 0, openedAt: 0 };
57
+ if (b.openedAt) {
58
+ if (Date.now() - b.openedAt < this.breaker.cooldownMs) throw new CircuitOpenError(key);
59
+ b.openedAt = 0;
60
+ b.failures = this.breaker.failureThreshold - 1;
61
+ }
62
+ try {
63
+ const result = await this.send(opts);
64
+ breakers.delete(key);
65
+ return result;
66
+ } catch (err) {
67
+ if (err instanceof HttpError && !this.isRetryableStatus(err.status)) {
68
+ breakers.delete(key);
69
+ throw err;
70
+ }
71
+ b.failures += 1;
72
+ if (b.failures >= this.breaker.failureThreshold) {
73
+ b.openedAt = Date.now();
74
+ this.log(`circuit opened for ${opts.path} after ${b.failures} failures`);
75
+ }
76
+ breakers.set(key, b);
77
+ throw err;
78
+ }
79
+ }
80
+ async send(opts) {
81
+ const url = buildUrl(this.baseUrl + opts.path, opts.query);
82
+ const method = opts.method ?? "GET";
83
+ const headers = {
84
+ "Content-Type": "application/json",
85
+ ...this.defaultHeaders,
86
+ ...opts.headers
87
+ };
88
+ const body = opts.body !== void 0 ? JSON.stringify(opts.body) : void 0;
89
+ const timeout = opts.timeout ?? this.timeout;
90
+ const maxAttempts = opts.noRetry ? 1 : (opts.retry?.attempts ?? this.retry.attempts) + 1;
91
+ const baseDelay = opts.retry?.baseDelayMs ?? this.retry.baseDelayMs;
92
+ let lastError;
93
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
94
+ try {
95
+ const response = await this.fetchWithTimeout(
96
+ url,
97
+ { method, headers, body, ...opts.keepalive ? { keepalive: true } : {} },
98
+ timeout
99
+ );
100
+ const data = await this.parseBody(response);
101
+ if (response.ok) {
102
+ return { response, data };
103
+ }
104
+ if (this.isRetryableStatus(response.status) && attempt < maxAttempts - 1) {
105
+ const wait = this.retryAfterMs(response, baseDelay, attempt);
106
+ this.log(`retrying ${opts.path} after ${wait}ms (status ${response.status})`);
107
+ await delay(wait);
108
+ continue;
109
+ }
110
+ throw new HttpError(response.status, data);
111
+ } catch (err) {
112
+ lastError = err;
113
+ if (err instanceof HttpError) throw err;
114
+ if (attempt < maxAttempts - 1) {
115
+ const wait = baseDelay * 2 ** attempt;
116
+ this.log(`retrying ${opts.path} after ${wait}ms (network error)`);
117
+ await delay(wait);
118
+ continue;
119
+ }
120
+ }
121
+ }
122
+ throw lastError instanceof Error ? lastError : new Error("Request failed");
123
+ }
124
+ async fetchWithTimeout(url, init, timeout) {
125
+ const controller = new AbortController();
126
+ const timer = setTimeout(() => controller.abort(), timeout);
127
+ try {
128
+ return await fetch(url, { ...init, signal: controller.signal });
129
+ } catch (err) {
130
+ if (err instanceof Error && err.name === "AbortError") {
131
+ throw new Error(`Request timeout after ${timeout}ms`, { cause: err });
132
+ }
133
+ throw err;
134
+ } finally {
135
+ clearTimeout(timer);
136
+ }
137
+ }
138
+ async parseBody(response) {
139
+ const text = await response.text().catch(() => "");
140
+ if (!text) return null;
141
+ try {
142
+ return JSON.parse(text);
143
+ } catch {
144
+ return text;
145
+ }
146
+ }
147
+ isRetryableStatus(status) {
148
+ return status === 429 || status >= 500 && status <= 599;
149
+ }
150
+ retryAfterMs(response, baseDelay, attempt) {
151
+ const header = response.headers.get("Retry-After");
152
+ if (header) {
153
+ const seconds = Number(header);
154
+ if (!Number.isNaN(seconds)) return seconds * 1e3;
155
+ }
156
+ return baseDelay * 2 ** attempt;
157
+ }
158
+ log(msg) {
159
+ if (this.debug) console.warn(`[xenia] ${msg}`);
160
+ }
161
+ };
162
+
163
+ // src/core/cookie-names.ts
164
+ var DEFAULT_PREFIX = "xenia";
165
+ function visitorCookieName(prefix = DEFAULT_PREFIX) {
166
+ return `${prefix || DEFAULT_PREFIX}_vid`;
167
+ }
168
+ function consentCookieName(prefix = DEFAULT_PREFIX) {
169
+ return `${prefix || DEFAULT_PREFIX}_consent`;
170
+ }
171
+ var VISITOR_COOKIE = visitorCookieName();
172
+ var CONSENT_COOKIE = consentCookieName();
173
+ function consentCookie(state, prefix, maxAgeDays = 365) {
174
+ return `${consentCookieName(prefix)}=${encodeURIComponent(JSON.stringify(state))}; path=/; max-age=${Math.floor(maxAgeDays * 86400)}; SameSite=Lax`;
175
+ }
176
+
177
+ export { CONSENT_COOKIE, CircuitOpenError, DEFAULT_BASE_URL, DEFAULT_PREFIX, HttpClient, HttpError, VISITOR_COOKIE, buildUrl, consentCookie, consentCookieName, isBrowser, visitorCookieName };
178
+ //# sourceMappingURL=chunk-BQ5PJCZ7.js.map
179
+ //# sourceMappingURL=chunk-BQ5PJCZ7.js.map