@decocms/blocks 7.48.4 → 7.49.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.48.4",
3
+ "version": "7.49.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -56,6 +56,7 @@
56
56
  "./sdk/env": "./src/sdk/env.ts",
57
57
  "./sdk/fetchCache": "./src/sdk/fetchCache.ts",
58
58
  "./sdk/fetchTimeout": "./src/sdk/fetchTimeout.ts",
59
+ "./sdk/experiments": "./src/sdk/experiments.ts",
59
60
  "./sdk/flags": "./src/sdk/flags.ts",
60
61
  "./sdk/http": "./src/sdk/http.ts",
61
62
  "./sdk/instrumentedFetch": "./src/sdk/instrumentedFetch.ts",
@@ -1,6 +1,7 @@
1
1
  import { getMatchersOverride, getRuleOverrideId, hasMatchersOverride } from "../matchers/override";
2
2
  import { getMeter, MetricNames, withTracing } from "../middleware/observability";
3
3
  import { djb2Hex } from "../sdk/djb2";
4
+ import { stickyDecide } from "../sdk/experiments";
4
5
  import { parseSegmentCookie, SEGMENT_COOKIE, type StoredFlag, trafficToPct } from "../sdk/flags";
5
6
  import { withInflightTimeout } from "../sdk/inflightTimeout";
6
7
  import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
@@ -758,21 +759,20 @@ function evaluateVariantRule(
758
759
  // production concern (previews aren't cached), so skipping it here is fine.
759
760
  if (!meta || hasMatchersOverride(ctx)) return evaluateMatcher(rule, ctx);
760
761
 
761
- // Reuse a decision already made this request so every resolve pass (page,
762
- // shallow section-key, deferred) selects the same variant.
763
- const already = ctx.flags?.find((f) => f.name === meta.name && f.pct === meta.pct);
764
- if (already) return already.value;
765
-
766
- const stored = parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]).find(
767
- (f) => f.name === meta.name,
768
- );
769
- // pct === -1 marks a classic-deco segment without a fingerprint — honor it
770
- // (stay sticky) instead of re-rolling. A stale fingerprint re-rolls.
771
- const useStored = stored && (stored.pct === -1 || stored.pct === meta.pct);
772
- const value = useStored ? stored.value : Math.random() < meta.traffic;
773
-
774
- ctx.flags?.push({ name: meta.name, value, pct: meta.pct });
775
- return value;
762
+ // Shared with sdk/experiments.ts' N-way resolution — same reuse / stay-sticky
763
+ // / re-roll precedence, so the two assignment paths cannot drift. This path
764
+ // keeps its own `trafficToPct` fingerprint: swapping it for the experiment
765
+ // weight hash would re-roll every live visitor on the next deploy.
766
+ return stickyDecide<boolean>({
767
+ name: meta.name,
768
+ fingerprint: meta.pct,
769
+ recorded: ctx.flags,
770
+ stored: parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]),
771
+ roll: () => Math.random() < meta.traffic,
772
+ // A matcher decision is always boolean; a string here means an experiment
773
+ // took the same cookie slot. Re-roll rather than coerce a variant id.
774
+ accepts: (v) => typeof v === "boolean",
775
+ }).value;
776
776
  }
777
777
 
778
778
  // ---------------------------------------------------------------------------
