@coframe/sdk 1.1.46 → 1.1.48

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
@@ -140,6 +140,178 @@ const projectData = serverData?.project
140
140
 
141
141
  Pass `projectData` into `<CoframeProvider>` to avoid the extra client-side project fetch and personalize on first paint.
142
142
 
143
+ ### Server-side assignment
144
+
145
+ Use `getExperimentAssignment()` when the server must know one experiment's
146
+ variant before rendering or calling an upstream service. Edge performs the full
147
+ assignment; the package never falls back to local bucketing.
148
+
149
+ ```ts
150
+ import {
151
+ generateUserToken,
152
+ getExperimentAssignment,
153
+ } from '@coframe/sdk/server'
154
+
155
+ // Next.js `request.cookies.get()` returns `{ name, value }`; other runtimes
156
+ // return the string. Either way `userToken` must be the string value.
157
+ const existingToken = request.cookies.get('coframe.user.token')?.value
158
+ const userToken = existingToken ?? generateUserToken()
159
+
160
+ if (!existingToken) {
161
+ // Do not set `httpOnly`: the browser SDK reads this cookie to reuse the
162
+ // same identity. An HttpOnly token makes the browser generate a new one and
163
+ // bucket differently, with no error.
164
+ response.cookies.set('coframe.user.token', userToken, {
165
+ path: '/',
166
+ secure: true,
167
+ sameSite: 'lax',
168
+ maxAge: 400 * 24 * 60 * 60,
169
+ })
170
+ }
171
+
172
+ const assignment = await getExperimentAssignment({
173
+ projectId: 'your-project-id',
174
+ experimentId: 'your-experiment-id',
175
+ userToken,
176
+ currentUrl: request.url,
177
+ headers: request.headers,
178
+ })
179
+
180
+ const upstreamVersion =
181
+ assignment?.variant?.slug === 'variant-2' ? 'v2' : 'v1'
182
+ ```
183
+
184
+ The package derives visitor context from an allowlist of request headers.
185
+ Explicit `userAgent`, `languages`, `browserPlatform`, `country`, `region`,
186
+ `city`, `device`, `botScore`, `verifiedBotCategory`, `clientIp`,
187
+ `userVisitCount`, and `userContext` options override the derived values. Only
188
+ the lane cookie is forwarded to Edge; authorization and application cookies are
189
+ not forwarded. `clientIp` is used only for existing blocklist targeting.
190
+
191
+ Geography, device, bot score, and client IP are read from `cf-*` request
192
+ headers. Cloudflare adds `cf-ipcountry` and `cf-connecting-ip` to every
193
+ request that reaches your origin; `cf-region-code` and `cf-ipcity` need the
194
+ "Add visitor location headers" Managed Transform, `cf-device-type` needs
195
+ "cache by device type", and `cf-bot-score` needs Bot Management plus a
196
+ Transform Rule. If your server is not behind Cloudflare, or a header is not
197
+ enabled, that value is missing: the country becomes `unknown`, country
198
+ audiences never match, and the device falls back to the user agent. Pass
199
+ `country`, `region`, `city`, and `device` explicitly in that case, from
200
+ whatever geo source your stack already has. Values are forwarded verbatim and
201
+ audiences compare them exactly, so use Cloudflare's spelling: `country` is the
202
+ uppercase ISO 3166-1 alpha-2 code (`US`, not `us`), `region` the ISO 3166-2
203
+ subdivision code (`CA`), and `device` one of `desktop`, `mobile`, `tablet`.
204
+
205
+ When a result is available, pass its project snapshot to the provider. The
206
+ browser reads the same standard token cookie and runs its existing assignment
207
+ flow without fetching project data again.
208
+
209
+ ```tsx
210
+ <CoframeProvider
211
+ projectConfig={{ projectId: 'your-project-id' }}
212
+ projectData={assignment?.projectData}
213
+ >
214
+ {children}
215
+ </CoframeProvider>
216
+ ```
217
+
218
+ `assignment` is `undefined` when the call failed (see below), so read
219
+ `projectData` with optional chaining and decide there whether the browser may
220
+ initialize on its own.
221
+
222
+ `generateUserToken()` uses Web Crypto when available and falls back to a
223
+ non-cryptographic UUID generator in server runtimes without Web Crypto. The
224
+ token is assignment identity, not an authentication credential. It remains
225
+ stable until its cookie is removed, expires, or is replaced.
226
+ The separate visit count uses the SDK's existing two-hour visit boundary. The
227
+ package only reads the visit cookies the browser SDK writes (`coframe.cvc`,
228
+ `coframe.lastVisitTime`) and never sets them, so without the browser SDK running
229
+ after consent the count stays at 1 and `returning` audiences never match.
230
+
231
+ The result contains either one `variant` or `variant: null` with a structured
232
+ reason (`experiment_not_found`; `not_assigned`, which also covers a visitor
233
+ held out of this experiment or of the whole project by a holdout and renders
234
+ the default experience in the browser too; `unsupported_experiment_type` for
235
+ CMAB targets; `preview_active` for URL previews; a newer Edge may add reasons,
236
+ so treat any reason you do not know as "no variant"). Both forms include the exact `projectData` snapshot used by Edge, so the
237
+ browser can initialize from that snapshot. The snapshot also carries the
238
+ normalized language, platform, and visit-count values used by Edge. The browser
239
+ uses those values only for its initial assignment and returns to its normal
240
+ browser-derived context for later SPA navigations. In every case without a
241
+ variant, use the default upstream experience.
242
+
243
+ The function returns `undefined` for transport, timeout (`timeoutMs`,
244
+ default 1500 ms), Edge, or response validation failures, and when
245
+ `currentUrl` carries `cf_disable`. Handle that state explicitly. Invalid options are a
246
+ programming error and throw instead: an empty `projectId`, `experimentId` or
247
+ `userToken`, a `currentUrl` that is not an absolute URL (Express and Fastify
248
+ `req.url` is a path, build the full URL first), out-of-range `languages`,
249
+ `device`, `botScore`, `timeoutMs` and `userVisitCount` values, any string
250
+ option longer than its limit (4096 characters for URLs, 2048 for the user
251
+ agent, 200 for `projectId`, 512 for `experimentId` and `userToken`), or a
252
+ `userContext` that serializes to more than 4096 bytes (`userContext is too large`). Values derived from
253
+ request headers are clamped instead. To preserve server/browser
254
+ consistency for the response, do not let the browser independently fetch project
255
+ data and assign an experiment after the server has fallen back to its default.
256
+ Applications that accept that possible mismatch may instead initialize the
257
+ browser normally as their fail-open policy.
258
+
259
+ #### What Edge cannot evaluate
260
+
261
+ Server assignment runs the same deterministic logic as the browser, with the
262
+ same token, the same project snapshot, and the same visitor context. The
263
+ browser then reaches the same bucket because every input is the same. Five
264
+ kinds of input only exist in the page, so Edge cannot evaluate them:
265
+
266
+ - **JavaScript targeting.** A `js_function` condition (custom JavaScript that
267
+ reads a page value, a cookie, `localStorage`, or the `dataLayer`) runs only
268
+ in the browser, whether it sits on an audience or on a frame's activation
269
+ condition. Edge treats it as not matched.
270
+ - If the target experiment itself uses a JavaScript audience, the result is
271
+ `variant: null` with reason `not_assigned`. Do not use JavaScript audiences
272
+ on experiments you resolve on the server.
273
+ - Experiments in the same frame are evaluated in order and the first match
274
+ wins. If a JavaScript-targeted experiment sits **ahead of the target in
275
+ the same frame**, Edge skips it and assigns the target, while the browser
276
+ can match it and skip the target. Keep JavaScript-targeted experiments out
277
+ of the target experiment's frame, or place them after the target.
278
+ - The same applies to a JavaScript-targeted experiment that shares a
279
+ **lane** with the target, wherever it sits: lane winners are drawn from
280
+ the experiments the visitor can reach, and Edge cannot tell whether that
281
+ one is reachable. Keep JavaScript-targeted experiments out of the target's
282
+ lane.
283
+ - **CMAB experiments.** A target CMAB experiment returns `variant: null` with
284
+ reason `unsupported_experiment_type`.
285
+ - **URL fragments.** A server never sees the `#fragment` of the page URL, and
286
+ the browser re-evaluates the snapshot with its full `location.href`. Frames
287
+ that use `base_url_matches` ignore query and fragment and are safe; an exact
288
+ `url` condition compares both, and a `url_regex` runs against the full
289
+ `location.href` in the browser, so pass the same URL the browser will have,
290
+ without a fragment, and avoid regexes anchored on the end of the URL.
291
+ - **Previews.** Previews are browser-only. A `coframe_variant_id` URL
292
+ parameter reaches Edge, but the published preview payloads do not name the
293
+ experiment a variant belongs to, so the server answers `variant: null` with
294
+ reason `preview_active` and the browser renders the preview from the
295
+ returned snapshot. A preview kept across navigation by the Preview Session
296
+ tool lives in the browser's `sessionStorage` and never reaches Edge; the
297
+ browser discards the snapshot and fetches its own project. Both affect
298
+ preview users only; render the default upstream experience.
299
+ - **Lane state.** Edge reads lane claims from the `coframe.lanes` cookie only.
300
+ Before cookie consent the browser keeps claims in `localStorage`, which the
301
+ server cannot see, and the server does not persist the claims Edge commits.
302
+ Laned experiments therefore agree once the browser has written the cookie,
303
+ not for a visitor without cookie consent.
304
+
305
+ Audience conditions based on URL (`base_url_matches`), country, device,
306
+ language, platform, visit type, search, time, allocation, or `userContext`
307
+ evaluate identically on both sides, as does bot cloaking. For platform, Edge derives the same
308
+ `navigator.platform` family the browser reports (a `Linux …` string on Android,
309
+ for example `Linux armv8l`), or takes an explicit `browserPlatform` option.
310
+
311
+ If the application uses a custom identity cookie, call `setUserToken()` before
312
+ browser initialization with `waitForUserToken: true`. That integration must also
313
+ keep visit and lane state aligned between the server and browser.
314
+
143
315
  ## Feature flags and remote config
144
316
 
145
317
  Browser Feature Flags use the existing public `projectId` and receive only
@@ -453,6 +625,8 @@ Modern resolution modes handle package subpath exports more reliably.
453
625
  ### `@coframe/sdk/server`
454
626
 
455
627
  - `getProjectData(options)`
628
+ - `getExperimentAssignment(options)`
629
+ - `generateUserToken()`
456
630
  - `evaluateFlags(options)`
457
631
  - `getFeatureFlag(key, options)` / `isFeatureEnabled(key, options)`
458
632
  - `getFeatureFlagPayload(key, options)` / `getRemoteConfig(key, options)`
@@ -302,9 +302,11 @@ const generateUUID = () => {
302
302
  const cryptoObject = (_a = getGlobalScope()) === null || _a === void 0 ? void 0 : _a.crypto;
303
303
  if (cryptoObject === null || cryptoObject === void 0 ? void 0 : cryptoObject.randomUUID)
304
304
  return cryptoObject.randomUUID();
305
+ const randomByte = (cryptoObject === null || cryptoObject === void 0 ? void 0 : cryptoObject.getRandomValues)
306
+ ? () => cryptoObject.getRandomValues(new Uint8Array(1))[0]
307
+ : () => Math.floor(Math.random() * 256);
305
308
  return `${1e7}${ -1e3}${ -4e3}${ -8e3}${ -1e11}`.replace(/[018]/g, (c) => {
306
- var _a, _b;
307
- const randomValue = (_b = (_a = cryptoObject === null || cryptoObject === void 0 ? void 0 : cryptoObject.getRandomValues) === null || _a === void 0 ? void 0 : _a.call(cryptoObject, new Uint8Array(1))[0]) !== null && _b !== void 0 ? _b : 0;
309
+ const randomValue = randomByte();
308
310
  return (c ^
309
311
  (randomValue & (15 >> (c / 4)))).toString(16);
310
312
  });
@@ -371,16 +373,30 @@ const getOrGenerateUserToken = () => {
371
373
  const setUserTokenCookie = (uuid, logger) => {
372
374
  setCookie(USER_TOKEN_COOKIE_KEY, uuid, logger);
373
375
  };
376
+ const VISIT_BOUNDARY_MS = 2 * 60 * 60 * 1000;
377
+ function startsNewVisit(lastVisitTime, now) {
378
+ return !lastVisitTime || now - lastVisitTime > VISIT_BOUNDARY_MS;
379
+ }
380
+ function nextVisitCount(existing, lastVisitTime, now) {
381
+ return startsNewVisit(lastVisitTime, now) ? existing + 1 : existing;
382
+ }
383
+ function parseNonNegativeInt(raw) {
384
+ if (!raw || !/^\d+$/.test(raw))
385
+ return undefined;
386
+ const parsed = Number(raw);
387
+ return parsed <= 9007199254740991 ? parsed : undefined;
388
+ }
389
+ function parseVisitCount(raw) {
390
+ var _a;
391
+ return (_a = parseNonNegativeInt(raw)) !== null && _a !== void 0 ? _a : 0;
392
+ }
374
393
  const trackVisitCookie = (logger) => {
375
- const visitCountString = getCookie(VISIT_COUNT_COOKIE_KEY);
376
- const lastVisitTime = Number.parseInt(getCookie(LAST_VISIT_TIME_KEY), 10);
394
+ const existing = parseVisitCount(getCookie(VISIT_COUNT_COOKIE_KEY));
395
+ const lastVisitTime = parseVisitCount(getCookie(LAST_VISIT_TIME_KEY));
377
396
  const currentTime = Date.now();
378
- const twoHours = 2 * 60 * 60 * 1000;
379
- if (!lastVisitTime || currentTime - lastVisitTime > twoHours) {
380
- const visitCount = visitCountString
381
- ? Number.parseInt(visitCountString, 10) + 1
382
- : 1;
383
- setCookie(VISIT_COUNT_COOKIE_KEY, visitCount.toString(), logger);
397
+ const next = nextVisitCount(existing, lastVisitTime, currentTime);
398
+ if (next !== existing) {
399
+ setCookie(VISIT_COUNT_COOKIE_KEY, next.toString(), logger);
384
400
  setCookie(LAST_VISIT_TIME_KEY, currentTime.toString(), logger);
385
401
  }
386
402
  };
@@ -2069,6 +2085,7 @@ const applyVariantChanges = (variantElements, currentUrl, logger, nonce, lifecyc
2069
2085
  ? lifecycleContext.runtime.createVariantSession({
2070
2086
  batchId: lifecycleContext.batchId,
2071
2087
  variantId: variant.id,
2088
+ experimentId: variant.experiment_id,
2072
2089
  })
2073
2090
  : null;
2074
2091
  if (!variant.modifications ||
@@ -2106,7 +2123,7 @@ const applyVariantChanges = (variantElements, currentUrl, logger, nonce, lifecyc
2106
2123
  });
2107
2124
  }
2108
2125
  else if (selectedElement instanceof HTMLElement) {
2109
- selectedElement.setAttribute('coframe-exp-id', variant.id);
2126
+ selectedElement.setAttribute('coframe-exp-id', variant.experiment_id);
2110
2127
  }
2111
2128
  }
2112
2129
  return true;
@@ -2292,6 +2309,16 @@ function murmurHash3_x86_32(key, seed) {
2292
2309
  return h1 >>> 0;
2293
2310
  }
2294
2311
 
2312
+ function parseLaneCookie(raw) {
2313
+ if (!raw)
2314
+ return {};
2315
+ try {
2316
+ return parseClaims(decodeURIComponent(raw));
2317
+ }
2318
+ catch (_a) {
2319
+ return parseClaims(raw);
2320
+ }
2321
+ }
2295
2322
  function parseClaims(raw) {
2296
2323
  if (!raw)
2297
2324
  return {};
@@ -2725,7 +2752,7 @@ function checkAllAudiences(metadata, ctx) {
2725
2752
  const audience = ctx.project.audiences[audienceId];
2726
2753
  if (!audience)
2727
2754
  continue;
2728
- const activated = checkActivationCondition(audience.selector, ctx.personalizationHeaders, ctx.currentPageUrl, ctx.languages, ctx.browserPlatform, ctx.userToken, ctx.userVisitCount, (js, args) => executeJs(js, args, { isExpression: true, nonce: ctx.nonce }), ctx.logger);
2755
+ const activated = checkActivationCondition(audience.selector, ctx.personalizationHeaders, ctx.currentPageUrl, ctx.languages, ctx.browserPlatform, ctx.userToken, ctx.userVisitCount, ctx.runJsFunction, ctx.logger);
2729
2756
  if (activated === false)
2730
2757
  return false;
2731
2758
  if (activated !== true)
@@ -2733,8 +2760,12 @@ function checkAllAudiences(metadata, ctx) {
2733
2760
  }
2734
2761
  return result;
2735
2762
  }
2736
- async function getVariantsAndElements(personalizationHeaders, project, previewVariantId, userToken, currentPageUrl, languages, browserPlatform, logger, nonce, laneClaims = {}, silentFallback = false, chooseCmabVariants) {
2737
- var _a;
2763
+ function audiencesPass(metadata, ctx) {
2764
+ const result = checkAllAudiences(metadata, ctx);
2765
+ return ctx.unknownIsInactive ? result === true : result !== false;
2766
+ }
2767
+ async function getVariantsAndElements(personalizationHeaders, project, previewVariantId, userToken, currentPageUrl, languages, browserPlatform, logger, nonce, laneClaims = {}, silentFallback = false, chooseCmabVariants, userVisitCountOverride, runtime = {}) {
2768
+ var _a, _b;
2738
2769
  const experimentsInfo = [];
2739
2770
  const cloakBot = !!((_a = project.settings) === null || _a === void 0 ? void 0 : _a.bot_cloaking_enabled) &&
2740
2771
  personalizationHeaders['x-is-bot'] === 'true';
@@ -2746,7 +2777,11 @@ async function getVariantsAndElements(personalizationHeaders, project, previewVa
2746
2777
  laneClaims: meClaims,
2747
2778
  };
2748
2779
  };
2749
- const userVisitCount = Number.parseInt(getCookie(VISIT_COUNT_COOKIE_KEY) || '0', 10);
2780
+ const userVisitCount = userVisitCountOverride !== null && userVisitCountOverride !== void 0 ? userVisitCountOverride : parseVisitCount(getCookie(VISIT_COUNT_COOKIE_KEY));
2781
+ const evaluateJsFunctions = (_b = runtime.evaluateJsFunctions) !== null && _b !== void 0 ? _b : true;
2782
+ const runJsFunction = evaluateJsFunctions
2783
+ ? (js, args) => executeJs(js, args, { isExpression: true, nonce })
2784
+ : undefined;
2750
2785
  const ctx = {
2751
2786
  project,
2752
2787
  personalizationHeaders,
@@ -2755,8 +2790,9 @@ async function getVariantsAndElements(personalizationHeaders, project, previewVa
2755
2790
  languages,
2756
2791
  browserPlatform,
2757
2792
  userVisitCount,
2758
- nonce,
2759
2793
  logger,
2794
+ runJsFunction,
2795
+ unknownIsInactive: !evaluateJsFunctions,
2760
2796
  };
2761
2797
  const validatedClaims = validateClaims(laneClaims, project.lanes);
2762
2798
  const previewVariantIds = splitPreviewVariantIds(previewVariantId);
@@ -2811,27 +2847,24 @@ function activateFrames(ctx) {
2811
2847
  return ctx.project.frames.filter((frame) => {
2812
2848
  if (!frame.experiments)
2813
2849
  return false;
2814
- const frameActivated = checkActivationCondition(frame.activation_condition, ctx.personalizationHeaders, ctx.currentPageUrl, ctx.languages, ctx.browserPlatform, ctx.userToken, ctx.userVisitCount, (js, args) => executeJs(js, args, { isExpression: true, nonce: ctx.nonce }), ctx.logger);
2815
- if (!frameActivated)
2816
- return false;
2817
- return true;
2850
+ const frameActivated = checkActivationCondition(frame.activation_condition, ctx.personalizationHeaders, ctx.currentPageUrl, ctx.languages, ctx.browserPlatform, ctx.userToken, ctx.userVisitCount, ctx.runJsFunction, ctx.logger);
2851
+ return ctx.unknownIsInactive ? frameActivated === true : !!frameActivated;
2818
2852
  });
2819
2853
  }
2820
2854
  async function assignFrames(activeFrames, validatedClaims, ctx, chooseCmabVariants) {
2821
- var _a;
2855
+ var _a, _b, _c;
2822
2856
  const { project, userToken, logger } = ctx;
2823
2857
  const experimentsInfo = [];
2824
2858
  const variantsAndElements = [];
2825
- const evalAudiences = (metadata) => checkAllAudiences(metadata, ctx) !== false;
2826
2859
  const audiencePass = new Map();
2827
2860
  const reachableByLane = {};
2828
2861
  for (const frame of activeFrames) {
2829
- for (const experiment of frame.experiments) {
2862
+ for (const experiment of (_a = frame.experiments) !== null && _a !== void 0 ? _a : []) {
2830
2863
  const metadata = experiment.metadata;
2831
2864
  const laneIds = experimentLaneIds(metadata);
2832
2865
  if (laneIds.length === 0)
2833
2866
  continue;
2834
- const passes = evalAudiences(metadata);
2867
+ const passes = audiencesPass(metadata, ctx);
2835
2868
  audiencePass.set(metadata.experiment_id, passes);
2836
2869
  if (passes) {
2837
2870
  for (const laneId of laneIds) {
@@ -2848,7 +2881,7 @@ async function assignFrames(activeFrames, validatedClaims, ctx, chooseCmabVarian
2848
2881
  let hasActivatedFrames = false;
2849
2882
  for (const frame of activeFrames) {
2850
2883
  let atLeastOneExperimentWasActivated = false;
2851
- for (const experiment of frame.experiments) {
2884
+ for (const experiment of (_b = frame.experiments) !== null && _b !== void 0 ? _b : []) {
2852
2885
  if (atLeastOneExperimentWasActivated)
2853
2886
  break;
2854
2887
  const metadata = experiment.metadata;
@@ -2858,7 +2891,7 @@ async function assignFrames(activeFrames, validatedClaims, ctx, chooseCmabVarian
2858
2891
  continue;
2859
2892
  let activated = audiencePass.get(metadata.experiment_id);
2860
2893
  if (activated === undefined) {
2861
- activated = evalAudiences(metadata);
2894
+ activated = audiencesPass(metadata, ctx);
2862
2895
  }
2863
2896
  if (!activated)
2864
2897
  continue;
@@ -2914,7 +2947,7 @@ async function assignFrames(activeFrames, validatedClaims, ctx, chooseCmabVarian
2914
2947
  try {
2915
2948
  mlVariantIds = await chooseCmabVariants(Array.from(cmabMetadataById.values()), userToken);
2916
2949
  }
2917
- catch (_b) {
2950
+ catch (_d) {
2918
2951
  mlVariantIds = new Map();
2919
2952
  }
2920
2953
  }
@@ -2925,7 +2958,7 @@ async function assignFrames(activeFrames, validatedClaims, ctx, chooseCmabVarian
2925
2958
  }
2926
2959
  const { metadata, variants, elements } = entry;
2927
2960
  let mlVariantId = metadata.type === 'CMAB'
2928
- ? ((_a = mlVariantIds.get(metadata.experiment_id)) !== null && _a !== void 0 ? _a : null)
2961
+ ? ((_c = mlVariantIds.get(metadata.experiment_id)) !== null && _c !== void 0 ? _c : null)
2929
2962
  : null;
2930
2963
  if (mlVariantId && !variants.some((v) => v.id === mlVariantId)) {
2931
2964
  logger.warn(`CMAB variant ${mlVariantId} not in experiment ${metadata.experiment_id}`, 'cmab_variant_not_found', { experiment_id: metadata.experiment_id });
@@ -2978,9 +3011,12 @@ async function previewVariants(previewVariantIds, validatedClaims, ctx, silentFa
2978
3011
  return uniqueByVariantId([...rolledOutVariants, ...requestedVariants]);
2979
3012
  }
2980
3013
  function findVariants(frames, variantIds) {
2981
- return frames.flatMap((frame) => frame.experiments.flatMap((experiment) => experiment.variants
2982
- .filter((variant) => variantIds.includes(variant.id))
2983
- .map((variant) => ({ variant, elements: frame.elements }))));
3014
+ return frames.flatMap((frame) => {
3015
+ var _a;
3016
+ return ((_a = frame.experiments) !== null && _a !== void 0 ? _a : []).flatMap((experiment) => experiment.variants
3017
+ .filter((variant) => variantIds.includes(variant.id))
3018
+ .map((variant) => ({ variant, elements: frame.elements })));
3019
+ });
2984
3020
  }
2985
3021
  function uniqueByVariantId(items) {
2986
3022
  const seen = {};
@@ -2992,10 +3028,13 @@ function uniqueByVariantId(items) {
2992
3028
  });
2993
3029
  }
2994
3030
  function keepRolledOutExperiments(frames) {
2995
- return frames.map((frame) => ({
2996
- ...frame,
2997
- experiments: frame.experiments.filter((experiment) => experiment.metadata.serving_mode === 'winner'),
2998
- }));
3031
+ return frames.map((frame) => {
3032
+ var _a;
3033
+ return ({
3034
+ ...frame,
3035
+ experiments: ((_a = frame.experiments) !== null && _a !== void 0 ? _a : []).filter((experiment) => experiment.metadata.serving_mode === 'winner'),
3036
+ });
3037
+ });
2999
3038
  }
3000
3039
 
3001
3040
  function buildSessionContext(isInitialApply) {
@@ -3666,6 +3705,7 @@ async function resolveProject(Coframe, previewVariantId, isSessionVariant, confi
3666
3705
  const queuedHeaders = getQueuedPersonalizationHeaders();
3667
3706
  let personalizationHeaders = queuedHeaders;
3668
3707
  let project = isSessionVariant ? undefined : projectData === null || projectData === void 0 ? void 0 : projectData.project;
3708
+ const assignmentContext = project ? projectData === null || projectData === void 0 ? void 0 : projectData.assignmentContext : undefined;
3669
3709
  if (!project) {
3670
3710
  const edgeResult = await fetchProject(config.projectUrl, Coframe.projectId, config.currentUrl || getCurrentPageUrl(), undefined, config.userContext, previewVariantId || undefined, undefined, (_b = config.referrer) !== null && _b !== void 0 ? _b : getBrowserReferrer());
3671
3711
  personalizationHeaders = mergeClientHeaders(queuedHeaders, edgeResult === null || edgeResult === void 0 ? void 0 : edgeResult.personalizationHeaders);
@@ -3682,6 +3722,7 @@ async function resolveProject(Coframe, previewVariantId, isSessionVariant, confi
3682
3722
  return {
3683
3723
  project,
3684
3724
  personalizationHeaders: personalizationHeaders,
3725
+ ...(assignmentContext ? { assignmentContext } : {}),
3685
3726
  };
3686
3727
  }
3687
3728
 
@@ -4159,7 +4200,7 @@ function setupListeners(Coframe, executionStartTime) {
4159
4200
  for (const variantElement of Coframe.variantsAndElements || []) {
4160
4201
  const variant = variantElement.variant;
4161
4202
  if (!experiencedCoframeIds.has(variant.id)) {
4162
- const elements = document.querySelectorAll(`[coframe-exp-id="${variant.id}"]`);
4203
+ const elements = document.querySelectorAll(`[coframe-exp-id="${variant.experiment_id}"]`);
4163
4204
  for (const el of Array.from(elements)) {
4164
4205
  if (isElementVisible(el)) {
4165
4206
  el.setAttribute('coframe-experienced', 'true');
@@ -4453,18 +4494,12 @@ function setupSPANavigation(Coframe, project, config, previewVariantId, isSessio
4453
4494
  }
4454
4495
  function readLaneClaims() {
4455
4496
  const cookie = getCookie(LANE_COOKIE_KEY);
4456
- if (cookie) {
4457
- try {
4458
- return parseClaims(decodeURIComponent(cookie));
4459
- }
4460
- catch (_a) {
4461
- return parseClaims(cookie);
4462
- }
4463
- }
4497
+ if (cookie)
4498
+ return parseLaneCookie(cookie);
4464
4499
  try {
4465
4500
  return parseClaims(localStorage.getItem(LANE_COOKIE_KEY));
4466
4501
  }
4467
- catch (_b) {
4502
+ catch (_a) {
4468
4503
  return {};
4469
4504
  }
4470
4505
  }
@@ -4479,14 +4514,17 @@ function persistLaneClaims(claims, logger) {
4479
4514
  setCookie(LANE_COOKIE_KEY, encodeURIComponent(serialized), logger);
4480
4515
  }
4481
4516
  }
4482
- function createApplyVariants(Coframe, config, previewVariantId, isSessionVariant, executionStartTime, analytics, laneStore, runtime) {
4517
+ function createApplyVariants(Coframe, config, previewVariantId, isSessionVariant, executionStartTime, analytics, laneStore, runtime, initialAssignmentContext) {
4483
4518
  let isFirstApply = true;
4519
+ let snapshotContext = initialAssignmentContext;
4484
4520
  let applicationId = 0;
4485
4521
  const apply = async (pageUrl, pageProject, personalizationHeaders, signal, deadline) => {
4486
- var _a, _b;
4522
+ var _a, _b, _c, _d;
4487
4523
  const currentApplication = ++applicationId;
4488
4524
  if (signal === null || signal === void 0 ? void 0 : signal.aborted)
4489
4525
  return 'skipped';
4526
+ const assignmentContext = snapshotContext;
4527
+ snapshotContext = undefined;
4490
4528
  const preview = resolvePreview(pageUrl, previewVariantId, isSessionVariant);
4491
4529
  const chooseCmabVariants = createCmabVariantBatchChooser({
4492
4530
  projectId: Coframe.projectId,
@@ -4497,7 +4535,7 @@ function createApplyVariants(Coframe, config, previewVariantId, isSessionVariant
4497
4535
  disableRemoteAssignment: Boolean(config.projectUrl || config.ingestUrl),
4498
4536
  userContext: config.userContext,
4499
4537
  });
4500
- const { variantsAndElements, experimentsInfo, hasActivatedFrames, laneClaims, } = await getVariantsAndElements(personalizationHeaders, pageProject, preview.previewVariantId, Coframe.userToken, pageUrl, getBrowserLanguages(), getBrowserPlatform(), Coframe.logger, config.nonce, laneStore.claims, preview.isSessionVariant, chooseCmabVariants);
4538
+ const { variantsAndElements, experimentsInfo, hasActivatedFrames, laneClaims, } = await getVariantsAndElements(personalizationHeaders, pageProject, preview.previewVariantId, Coframe.userToken, pageUrl, (_b = assignmentContext === null || assignmentContext === void 0 ? void 0 : assignmentContext.languages) !== null && _b !== void 0 ? _b : getBrowserLanguages(), (_c = assignmentContext === null || assignmentContext === void 0 ? void 0 : assignmentContext.browserPlatform) !== null && _c !== void 0 ? _c : getBrowserPlatform(), Coframe.logger, config.nonce, laneStore.claims, preview.isSessionVariant, chooseCmabVariants, assignmentContext === null || assignmentContext === void 0 ? void 0 : assignmentContext.userVisitCount);
4501
4539
  if ((signal === null || signal === void 0 ? void 0 : signal.aborted) ||
4502
4540
  currentApplication !== applicationId ||
4503
4541
  (runtime === null || runtime === void 0 ? void 0 : runtime.isDestroyed()) ||
@@ -4505,7 +4543,7 @@ function createApplyVariants(Coframe, config, previewVariantId, isSessionVariant
4505
4543
  return 'skipped';
4506
4544
  if (!signal &&
4507
4545
  runtime &&
4508
- ((_b = pageProject === null || pageProject === void 0 ? void 0 : pageProject.settings) === null || _b === void 0 ? void 0 : _b.mutation_observer_active) &&
4546
+ ((_d = pageProject === null || pageProject === void 0 ? void 0 : pageProject.settings) === null || _d === void 0 ? void 0 : _d.mutation_observer_active) &&
4509
4547
  !urlsMatch(pageUrl, getCurrentPageUrl(), true))
4510
4548
  return 'stale-route';
4511
4549
  if (!signal && runtime) {
@@ -4551,7 +4589,7 @@ function createApplyVariants(Coframe, config, previewVariantId, isSessionVariant
4551
4589
  async function modifyPage(Coframe, configResult, projectResult, analytics, lifecycle) {
4552
4590
  var _a;
4553
4591
  const { config, timeoutMs, previewVariantId, isSessionVariant, integrationStartTime, executionStartTime, } = configResult;
4554
- const { project: cdnProject, personalizationHeaders } = projectResult;
4592
+ const { project: cdnProject, personalizationHeaders, assignmentContext, } = projectResult;
4555
4593
  const revealCoordinator = (_a = lifecycle === null || lifecycle === void 0 ? void 0 : lifecycle.revealCoordinator) !== null && _a !== void 0 ? _a : createRevealCoordinator(revealPage, logRevealListenerError(() => Coframe.logger));
4556
4594
  const runtime = lifecycle === null || lifecycle === void 0 ? void 0 : lifecycle.runtime;
4557
4595
  if (runtime && config.waitForHydration) {
@@ -4577,7 +4615,7 @@ async function modifyPage(Coframe, configResult, projectResult, analytics, lifec
4577
4615
  if (!(await ensureBody(Coframe, cdnProject, timeoutMs, config.nonce, revealCoordinator, () => !(runtime === null || runtime === void 0 ? void 0 : runtime.isDestroyed()))))
4578
4616
  return;
4579
4617
  const laneStore = { claims: readLaneClaims() };
4580
- const applier = createApplyVariants(Coframe, config, previewVariantId, isSessionVariant, executionStartTime, analytics, laneStore, runtime);
4618
+ const applier = createApplyVariants(Coframe, config, previewVariantId, isSessionVariant, executionStartTime, analytics, laneStore, runtime, assignmentContext);
4581
4619
  const initialUrl = getCurrentPageUrl();
4582
4620
  if (runtime) {
4583
4621
  const runtimeUrl = runtime.getState().currentUrl;
@@ -5180,6 +5218,9 @@ function injectOptions(options) {
5180
5218
  projectData: {
5181
5219
  project: options.projectData.project,
5182
5220
  personalizationHeaders: (_a = options.projectData.personalizationHeaders) !== null && _a !== void 0 ? _a : {},
5221
+ ...(options.projectData.assignmentContext
5222
+ ? { assignmentContext: options.projectData.assignmentContext }
5223
+ : {}),
5183
5224
  },
5184
5225
  });
5185
5226
  }
@@ -5389,6 +5430,7 @@ function createVariantSession(options) {
5389
5430
  getPageState: () => ({ ...options.getPageState() }),
5390
5431
  getState: () => ({ ...state }),
5391
5432
  getVariantId: () => state.variantId,
5433
+ getExperimentId: () => options.experimentId,
5392
5434
  onPage: (event, handler) => subscribe(event, handler, false),
5393
5435
  oncePage: (event, handler) => subscribe(event, handler, true),
5394
5436
  ready() {
@@ -5748,6 +5790,7 @@ function createPageRuntime(options) {
5748
5790
  const session = createVariantSession({
5749
5791
  sessionId: createId(),
5750
5792
  variantId: input.variantId,
5793
+ experimentId: input.experimentId,
5751
5794
  batchId: batch.batchId,
5752
5795
  routeVersion: batch.routeVersion,
5753
5796
  getPageState: () => ({ ...state }),