@@ -0,0 +1,328 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ type ExperimentConfig,
4
+ pickWeightedVariant,
5
+ readExperimentConfig,
6
+ resolveExperimentVariant,
7
+ weightsFingerprint,
8
+ } from "./experiments";
9
+ import {
10
+ parseSegmentCookie,
11
+ type StoredFlag,
12
+ segmentCacheToken,
13
+ serializeSegmentCookie,
14
+ } from "./flags";
15
+
16
+ type Payload = { collectionId: string };
17
+
18
+ /** Contract 1's example document, verbatim. */
19
+ const CONFIG: ExperimentConfig<Payload> = {
20
+ version: 1,
21
+ experiments: [
22
+ {
23
+ key: "plp-ranking",
24
+ variants: [
25
+ { id: "control", weight: 95, payload: { collectionId: "139" } },
26
+ { id: "model-b", weight: 5, payload: { collectionId: "412" } },
27
+ ],
28
+ },
29
+ ],
30
+ };
31
+
32
+ /** Same experiment after a ramp stage advances 5% -> 10%. */
33
+ const RAMPED: ExperimentConfig<Payload> = {
34
+ version: 1,
35
+ experiments: [
36
+ {
37
+ key: "plp-ranking",
38
+ variants: [
39
+ { id: "control", weight: 90, payload: { collectionId: "139" } },
40
+ { id: "model-b", weight: 10, payload: { collectionId: "412" } },
41
+ ],
42
+ },
43
+ ],
44
+ };
45
+
46
+ /**
47
+ * One request: a cookie in, a fresh assignment sink, a fixed RNG sample.
48
+ * Mirrors what the framework threads through in production.
49
+ */
50
+ function request(
51
+ config: ExperimentConfig<Payload> | null,
52
+ cookie: string | undefined,
53
+ rand: number,
54
+ ) {
55
+ const assignments: StoredFlag[] = [];
56
+ const resolve = () =>
57
+ resolveExperimentVariant<Payload>("plp-ranking", {
58
+ config,
59
+ segmentCookie: cookie,
60
+ assignments,
61
+ random: () => rand,
62
+ });
63
+ // The cookie the framework would write back (persistFlags merges the same way).
64
+ const nextCookie = () =>
65
+ serializeSegmentCookie([
66
+ ...parseSegmentCookie(cookie).filter((f) => !assignments.some((a) => a.name === f.name)),
67
+ ...assignments,
68
+ ]);
69
+ return { resolve, assignments, nextCookie };
70
+ }
71
+
72
+ describe("pickWeightedVariant", () => {
73
+ const variants = CONFIG.experiments[0].variants;
74
+
75
+ it("splits the range in proportion to the weights", () => {
76
+ expect(pickWeightedVariant(variants, 0)).toBe("control");
77
+ expect(pickWeightedVariant(variants, 0.94)).toBe("control");
78
+ expect(pickWeightedVariant(variants, 0.96)).toBe("model-b");
79
+ // 0.9999 must not fall off the end into `undefined`.
80
+ expect(pickWeightedVariant(variants, 0.9999)).toBe("model-b");
81
+ });
82
+
83
+ it("holds the distribution over many draws", () => {
84
+ let modelB = 0;
85
+ for (let i = 0; i < 10_000; i++) {
86
+ if (pickWeightedVariant(variants, i / 10_000) === "model-b") modelB++;
87
+ }
88
+ expect(modelB / 10_000).toBeCloseTo(0.05, 2);
89
+ });
90
+
91
+ it("handles N > 2 arms", () => {
92
+ const three = [
93
+ { id: "a", weight: 50, payload: {} },
94
+ { id: "b", weight: 30, payload: {} },
95
+ { id: "c", weight: 20, payload: {} },
96
+ ];
97
+ expect(pickWeightedVariant(three, 0.1)).toBe("a");
98
+ expect(pickWeightedVariant(three, 0.6)).toBe("b");
99
+ expect(pickWeightedVariant(three, 0.9)).toBe("c");
100
+ });
101
+
102
+ it("still splits proportionally when weights violate the sum-to-100 contract", () => {
103
+ const bad = [
104
+ { id: "a", weight: 1, payload: {} },
105
+ { id: "b", weight: 1, payload: {} },
106
+ ];
107
+ expect(pickWeightedVariant(bad, 0.25)).toBe("a");
108
+ expect(pickWeightedVariant(bad, 0.75)).toBe("b");
109
+ });
110
+ });
111
+
112
+ describe("weightsFingerprint", () => {
113
+ it("changes when a weight changes", () => {
114
+ expect(weightsFingerprint(CONFIG.experiments[0].variants)).not.toBe(
115
+ weightsFingerprint(RAMPED.experiments[0].variants),
116
+ );
117
+ });
118
+
119
+ it("is stable under variant reordering", () => {
120
+ const reversed = [...CONFIG.experiments[0].variants].reverse();
121
+ expect(weightsFingerprint(reversed)).toBe(weightsFingerprint(CONFIG.experiments[0].variants));
122
+ });
123
+
124
+ it("never collides with the legacy -1 sentinel", () => {
125
+ expect(weightsFingerprint(CONFIG.experiments[0].variants)).toBeGreaterThanOrEqual(0);
126
+ });
127
+ });
128
+
129
+ describe("resolveExperimentVariant — first assignment", () => {
130
+ it("assigns a fresh visitor and reports isFresh", async () => {
131
+ const { resolve, assignments } = request(CONFIG, undefined, 0.99);
132
+ const variant = await resolve();
133
+
134
+ expect(variant).toEqual({
135
+ experimentKey: "plp-ranking",
136
+ variantId: "model-b",
137
+ payload: { collectionId: "412" },
138
+ isFresh: true,
139
+ });
140
+ expect(assignments).toEqual([
141
+ {
142
+ name: "plp-ranking",
143
+ value: "model-b",
144
+ pct: weightsFingerprint(CONFIG.experiments[0].variants),
145
+ },
146
+ ]);
147
+ });
148
+
149
+ it("routes the majority of fresh visitors to the heavy arm", async () => {
150
+ const { resolve } = request(CONFIG, undefined, 0.5);
151
+ expect((await resolve())?.variantId).toBe("control");
152
+ });
153
+
154
+ it("returns null when no config is published", async () => {
155
+ expect(await request(null, undefined, 0.5).resolve()).toBeNull();
156
+ });
157
+
158
+ it("returns null when the key is not among the active experiments", async () => {
159
+ const other: ExperimentConfig<Payload> = { version: 1, experiments: [] };
160
+ expect(await resolveExperimentVariant<Payload>("plp-ranking", { config: other })).toBeNull();
161
+ });
162
+
163
+ it("resolves once per request even when several loaders ask", async () => {
164
+ const { resolve, assignments } = request(CONFIG, undefined, 0.99);
165
+ const [a, b] = [await resolve(), await resolve()];
166
+
167
+ expect(b?.variantId).toBe(a?.variantId);
168
+ expect(b?.isFresh).toBe(false); // only the first call assigned
169
+ expect(assignments).toHaveLength(1);
170
+ });
171
+ });
172
+
173
+ describe("resolveExperimentVariant — stickiness across requests", () => {
174
+ it("re-serves the stored variant without re-rolling", async () => {
175
+ const first = request(CONFIG, undefined, 0.99);
176
+ expect((await first.resolve())?.variantId).toBe("model-b");
177
+ const cookie = first.nextCookie();
178
+
179
+ // Next request: an RNG sample that WOULD have drawn "control".
180
+ const second = request(CONFIG, cookie, 0.1);
181
+ const variant = await second.resolve();
182
+
183
+ expect(variant?.variantId).toBe("model-b");
184
+ expect(variant?.payload).toEqual({ collectionId: "412" });
185
+ expect(variant?.isFresh).toBe(false);
186
+ });
187
+
188
+ it("stays sticky over many requests with adversarial RNG", async () => {
189
+ let cookie = request(CONFIG, undefined, 0.99).nextCookie();
190
+ const first = request(CONFIG, undefined, 0.99);
191
+ await first.resolve();
192
+ cookie = first.nextCookie();
193
+
194
+ for (const rand of [0, 0.2, 0.5, 0.94, 0.999]) {
195
+ const req = request(CONFIG, cookie, rand);
196
+ expect((await req.resolve())?.variantId).toBe("model-b");
197
+ cookie = req.nextCookie();
198
+ }
199
+ });
200
+
201
+ it("honours a classic-deco cookie that carries no fingerprint", async () => {
202
+ // pct absent -> parses as -1 -> must stick, not churn every legacy visitor.
203
+ const legacy = btoa(encodeURIComponent(JSON.stringify({ exp: { "plp-ranking": "model-b" } })));
204
+ const variant = await request(CONFIG, legacy, 0.1).resolve();
205
+
206
+ expect(variant?.variantId).toBe("model-b");
207
+ expect(variant?.isFresh).toBe(false);
208
+ });
209
+
210
+ it("re-rolls a visitor holding a variant that no longer exists", async () => {
211
+ const stale = serializeSegmentCookie([
212
+ {
213
+ name: "plp-ranking",
214
+ value: "model-z",
215
+ pct: weightsFingerprint(CONFIG.experiments[0].variants),
216
+ },
217
+ ]);
218
+ const variant = await request(CONFIG, stale, 0.1).resolve();
219
+
220
+ expect(variant?.variantId).toBe("control");
221
+ expect(variant?.isFresh).toBe(true);
222
+ });
223
+ });
224
+
225
+ describe("resolveExperimentVariant — re-roll once, then stick, when weights change", () => {
226
+ it("re-rolls on the ramp and re-sticks on the new weights", async () => {
227
+ // Assigned under 95/5.
228
+ const first = request(CONFIG, undefined, 0.99);
229
+ expect((await first.resolve())?.variantId).toBe("model-b");
230
+ const beforeRamp = first.nextCookie();
231
+
232
+ // Ramp to 90/10: the stored fingerprint goes stale -> exactly one re-roll.
233
+ const rerolled = request(RAMPED, beforeRamp, 0.5);
234
+ const afterRamp = await rerolled.resolve();
235
+ expect(afterRamp?.isFresh).toBe(true);
236
+ expect(afterRamp?.variantId).toBe("control"); // 0.5 lands in the 90% arm
237
+ const afterRampCookie = rerolled.nextCookie();
238
+
239
+ // Every subsequent request sticks — no second re-roll.
240
+ for (const rand of [0.99, 0.01, 0.5]) {
241
+ const req = request(RAMPED, afterRampCookie, rand);
242
+ const variant = await req.resolve();
243
+ expect(variant?.variantId).toBe("control");
244
+ expect(variant?.isFresh).toBe(false);
245
+ }
246
+ });
247
+
248
+ it("writes the new fingerprint so the re-roll cannot repeat", async () => {
249
+ const first = request(CONFIG, undefined, 0.99);
250
+ await first.resolve();
251
+
252
+ const rerolled = request(RAMPED, first.nextCookie(), 0.5);
253
+ await rerolled.resolve();
254
+
255
+ expect(rerolled.assignments[0].pct).toBe(weightsFingerprint(RAMPED.experiments[0].variants));
256
+ });
257
+ });
258
+
259
+ describe("deco_segment carries the assignment (contract 4)", () => {
260
+ it("stores the variant id under the experiment key", async () => {
261
+ const req = request(CONFIG, undefined, 0.99);
262
+ await req.resolve();
263
+
264
+ const payload = JSON.parse(decodeURIComponent(atob(req.nextCookie())));
265
+ expect(payload.exp).toEqual({ "plp-ranking": "model-b" });
266
+ // Boolean buckets stay untouched, so analytics reading `active` is unaffected.
267
+ expect(payload.active).toEqual([]);
268
+ });
269
+
270
+ it("coexists with a CMS boolean flag in the same cookie", async () => {
271
+ const withFlag = serializeSegmentCookie([{ name: "TestHero", value: true, pct: 50 }]);
272
+ const req = request(CONFIG, withFlag, 0.99);
273
+ await req.resolve();
274
+
275
+ const payload = JSON.parse(decodeURIComponent(atob(req.nextCookie())));
276
+ expect(payload.active).toEqual(["TestHero"]);
277
+ expect(payload.exp).toEqual({ "plp-ranking": "model-b" });
278
+ expect(parseSegmentCookie(req.nextCookie())).toEqual(
279
+ expect.arrayContaining([
280
+ { name: "TestHero", value: true, pct: 50 },
281
+ { name: "plp-ranking", value: "model-b", pct: expect.any(Number) },
282
+ ]),
283
+ );
284
+ });
285
+
286
+ it("folds into the CDN cache key so two variants never share cached HTML", async () => {
287
+ const a = request(CONFIG, undefined, 0.99);
288
+ const b = request(CONFIG, undefined, 0.1);
289
+ await a.resolve();
290
+ await b.resolve();
291
+
292
+ const tokenA = segmentCacheToken(parseSegmentCookie(a.nextCookie()));
293
+ const tokenB = segmentCacheToken(parseSegmentCookie(b.nextCookie()));
294
+ expect(tokenA).not.toBe(tokenB);
295
+ });
296
+
297
+ it("keeps the cookie byte-identical to today when no experiment is assigned", () => {
298
+ const flags: StoredFlag[] = [{ name: "TestHero", value: true, pct: 50 }];
299
+ const payload = JSON.parse(decodeURIComponent(atob(serializeSegmentCookie(flags))));
300
+ expect("exp" in payload).toBe(false);
301
+ });
302
+ });
303
+
304
+ describe("readExperimentConfig", () => {
305
+ const kv = (value: unknown) => ({ get: async () => value as never });
306
+
307
+ it("reads contract 1's document", async () => {
308
+ expect(await readExperimentConfig(kv(CONFIG), "www.farmrio.com")).toEqual(CONFIG);
309
+ });
310
+
311
+ it("returns null instead of throwing when the binding is missing", async () => {
312
+ expect(await readExperimentConfig(undefined, "www.farmrio.com")).toBeNull();
313
+ });
314
+
315
+ it("returns null on an unset key or a malformed document", async () => {
316
+ expect(await readExperimentConfig(kv(null), "www.farmrio.com")).toBeNull();
317
+ expect(await readExperimentConfig(kv({ version: 1 }), "www.farmrio.com")).toBeNull();
318
+ });
319
+
320
+ it("returns null when KV itself fails — never a thrown request", async () => {
321
+ const broken = {
322
+ get: async () => {
323
+ throw new Error("KV unavailable");
324
+ },
325
+ };
326
+ expect(await readExperimentConfig(broken, "www.farmrio.com")).toBeNull();
327
+ });
328
+ });
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Generic variant resolution — sticky, self-healing, N-way, and independent of
3
+ * where the variant list comes from.
4
+ *
5
+ * ## What this is
6
+ *
7
+ * Two layers, deliberately separable:
8
+ *
9
+ * 1. {@link stickyDecide} — the assignment *policy*, extracted from
10
+ * `cms/resolve.ts`'s `evaluateVariantRule`: reuse the decision already made
11
+ * this request, else honour the `deco_segment` cookie while its fingerprint
12
+ * still matches, else roll fresh. Pure, synchronous, no CMS, no KV, no
13
+ * cookies. The CMS multivariate path and this module both call it, so the
14
+ * two cannot drift.
15
+ * 2. {@link resolveExperimentVariant} — the N-way experiment on top of it:
16
+ * reads config from KV, draws from a weight vector, records the assignment
17
+ * for the framework to persist into `deco_segment`.
18
+ *
19
+ * ## Self-healing re-roll
20
+ *
21
+ * The stored fingerprint is a hash of the *weight vector*. Change a weight (a
22
+ * ramp stage advancing 5% to 10%) and every stored assignment's fingerprint
23
+ * goes stale, so each visitor is re-rolled exactly once and then re-sticks on
24
+ * the new weights. The control plane never has to know this mechanic exists —
25
+ * hence "no fingerprint field" in the published config.
26
+ *
27
+ * ## Why not `sdk/abTesting.ts`
28
+ *
29
+ * That is a binary `"worker" | "fallback"` whole-request proxy for the
30
+ * migration period. Its bucket type, cookie, and fallback machinery are all
31
+ * two-way-specific. Only its `kv.get<T>(key, "json")` shape is borrowed here.
32
+ */
33
+
34
+ import { djb2 } from "./djb2";
35
+ import { parseSegmentCookie, SEGMENT_COOKIE, type StoredFlag } from "./flags";
36
+ import { getRuntimeEnv } from "./otelAdapters";
37
+ import { RequestContext } from "./requestContext";
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Layer 1 — the extracted sticky/re-roll core
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /** Outcome of a sticky decision. `isFresh` is true when it was rolled now. */
44
+ export interface StickyDecision<V> {
45
+ value: V;
46
+ isFresh: boolean;
47
+ }
48
+
49
+ /**
50
+ * Sticky assignment with self-healing re-roll, over any value type.
51
+ *
52
+ * Precedence: a decision already recorded this request (so every resolve pass
53
+ * agrees) then the stored decision, if its fingerprint still matches, then a
54
+ * fresh roll. `fingerprint === -1` on the stored decision marks a classic-deco
55
+ * segment written without the blocks extension: honour it rather than
56
+ * re-rolling, or every legacy visitor churns on first contact.
57
+ *
58
+ * @param name Cohort identity — matcher block name or experiment key.
59
+ * @param fingerprint Current config fingerprint; a mismatch re-rolls once.
60
+ * @param recorded Decisions already made this request (mutated: the new one
61
+ * is pushed onto it). Undefined disables both dedupe and
62
+ * persistence.
63
+ * @param stored Decisions parsed from the visitor's cookie.
64
+ * @param roll Draws a fresh value. Called at most once.
65
+ * @param accepts Guards a stored value that is no longer valid — e.g. a
66
+ * variant deleted from the config. Rejected means re-roll.
67
+ */
68
+ export function stickyDecide<V extends boolean | string>({
69
+ name,
70
+ fingerprint,
71
+ recorded,
72
+ stored,
73
+ roll,
74
+ accepts,
75
+ }: {
76
+ name: string;
77
+ fingerprint: number;
78
+ recorded: StoredFlag[] | undefined;
79
+ stored: StoredFlag[];
80
+ roll: () => V;
81
+ accepts?: (value: V) => boolean;
82
+ }): StickyDecision<V> {
83
+ const already = recorded?.find((f) => f.name === name && f.pct === fingerprint);
84
+ if (already) return { value: already.value as V, isFresh: false };
85
+
86
+ const prev = stored.find((f) => f.name === name);
87
+ const usable =
88
+ prev !== undefined &&
89
+ (prev.pct === -1 || prev.pct === fingerprint) &&
90
+ (!accepts || accepts(prev.value as V));
91
+
92
+ const value = usable ? (prev.value as V) : roll();
93
+ recorded?.push({ name, value, pct: fingerprint });
94
+ return { value, isFresh: !usable };
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Layer 2 — experiments
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /** One arm of an experiment, as published by the control plane. */
102
+ export interface ExperimentVariant<P = unknown> {
103
+ id: string;
104
+ /** Integer share of traffic. Weights across an experiment must sum to 100. */
105
+ weight: number;
106
+ /** Opaque to the runtime — forwarded to the caller untouched. */
107
+ payload: P;
108
+ }
109
+
110
+ /** An active experiment. */
111
+ export interface ExperimentDefinition<P = unknown> {
112
+ key: string;
113
+ variants: ExperimentVariant<P>[];
114
+ }
115
+
116
+ /**
117
+ * Frozen contract 1 — the document written to KV by the control plane's
118
+ * `EXPERIMENT_ACTIVE_PUBLISH`, keyed by hostname. Only *active* experiments
119
+ * appear: status and time-window logic live in the control plane, so the
120
+ * runtime does no date math and parses no status.
121
+ */
122
+ export interface ExperimentConfig<P = unknown> {
123
+ version: number;
124
+ experiments: ExperimentDefinition<P>[];
125
+ }
126
+
127
+ /** Frozen contract 2 — what a caller gets back. */
128
+ export interface ResolvedVariant<P = unknown> {
129
+ experimentKey: string;
130
+ variantId: string;
131
+ payload: P;
132
+ /** True when assigned on this request — a first visit or a re-roll. */
133
+ isFresh: boolean;
134
+ }
135
+
136
+ /** The slice of a KV namespace this module uses (mirrors `abTesting.ts`). */
137
+ interface ExperimentKV {
138
+ get<T>(key: string, type: "json"): Promise<T | null>;
139
+ }
140
+
141
+ /** Default Workers binding holding the published config. */
142
+ export const EXPERIMENTS_KV_BINDING = "EXPERIMENTS_KV";
143
+
144
+ /** Bag key for the per-request config read, so N loaders share one KV get. */
145
+ const CONFIG_BAG_KEY = "deco:experiments:config";
146
+ /** Bag key for assignments made this request, drained by the cookie writer. */
147
+ const ASSIGNMENTS_BAG_KEY = "deco:experiments:assignments";
148
+
149
+ /**
150
+ * Fingerprint of a weight vector. Order-independent (the control plane may
151
+ * reorder variants without meaning to re-roll anyone) and never `-1`, which is
152
+ * reserved for "legacy cookie, no fingerprint".
153
+ */
154
+ export function weightsFingerprint(variants: ExperimentVariant[]): number {
155
+ const vector = [...variants]
156
+ .map((v) => `${v.id}:${v.weight}`)
157
+ .sort()
158
+ .join("|");
159
+ return djb2(vector) % 1_000_000;
160
+ }
161
+
162
+ /**
163
+ * Draw a variant id from the weight vector. `rand` is a 0-1 sample.
164
+ *
165
+ * Weights are treated as shares of their own total rather than of a hardcoded
166
+ * 100, so a config that violates the sum-to-100 contract still splits traffic
167
+ * in the intended proportions instead of collapsing onto the first arm.
168
+ */
169
+ export function pickWeightedVariant(variants: ExperimentVariant[], rand: number): string {
170
+ const total = variants.reduce((sum, v) => sum + Math.max(0, v.weight), 0);
171
+ if (total <= 0) return variants[0].id;
172
+
173
+ let cursor = rand * total;
174
+ for (const v of variants) {
175
+ cursor -= Math.max(0, v.weight);
176
+ if (cursor < 0) return v.id;
177
+ }
178
+ return variants[variants.length - 1].id;
179
+ }
180
+
181
+ /**
182
+ * Read the published config for a hostname. Mirrors `abTesting.ts`'s
183
+ * `kv.get<T>(key, "json")` shape. Returns null when the binding is absent, the
184
+ * key is unset, or the document is unusable — every one of which must mean
185
+ * "no experiment", never a thrown request.
186
+ */
187
+ export async function readExperimentConfig<P = unknown>(
188
+ kv: ExperimentKV | undefined,
189
+ hostname: string,
190
+ ): Promise<ExperimentConfig<P> | null> {
191
+ if (!kv) return null;
192
+ try {
193
+ const config = await kv.get<ExperimentConfig<P>>(hostname, "json");
194
+ return Array.isArray(config?.experiments) ? config : null;
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Explicit inputs, for tests and for callers outside a request context.
202
+ * Omitted fields fall back to the ambient {@link RequestContext} + Workers env.
203
+ */
204
+ export interface ExperimentContext<P = unknown> {
205
+ /** Skips the KV read entirely when provided. */
206
+ config?: ExperimentConfig<P> | null;
207
+ kv?: ExperimentKV;
208
+ hostname?: string;
209
+ /** Raw `deco_segment` cookie value from the request. */
210
+ segmentCookie?: string;
211
+ /** Assignment sink; the cookie writer drains it. */
212
+ assignments?: StoredFlag[];
213
+ /** Injectable RNG — tests pass a fixed sample. */
214
+ random?: () => number;
215
+ }
216
+
217
+ /**
218
+ * Resolve this visitor's variant for `experimentKey`.
219
+ *
220
+ * Returns null when no active experiment matches — no config published, the
221
+ * key absent, or the experiment has no variants. Callers must behave exactly
222
+ * as they do today on null.
223
+ *
224
+ * The assignment is recorded for persistence into the existing `deco_segment`
225
+ * cookie under `experimentKey` (frozen contract 4); **the caller does not set
226
+ * cookies itself**. Because it lands in that cookie, the CDN cache-key folding
227
+ * already wired in `tanstack/sdk/workerEntry.ts` (`segmentCacheToken` to
228
+ * `__abf`) covers it with no change, and analytics that already read
229
+ * `deco_segment` see it for free.
230
+ *
231
+ * @example
232
+ * ```ts
233
+ * const variant = await resolveExperimentVariant<{ collectionId: string }>("plp-ranking");
234
+ * if (!variant) return vtexProductListingPage(props, req, ctx); // unchanged path
235
+ * props.selectedFacets = [
236
+ * ...(props.selectedFacets ?? []),
237
+ * { key: "productClusterIds", value: variant.payload.collectionId },
238
+ * ];
239
+ * ```
240
+ */
241
+ export async function resolveExperimentVariant<P = unknown>(
242
+ experimentKey: string,
243
+ ctx: ExperimentContext<P> = {},
244
+ ): Promise<ResolvedVariant<P> | null> {
245
+ const config = ctx.config !== undefined ? ctx.config : await loadConfig<P>(ctx);
246
+ const experiment = config?.experiments.find((e) => e.key === experimentKey);
247
+ if (!experiment?.variants?.length) return null;
248
+
249
+ const stored = parseSegmentCookie(ctx.segmentCookie ?? ambientSegmentCookie());
250
+ const random = ctx.random ?? Math.random;
251
+ const { value: variantId, isFresh } = stickyDecide<string>({
252
+ name: experimentKey,
253
+ fingerprint: weightsFingerprint(experiment.variants),
254
+ recorded: ctx.assignments ?? ambientAssignments(),
255
+ stored,
256
+ roll: () => pickWeightedVariant(experiment.variants, random()),
257
+ // A variant retired mid-flight leaves visitors holding a dead id. Re-roll
258
+ // them onto a live arm instead of returning a payload that no longer exists.
259
+ accepts: (id) => experiment.variants.some((v) => v.id === id),
260
+ });
261
+
262
+ const variant = experiment.variants.find((v) => v.id === variantId) ?? experiment.variants[0];
263
+ return { experimentKey, variantId: variant.id, payload: variant.payload, isFresh };
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Ambient request wiring
268
+ // ---------------------------------------------------------------------------
269
+
270
+ /**
271
+ * One KV read per request, shared by every caller, memoised on the in-flight
272
+ * promise so concurrent section loaders coalesce instead of racing.
273
+ */
274
+ function loadConfig<P>(ctx: ExperimentContext<P>): Promise<ExperimentConfig<P> | null> {
275
+ const kv = ctx.kv ?? (getRuntimeEnv()?.[EXPERIMENTS_KV_BINDING] as ExperimentKV | undefined);
276
+ const hostname = ctx.hostname ?? ambientHostname();
277
+ if (!kv || !hostname) return Promise.resolve(null);
278
+
279
+ const cached = RequestContext.getBag<Promise<ExperimentConfig<P> | null>>(CONFIG_BAG_KEY);
280
+ if (cached) return cached;
281
+
282
+ const pending = readExperimentConfig<P>(kv, hostname);
283
+ RequestContext.setBag(CONFIG_BAG_KEY, pending);
284
+ return pending;
285
+ }
286
+
287
+ function ambientHostname(): string | undefined {
288
+ const request = RequestContext.current?.request;
289
+ return request ? new URL(request.url).hostname : undefined;
290
+ }
291
+
292
+ function ambientSegmentCookie(): string | undefined {
293
+ const cookies = RequestContext.current?.request?.headers.get("cookie");
294
+ if (!cookies) return undefined;
295
+ const match = cookies.match(new RegExp(`(?:^|;\\s*)${SEGMENT_COOKIE}=([^;]+)`));
296
+ return match?.[1];
297
+ }
298
+
299
+ function ambientAssignments(): StoredFlag[] | undefined {
300
+ if (!RequestContext.current) return undefined;
301
+ const existing = RequestContext.getBag<StoredFlag[]>(ASSIGNMENTS_BAG_KEY);
302
+ if (existing) return existing;
303
+ const fresh: StoredFlag[] = [];
304
+ RequestContext.setBag(ASSIGNMENTS_BAG_KEY, fresh);
305
+ return fresh;
306
+ }
307
+
308
+ /**
309
+ * Assignments recorded this request, for the framework's cookie writer.
310
+ * Empty when nothing was assigned — callers must not re-issue the cookie then.
311
+ */
312
+ export function takeExperimentAssignments(): StoredFlag[] {
313
+ return RequestContext.getBag<StoredFlag[]>(ASSIGNMENTS_BAG_KEY) ?? [];
314
+ }
package/src/sdk/flags.ts CHANGED
@@ -23,6 +23,12 @@
23
23
  * changes `traffic` and redeploys, the fingerprint no longer matches and the
24
24
  * visitor is re-rolled once, then re-sticks — same self-healing scheme as
25
25
  * {@link ./abTesting.ts}.
26
+ * - `exp` — `{ experimentKey: variantId }`, the N-way assignments written by
27
+ * {@link ./experiments.ts}. `active`/`inactiveDrawn` are boolean buckets and
28
+ * physically cannot hold a variant id, so string-valued decisions ride in
29
+ * their own map. It is deliberately the same shape Deco Analytics consumes,
30
+ * so pointing the collector at it later is a read, not a translation.
31
+ * Their re-roll fingerprints share the `pct` map, keyed by experiment key.
26
32
  *
27
33
  * The cookie MUST be written un-encoded (raw base64) — OneDollarStats reads it
28
34
  * with `atob()` directly. Base64's `+ / =` are all valid cookie-value octets
@@ -35,11 +41,19 @@ export const SEGMENT_COOKIE = "deco_segment";
35
41
  /** A recorded flag decision: which named flag, the branch taken, and the
36
42
  * `traffic` fingerprint (0–100) it was decided under. */
37
43
  export interface StoredFlag {
38
- /** Matcher block name, e.g. "TestHero". Stable identity for the cohort. */
44
+ /** Matcher block name, e.g. "TestHero", or an experiment key, e.g.
45
+ * "plp-ranking". Stable identity for the cohort. */
39
46
  name: string;
40
- /** The branch the visitor was assigned to. */
41
- value: boolean;
42
- /** `round(traffic * 100)` at assignment time — the re-roll fingerprint. */
47
+ /**
48
+ * The branch the visitor was assigned to. A boolean for CMS multivariate
49
+ * matchers (the historical case); a variant id for N-way experiments.
50
+ */
51
+ value: boolean | string;
52
+ /**
53
+ * The fingerprint this decision was made under — `round(traffic * 100)` for
54
+ * a CMS matcher, a weight-vector hash for an experiment. A decision whose
55
+ * stored fingerprint no longer matches the current one is re-rolled once.
56
+ */
43
57
  pct: number;
44
58
  }
45
59
 
@@ -49,6 +63,8 @@ interface DecoSegment {
49
63
  inactiveDrawn?: string[];
50
64
  /** blocks extension: per-flag traffic fingerprint. Ignored by analytics. */
51
65
  pct?: Record<string, number>;
66
+ /** blocks extension: N-way experiment assignments, `{ key: variantId }`. */
67
+ exp?: Record<string, string>;
52
68
  }
53
69
 
54
70
  /** Convert a 0–1 traffic ratio to the 0–100 integer fingerprint. */
@@ -75,6 +91,9 @@ export function parseSegmentCookie(raw: string | undefined | null): StoredFlag[]
75
91
  for (const name of seg.inactiveDrawn ?? []) {
76
92
  out.push({ name, value: false, pct: pct[name] ?? -1 });
77
93
  }
94
+ for (const [name, variantId] of Object.entries(seg.exp ?? {})) {
95
+ out.push({ name, value: variantId, pct: pct[name] ?? -1 });
96
+ }
78
97
  return out;
79
98
  } catch {
80
99
  return [];
@@ -86,23 +105,34 @@ export function serializeSegmentCookie(flags: StoredFlag[]): string {
86
105
  const active: string[] = [];
87
106
  const inactiveDrawn: string[] = [];
88
107
  const pct: Record<string, number> = {};
108
+ const exp: Record<string, string> = {};
89
109
  for (const f of [...flags].sort((a, b) => a.name.localeCompare(b.name))) {
90
- (f.value ? active : inactiveDrawn).push(f.name);
110
+ if (typeof f.value === "string") {
111
+ exp[f.name] = f.value;
112
+ } else {
113
+ (f.value ? active : inactiveDrawn).push(f.name);
114
+ }
91
115
  pct[f.name] = f.pct;
92
116
  }
93
117
  const seg: DecoSegment = { active, inactiveDrawn, pct };
118
+ // Omit `exp` entirely when unused so the cookie stays byte-identical to what
119
+ // sites emit today — an added empty map would re-issue every visitor's cookie
120
+ // on deploy and, via segmentCacheToken, cold-start every edge cache entry.
121
+ if (Object.keys(exp).length) seg.exp = exp;
94
122
  return btoa(encodeURIComponent(JSON.stringify(seg)));
95
123
  }
96
124
 
97
125
  /**
98
126
  * Stable token for the cache key — includes the `pct` fingerprint so a
99
- * `traffic` change lands cohorts in fresh buckets. Empty string when there are
100
- * no flags, so non-A/B pages keep sharing one entry.
127
+ * `traffic` (or experiment weight) change lands cohorts in fresh buckets.
128
+ * Empty string when there are no flags, so non-A/B pages keep sharing one
129
+ * entry. Experiment assignments fold in on the same footing as boolean flags:
130
+ * without that, two visitors on different variants would share cached HTML.
101
131
  */
102
132
  export function segmentCacheToken(flags: StoredFlag[]): string {
103
133
  if (!flags.length) return "";
104
134
  return [...flags]
105
135
  .sort((a, b) => a.name.localeCompare(b.name))
106
- .map((f) => `${f.name}:${f.value ? "1" : "0"}:${f.pct}`)
136
+ .map((f) => `${f.name}:${typeof f.value === "string" ? f.value : f.value ? "1" : "0"}:${f.pct}`)
107
137
  .join(",");
108
138
  }