@shopkit/ab 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # @shopkit/ab
2
+
3
+ Section-level A/B testing for the storefront edge.
4
+
5
+ Answers one question per request: **does this URL belong to a running
6
+ experiment, and if so, which arm is this visitor in?**
7
+
8
+ Everything else — authoring variants, setting the split, promoting a winner —
9
+ happens in the visual editor and the admin dashboard.
10
+
11
+ ## Constraints this package is built to
12
+
13
+ It runs in Next.js **middleware**, on the request path, for every merchant —
14
+ including the ones who never use A/B. So:
15
+
16
+ - **No React, no DOM, no dependencies.** ~2.6 kB in the edge bundle.
17
+ - **Nothing throws.** Every public function has a safe return.
18
+ - **Fails open.** A broken backend degrades A/B testing and nothing else.
19
+
20
+ ## Usage
21
+
22
+ ```ts
23
+ import {
24
+ getManifest, matchExperiment, isExcludedPath,
25
+ assignArm, readArmCookie, serialiseArmCookie, selectionFor,
26
+ ARM_COOKIE, ARM_COOKIE_MAX_AGE_S,
27
+ } from "@shopkit/ab";
28
+
29
+ try {
30
+ if (isExcludedPath(pathname)) return response;
31
+
32
+ const manifest = await getManifest({ baseUrl, themeId });
33
+ const exp = matchExperiment(pathname, manifest);
34
+ if (!exp) return response;
35
+
36
+ const stored = readArmCookie(request.cookies.get(ARM_COOKIE)?.value);
37
+ const arm = assignArm(exp, stored);
38
+
39
+ response.cookies.set(ARM_COOKIE, serialiseArmCookie({ ...stored, [exp.id]: arm }), {
40
+ maxAge: ARM_COOKIE_MAX_AGE_S, path: "/", sameSite: "lax",
41
+ });
42
+
43
+ if (arm === "a") return response; // no rewrite at all
44
+ return NextResponse.rewrite(new URL(`${pathname}/variant/b`, request.url));
45
+ } catch {
46
+ // Never break a request over an experiment.
47
+ }
48
+ ```
49
+
50
+ `arm === "a"` returns with **no rewrite** — existing route, existing cache
51
+ entry, byte-identical to a storefront without this feature.
52
+
53
+ ## Behaviour worth knowing
54
+
55
+ | | |
56
+ | --- | --- |
57
+ | Manifest TTL | 30s per pod, matching the endpoint's `s-maxage=30` |
58
+ | Timeout | 250ms, then the request proceeds as "no experiments" |
59
+ | Failure backoff | 5s — a down backend costs one attempt per window, not one per request |
60
+ | Concurrent misses | Coalesced to a single fetch |
61
+ | Assignment | Stored in one cookie, read **before** the split is consulted |
62
+
63
+ That last row is the rule the dashboard promises merchants: **changing the split
64
+ affects new visitors only.** A visitor already on Variant B stays there when the
65
+ merchant ramps 25% → 50%.
66
+
67
+ ## Not in scope
68
+
69
+ Rendering. This package never sees page content and does not know what a variant
70
+ contains — `selectionFor()` returns `{ [sectionId]: arm }` for
71
+ `applySectionVariants` in `@shopkit/builder` to apply.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Section-level A/B testing — the shapes the edge works with.
3
+ *
4
+ * Read on the storefront request path: no React, no DOM, no dependencies.
5
+ */
6
+ /** Arm "a" IS Variant A, "b" IS Variant B — no mapping stored, so none can drift. */
7
+ type Arm = "a" | "b";
8
+ /** One running experiment, as the manifest reports it. */
9
+ interface ActiveExperiment {
10
+ id: string;
11
+ /**
12
+ * The template under test, e.g. `bblunt-2_home_default`. Unusable at the edge
13
+ * (a URL doesn't reveal the template suffix) — the RENDER checks it.
14
+ */
15
+ templateKey: string;
16
+ /** `home | products | collection` — the route family the edge matches on. */
17
+ pageType: string;
18
+ /** Percent of visitors on arm B. 1–99. */
19
+ split: number;
20
+ /**
21
+ * Narrows which paths are rewritten; empty means every URL of this `pageType`.
22
+ * Not targeting — only avoids arm-B cache entries for out-of-scope URLs.
23
+ */
24
+ targetUrls: string[];
25
+ /** The slots that vary. They flip TOGETHER — all B, or all A, never a mix. */
26
+ sectionIds: string[];
27
+ }
28
+ /** The response body of `GET /experiments/active`. */
29
+ interface Manifest {
30
+ /** Master switch. Already applied server-side; reported so a UI can say why. */
31
+ masterEnabled: boolean;
32
+ /** Content fingerprint, not a counter — compare for inequality only. */
33
+ revision: number;
34
+ experiments: ActiveExperiment[];
35
+ }
36
+ /** `{ [experimentId]: arm }` — the visitor's stored assignments. */
37
+ type ArmAssignments = Record<string, Arm>;
38
+ /** `{ [sectionId]: variantId }` — what `applySectionVariants` consumes. */
39
+ type SectionVariantSelection = Record<string, Arm>;
40
+
41
+ /**
42
+ * How long a cached manifest is considered fresh.
43
+ *
44
+ * 10s, not 30: `getManifestSync` never awaits a refresh, so a shorter window
45
+ * costs no latency — it only decides how quickly a start/pause reaches the
46
+ * edge. The endpoint's own `s-maxage=30` still bounds how often the backend is
47
+ * actually touched.
48
+ */
49
+ declare const MANIFEST_TTL_MS = 10000;
50
+ /** Data Cache tag. Must match the revalidate webhook, which purges it. */
51
+ declare const MANIFEST_CACHE_TAG = "editor-experiments";
52
+ /**
53
+ * How long to wait for the manifest before giving up.
54
+ *
55
+ * Was 250ms, taken from the TRD's edge budget. **That number does not survive
56
+ * contact with a real backend.** Measured 2026-09-07: 143ms from a warm shell to
57
+ * the deployed backend, but the first call from a cold Node process — DNS, TLS
58
+ * handshake, module init — reliably exceeded 250ms. The result was silent and
59
+ * expensive: every render timed out, failed open to an empty manifest, and both
60
+ * arms of a running experiment rendered the same page. The split looked like it
61
+ * worked (the cookie was set) while the content never changed.
62
+ *
63
+ * 1500ms is still a hard bound, and it is paid at most once per pod per TTL
64
+ * because a hit is cached and concurrent misses coalesce. A timeout still fails
65
+ * open — it just no longer fires on a perfectly healthy backend.
66
+ */
67
+ declare const MANIFEST_TIMEOUT_MS = 1500;
68
+ interface GetManifestOptions {
69
+ /** Editor backend origin, e.g. `https://visual-editor-be.example.com`. */
70
+ baseUrl: string;
71
+ /** `themeId` — the same value as `merchant_mappings.merchantName`. */
72
+ themeId: string;
73
+ ttlMs?: number;
74
+ timeoutMs?: number;
75
+ /** Quiet period after a failure. See MANIFEST_FAILURE_BACKOFF_MS. */
76
+ failureBackoffMs?: number;
77
+ /** Injectable for tests. Defaults to global fetch. */
78
+ fetchImpl?: typeof fetch;
79
+ /** Injectable for tests; must be monotonic-ish. Defaults to Date.now. */
80
+ now?: () => number;
81
+ }
82
+ /**
83
+ * The manifest for a theme, awaiting a refresh when the copy on hand is stale.
84
+ *
85
+ * For the RENDER only. It may block, which a page can afford and middleware
86
+ * cannot — and unlike `getManifestSync` it never reports "no experiments" for a
87
+ * theme that has them, so the render cannot refuse a page middleware bucketed.
88
+ *
89
+ * **Never throws and never returns null.** A failed refresh keeps serving the
90
+ * last good copy — stale experiment data is a far smaller problem than a
91
+ * storefront that stops splitting traffic mid-test because of one bad response.
92
+ * With no copy at all, callers get an empty manifest, which reads as "no
93
+ * experiments" and renders every page exactly as it did before this feature.
94
+ */
95
+ declare function getManifest(opts: GetManifestOptions): Promise<Manifest>;
96
+ /**
97
+ * The manifest for a theme, without ever touching the network.
98
+ *
99
+ * For MIDDLEWARE, which runs before every render: awaiting here adds the fetch's
100
+ * latency to a shopper's TTFB, and when the backend is down that is the full
101
+ * timeout on a share of every merchant's traffic — including merchants running
102
+ * no experiment at all.
103
+ *
104
+ * Returns whatever is cached and refreshes in the background. A cold pod
105
+ * therefore reports EMPTY for its first few requests, so those visitors are not
106
+ * bucketed and see the control page — a handful of sessions per deploy, failing
107
+ * in the safe direction. The render keeps the awaiting version above, so it can
108
+ * never decline a page middleware already bucketed.
109
+ */
110
+ declare function getManifestSync(opts: GetManifestOptions): Manifest;
111
+ /** Drop cached manifests. Tests only — nothing in the request path calls this. */
112
+ declare function __resetManifestCache(): void;
113
+
114
+ /**
115
+ * Which family a path belongs to, or null. Order matters: a nested product URL
116
+ * is a PRODUCT page, so the collection prefix must not be tested first.
117
+ */
118
+ declare function pageTypeOf(pathname: string): string | null;
119
+ /**
120
+ * Does `pathname` satisfy one of the merchant's `targetUrls`? Empty means every
121
+ * URL in the family. A non-empty list only narrows, never expands. One trailing
122
+ * `*` is supported — deliberately not a regex on the request path.
123
+ */
124
+ declare function matchesTargetUrls(pathname: string, targetUrls: string[] | undefined): boolean;
125
+ /**
126
+ * The experiment covering this request, or null.
127
+ *
128
+ * Returns null — never throws — for a manifest that is empty, malformed, or has
129
+ * the master switch off. Callers are on the request path and have nothing
130
+ * useful to do with an exception.
131
+ *
132
+ * `templateKey` is passed by the render and omitted by middleware, which only
133
+ * has a URL. Omitting it in the render is the bug it exists for: an experiment
134
+ * on ONE product template varied every product page, because both templates
135
+ * shared a section id.
136
+ *
137
+ * When several experiments cover one path the FIRST is taken; a database index
138
+ * keeps one active experiment per template, so at most one can apply at render.
139
+ */
140
+ declare function matchExperiment(pathname: string, manifest: Manifest | null | undefined, templateKey?: string): ActiveExperiment | null;
141
+ /**
142
+ * Paths never bucketed. The `/variant/` guard matters most: middleware rewrites
143
+ * TO those, so matching again would rewrite a rewrite.
144
+ */
145
+ declare function isExcludedPath(pathname: string): boolean;
146
+ /**
147
+ * `{themeId}_{templateName}_{suffix}`, as the resolver composes it. Absent,
148
+ * empty and "default" must all map to `_default`, or no experiment on a default
149
+ * template could ever match.
150
+ */
151
+ declare function templateKeyOf(themeId: string, templateName: string, suffix?: string | null): string;
152
+
153
+ /**
154
+ * Putting a visitor on one side of a test, and keeping them there.
155
+ *
156
+ * The dashboard promises "split changes affect new visitors only", which forces
157
+ * STORED assignment: a stateless hash would re-bucket the whole audience the
158
+ * moment the split moves. Read the cookie first; consult the split only when
159
+ * nothing is stored.
160
+ */
161
+ /** One cookie holds every assignment, so a merchant running three tests spends one. */
162
+ declare const ARM_COOKIE = "_ab_arm";
163
+ /** Long enough to outlive any sane test, so returning visitors stay comparable. */
164
+ declare const ARM_COOKIE_MAX_AGE_S: number;
165
+ /**
166
+ * Analytics dimension NAMES — three flat scalars for the event enricher.
167
+ *
168
+ * No longer cookies. Middleware matches on page type alone and cannot know the
169
+ * resolved template, so it could report an arm the render then declined. The
170
+ * render publishes these on `window.__ab` instead, and the enrichers key off
171
+ * these same names. Kept here so the writer and the readers share one source.
172
+ *
173
+ * Flat because GA4 drops nested objects — which is why the UUID-keyed
174
+ * `ARM_COOKIE` cannot double as the reporting signal.
175
+ */
176
+ /** The template under test, e.g. `bblunt-2_products_pdp3`. */
177
+ declare const AB_EXPERIMENT_COOKIE = "ab_experiment";
178
+ /** Which side of the split this visitor is on: `a` or `b`. */
179
+ declare const AB_ARM_COOKIE = "ab_arm";
180
+ /**
181
+ * The route family: `home` | `products` | `collection`. Looks redundant beside
182
+ * `ab_experiment`, but GA4 explorations cannot pattern-match a dimension, so
183
+ * "how did the PDP tests do overall" needs its own field. Values are the
184
+ * manifest's own `pageType` so cookie and API cannot disagree.
185
+ */
186
+ declare const AB_PAGE_COOKIE = "ab_page";
187
+ /**
188
+ * The arm for this experiment. `stored` wins whenever valid; `split` is read
189
+ * only for an unassigned visitor. `random` is injectable so tests can assert
190
+ * the boundary rather than sample it.
191
+ */
192
+ declare function assignArm(experiment: Pick<ActiveExperiment, "id" | "split">, stored: ArmAssignments | null | undefined, random?: () => number): Arm;
193
+ /**
194
+ * Parse the assignment cookie, tolerating any encoding depth: the server sees
195
+ * plain JSON (Next decodes once), the client sees it encoded. Hard-coding one
196
+ * makes the other silently return `{}`, which reads as "no assignment" and
197
+ * re-buckets the visitor. Unparseable is treated as absent, never an error.
198
+ */
199
+ declare function readArmCookie(raw: string | undefined | null): ArmAssignments;
200
+ /**
201
+ * Serialise assignments. Plain JSON, not encoded — every cookie API encodes on
202
+ * the way out, and doing it here too produced a double-encoded value.
203
+ *
204
+ * Bounded: an unbounded map grows the cookie until it breaks the header. The
205
+ * NEWEST entries are kept, by insertion order — so a caller re-assigning an
206
+ * existing experiment must delete the key before re-adding it, or object spread
207
+ * leaves it at its original position and the trim can drop the very experiment
208
+ * being assigned.
209
+ */
210
+ declare function serialiseArmCookie(assignments: ArmAssignments, maxEntries?: number): string;
211
+ /**
212
+ * Every tested section pinned to the visitor's arm. All sections flip together,
213
+ * or the result is unattributable to any single change.
214
+ *
215
+ * **Arm A is pinned explicitly, not left to the live body.** After a promote,
216
+ * `liveVariantId` is "b" — so "render the live body" means render B, and an
217
+ * un-pinned arm A would compare B with itself. Pinning costs nothing: the
218
+ * overlay returns by reference when a section already shows the wanted variant.
219
+ */
220
+ declare function selectionFor(experiment: Pick<ActiveExperiment, "sectionIds">, arm: Arm): SectionVariantSelection | undefined;
221
+
222
+ export { AB_ARM_COOKIE, AB_EXPERIMENT_COOKIE, AB_PAGE_COOKIE, ARM_COOKIE, ARM_COOKIE_MAX_AGE_S, type ActiveExperiment, type Arm, type ArmAssignments, type GetManifestOptions, MANIFEST_CACHE_TAG, MANIFEST_TIMEOUT_MS, MANIFEST_TTL_MS, type Manifest, type SectionVariantSelection, __resetManifestCache, assignArm, getManifest, getManifestSync, isExcludedPath, matchExperiment, matchesTargetUrls, pageTypeOf, readArmCookie, selectionFor, serialiseArmCookie, templateKeyOf };
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Section-level A/B testing — the shapes the edge works with.
3
+ *
4
+ * Read on the storefront request path: no React, no DOM, no dependencies.
5
+ */
6
+ /** Arm "a" IS Variant A, "b" IS Variant B — no mapping stored, so none can drift. */
7
+ type Arm = "a" | "b";
8
+ /** One running experiment, as the manifest reports it. */
9
+ interface ActiveExperiment {
10
+ id: string;
11
+ /**
12
+ * The template under test, e.g. `bblunt-2_home_default`. Unusable at the edge
13
+ * (a URL doesn't reveal the template suffix) — the RENDER checks it.
14
+ */
15
+ templateKey: string;
16
+ /** `home | products | collection` — the route family the edge matches on. */
17
+ pageType: string;
18
+ /** Percent of visitors on arm B. 1–99. */
19
+ split: number;
20
+ /**
21
+ * Narrows which paths are rewritten; empty means every URL of this `pageType`.
22
+ * Not targeting — only avoids arm-B cache entries for out-of-scope URLs.
23
+ */
24
+ targetUrls: string[];
25
+ /** The slots that vary. They flip TOGETHER — all B, or all A, never a mix. */
26
+ sectionIds: string[];
27
+ }
28
+ /** The response body of `GET /experiments/active`. */
29
+ interface Manifest {
30
+ /** Master switch. Already applied server-side; reported so a UI can say why. */
31
+ masterEnabled: boolean;
32
+ /** Content fingerprint, not a counter — compare for inequality only. */
33
+ revision: number;
34
+ experiments: ActiveExperiment[];
35
+ }
36
+ /** `{ [experimentId]: arm }` — the visitor's stored assignments. */
37
+ type ArmAssignments = Record<string, Arm>;
38
+ /** `{ [sectionId]: variantId }` — what `applySectionVariants` consumes. */
39
+ type SectionVariantSelection = Record<string, Arm>;
40
+
41
+ /**
42
+ * How long a cached manifest is considered fresh.
43
+ *
44
+ * 10s, not 30: `getManifestSync` never awaits a refresh, so a shorter window
45
+ * costs no latency — it only decides how quickly a start/pause reaches the
46
+ * edge. The endpoint's own `s-maxage=30` still bounds how often the backend is
47
+ * actually touched.
48
+ */
49
+ declare const MANIFEST_TTL_MS = 10000;
50
+ /** Data Cache tag. Must match the revalidate webhook, which purges it. */
51
+ declare const MANIFEST_CACHE_TAG = "editor-experiments";
52
+ /**
53
+ * How long to wait for the manifest before giving up.
54
+ *
55
+ * Was 250ms, taken from the TRD's edge budget. **That number does not survive
56
+ * contact with a real backend.** Measured 2026-09-07: 143ms from a warm shell to
57
+ * the deployed backend, but the first call from a cold Node process — DNS, TLS
58
+ * handshake, module init — reliably exceeded 250ms. The result was silent and
59
+ * expensive: every render timed out, failed open to an empty manifest, and both
60
+ * arms of a running experiment rendered the same page. The split looked like it
61
+ * worked (the cookie was set) while the content never changed.
62
+ *
63
+ * 1500ms is still a hard bound, and it is paid at most once per pod per TTL
64
+ * because a hit is cached and concurrent misses coalesce. A timeout still fails
65
+ * open — it just no longer fires on a perfectly healthy backend.
66
+ */
67
+ declare const MANIFEST_TIMEOUT_MS = 1500;
68
+ interface GetManifestOptions {
69
+ /** Editor backend origin, e.g. `https://visual-editor-be.example.com`. */
70
+ baseUrl: string;
71
+ /** `themeId` — the same value as `merchant_mappings.merchantName`. */
72
+ themeId: string;
73
+ ttlMs?: number;
74
+ timeoutMs?: number;
75
+ /** Quiet period after a failure. See MANIFEST_FAILURE_BACKOFF_MS. */
76
+ failureBackoffMs?: number;
77
+ /** Injectable for tests. Defaults to global fetch. */
78
+ fetchImpl?: typeof fetch;
79
+ /** Injectable for tests; must be monotonic-ish. Defaults to Date.now. */
80
+ now?: () => number;
81
+ }
82
+ /**
83
+ * The manifest for a theme, awaiting a refresh when the copy on hand is stale.
84
+ *
85
+ * For the RENDER only. It may block, which a page can afford and middleware
86
+ * cannot — and unlike `getManifestSync` it never reports "no experiments" for a
87
+ * theme that has them, so the render cannot refuse a page middleware bucketed.
88
+ *
89
+ * **Never throws and never returns null.** A failed refresh keeps serving the
90
+ * last good copy — stale experiment data is a far smaller problem than a
91
+ * storefront that stops splitting traffic mid-test because of one bad response.
92
+ * With no copy at all, callers get an empty manifest, which reads as "no
93
+ * experiments" and renders every page exactly as it did before this feature.
94
+ */
95
+ declare function getManifest(opts: GetManifestOptions): Promise<Manifest>;
96
+ /**
97
+ * The manifest for a theme, without ever touching the network.
98
+ *
99
+ * For MIDDLEWARE, which runs before every render: awaiting here adds the fetch's
100
+ * latency to a shopper's TTFB, and when the backend is down that is the full
101
+ * timeout on a share of every merchant's traffic — including merchants running
102
+ * no experiment at all.
103
+ *
104
+ * Returns whatever is cached and refreshes in the background. A cold pod
105
+ * therefore reports EMPTY for its first few requests, so those visitors are not
106
+ * bucketed and see the control page — a handful of sessions per deploy, failing
107
+ * in the safe direction. The render keeps the awaiting version above, so it can
108
+ * never decline a page middleware already bucketed.
109
+ */
110
+ declare function getManifestSync(opts: GetManifestOptions): Manifest;
111
+ /** Drop cached manifests. Tests only — nothing in the request path calls this. */
112
+ declare function __resetManifestCache(): void;
113
+
114
+ /**
115
+ * Which family a path belongs to, or null. Order matters: a nested product URL
116
+ * is a PRODUCT page, so the collection prefix must not be tested first.
117
+ */
118
+ declare function pageTypeOf(pathname: string): string | null;
119
+ /**
120
+ * Does `pathname` satisfy one of the merchant's `targetUrls`? Empty means every
121
+ * URL in the family. A non-empty list only narrows, never expands. One trailing
122
+ * `*` is supported — deliberately not a regex on the request path.
123
+ */
124
+ declare function matchesTargetUrls(pathname: string, targetUrls: string[] | undefined): boolean;
125
+ /**
126
+ * The experiment covering this request, or null.
127
+ *
128
+ * Returns null — never throws — for a manifest that is empty, malformed, or has
129
+ * the master switch off. Callers are on the request path and have nothing
130
+ * useful to do with an exception.
131
+ *
132
+ * `templateKey` is passed by the render and omitted by middleware, which only
133
+ * has a URL. Omitting it in the render is the bug it exists for: an experiment
134
+ * on ONE product template varied every product page, because both templates
135
+ * shared a section id.
136
+ *
137
+ * When several experiments cover one path the FIRST is taken; a database index
138
+ * keeps one active experiment per template, so at most one can apply at render.
139
+ */
140
+ declare function matchExperiment(pathname: string, manifest: Manifest | null | undefined, templateKey?: string): ActiveExperiment | null;
141
+ /**
142
+ * Paths never bucketed. The `/variant/` guard matters most: middleware rewrites
143
+ * TO those, so matching again would rewrite a rewrite.
144
+ */
145
+ declare function isExcludedPath(pathname: string): boolean;
146
+ /**
147
+ * `{themeId}_{templateName}_{suffix}`, as the resolver composes it. Absent,
148
+ * empty and "default" must all map to `_default`, or no experiment on a default
149
+ * template could ever match.
150
+ */
151
+ declare function templateKeyOf(themeId: string, templateName: string, suffix?: string | null): string;
152
+
153
+ /**
154
+ * Putting a visitor on one side of a test, and keeping them there.
155
+ *
156
+ * The dashboard promises "split changes affect new visitors only", which forces
157
+ * STORED assignment: a stateless hash would re-bucket the whole audience the
158
+ * moment the split moves. Read the cookie first; consult the split only when
159
+ * nothing is stored.
160
+ */
161
+ /** One cookie holds every assignment, so a merchant running three tests spends one. */
162
+ declare const ARM_COOKIE = "_ab_arm";
163
+ /** Long enough to outlive any sane test, so returning visitors stay comparable. */
164
+ declare const ARM_COOKIE_MAX_AGE_S: number;
165
+ /**
166
+ * Analytics dimension NAMES — three flat scalars for the event enricher.
167
+ *
168
+ * No longer cookies. Middleware matches on page type alone and cannot know the
169
+ * resolved template, so it could report an arm the render then declined. The
170
+ * render publishes these on `window.__ab` instead, and the enrichers key off
171
+ * these same names. Kept here so the writer and the readers share one source.
172
+ *
173
+ * Flat because GA4 drops nested objects — which is why the UUID-keyed
174
+ * `ARM_COOKIE` cannot double as the reporting signal.
175
+ */
176
+ /** The template under test, e.g. `bblunt-2_products_pdp3`. */
177
+ declare const AB_EXPERIMENT_COOKIE = "ab_experiment";
178
+ /** Which side of the split this visitor is on: `a` or `b`. */
179
+ declare const AB_ARM_COOKIE = "ab_arm";
180
+ /**
181
+ * The route family: `home` | `products` | `collection`. Looks redundant beside
182
+ * `ab_experiment`, but GA4 explorations cannot pattern-match a dimension, so
183
+ * "how did the PDP tests do overall" needs its own field. Values are the
184
+ * manifest's own `pageType` so cookie and API cannot disagree.
185
+ */
186
+ declare const AB_PAGE_COOKIE = "ab_page";
187
+ /**
188
+ * The arm for this experiment. `stored` wins whenever valid; `split` is read
189
+ * only for an unassigned visitor. `random` is injectable so tests can assert
190
+ * the boundary rather than sample it.
191
+ */
192
+ declare function assignArm(experiment: Pick<ActiveExperiment, "id" | "split">, stored: ArmAssignments | null | undefined, random?: () => number): Arm;
193
+ /**
194
+ * Parse the assignment cookie, tolerating any encoding depth: the server sees
195
+ * plain JSON (Next decodes once), the client sees it encoded. Hard-coding one
196
+ * makes the other silently return `{}`, which reads as "no assignment" and
197
+ * re-buckets the visitor. Unparseable is treated as absent, never an error.
198
+ */
199
+ declare function readArmCookie(raw: string | undefined | null): ArmAssignments;
200
+ /**
201
+ * Serialise assignments. Plain JSON, not encoded — every cookie API encodes on
202
+ * the way out, and doing it here too produced a double-encoded value.
203
+ *
204
+ * Bounded: an unbounded map grows the cookie until it breaks the header. The
205
+ * NEWEST entries are kept, by insertion order — so a caller re-assigning an
206
+ * existing experiment must delete the key before re-adding it, or object spread
207
+ * leaves it at its original position and the trim can drop the very experiment
208
+ * being assigned.
209
+ */
210
+ declare function serialiseArmCookie(assignments: ArmAssignments, maxEntries?: number): string;
211
+ /**
212
+ * Every tested section pinned to the visitor's arm. All sections flip together,
213
+ * or the result is unattributable to any single change.
214
+ *
215
+ * **Arm A is pinned explicitly, not left to the live body.** After a promote,
216
+ * `liveVariantId` is "b" — so "render the live body" means render B, and an
217
+ * un-pinned arm A would compare B with itself. Pinning costs nothing: the
218
+ * overlay returns by reference when a section already shows the wanted variant.
219
+ */
220
+ declare function selectionFor(experiment: Pick<ActiveExperiment, "sectionIds">, arm: Arm): SectionVariantSelection | undefined;
221
+
222
+ export { AB_ARM_COOKIE, AB_EXPERIMENT_COOKIE, AB_PAGE_COOKIE, ARM_COOKIE, ARM_COOKIE_MAX_AGE_S, type ActiveExperiment, type Arm, type ArmAssignments, type GetManifestOptions, MANIFEST_CACHE_TAG, MANIFEST_TIMEOUT_MS, MANIFEST_TTL_MS, type Manifest, type SectionVariantSelection, __resetManifestCache, assignArm, getManifest, getManifestSync, isExcludedPath, matchExperiment, matchesTargetUrls, pageTypeOf, readArmCookie, selectionFor, serialiseArmCookie, templateKeyOf };
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ 'use strict';var l=Object.freeze({masterEnabled:true,revision:0,experiments:Object.freeze([])}),u=1e4,p="editor-experiments",A=1500,x=5e3,c=new Map,o=new Map,h=64;function y(){if(o.size<h)return;let t=o.keys().next().value;t!==void 0&&(o.delete(t),c.delete(t));}var f=new Map;function I(t){return !t||typeof t!="object"?false:Array.isArray(t.experiments)}async function b(t){let e=t.fetchImpl??fetch,i=new AbortController,n=setTimeout(()=>i.abort(),t.timeoutMs??A);try{let r=`${t.baseUrl.replace(/\/$/,"")}/api/v1/experiments/active?themeId=${encodeURIComponent(t.themeId)}`,s={signal:i.signal,next:{revalidate:3600,tags:[p]}},a=await e(r,s);if(!a.ok)return null;let d=await a.json();return I(d?.data)?d.data:null}catch{return null}finally{clearTimeout(n);}}function M(t){return `${t.baseUrl}::${t.themeId}`}function g(t,e){let i=e.now??Date.now,n=e.failureBackoffMs??x,r=c.get(t);return r!==void 0&&i()-r<n}function E(t,e){let i=f.get(t);if(i)return i;let n=e.now??Date.now,r=b(e).then(s=>(s?(c.delete(t),y(),o.set(t,{at:n(),data:s})):c.set(t,n()),s)).catch(()=>(c.set(t,n()),null)).finally(()=>f.delete(t));return f.set(t,r),r}async function T(t){let e=t.now??Date.now,i=t.ttlMs??u,n=M(t),r=o.get(n);return r&&e()-r.at<i?r.data:g(n,t)?r?.data??l:await E(n,t)??r?.data??l}function C(t){let e=t.now??Date.now,i=t.ttlMs??u,n=M(t),r=o.get(n);return (!r||e()-r.at>=i)&&!g(n,t)&&E(n,t),r?.data??l}function S(){o.clear(),f.clear(),c.clear();}var w="home",v="products",k="collection";function _(t){let e=m(t);return e==="/"?w:e.includes("/products/")?v:e.startsWith("/collections/")?k:null}function m(t){let e=t||"/";return e.length>1&&e.endsWith("/")&&(e=e.slice(0,-1)),e||"/"}function O(t,e){if(!e||e.length===0)return true;let i=m(t);return e.some(n=>{let r=m(String(n??""));return r?r.endsWith("*")?i.startsWith(r.slice(0,-1)):i===r:false})}function P(t,e,i){if(!e||e.masterEnabled===false)return null;let n=e.experiments;if(!Array.isArray(n)||n.length===0)return null;let r=_(t);return r?n.find(s=>s&&s.pageType===r&&(i===void 0||s.templateKey===i)&&Array.isArray(s.sectionIds)&&s.sectionIds.length>0&&O(t,s.targetUrls))??null:null}function F(t){return t.startsWith("/_next")||t.startsWith("/api/")||t.includes("/variant/")||/\.[a-z0-9]+$/i.test(t)}function R(t,e,i){let n=typeof i=="string"?i.trim():"";return `${t}_${e}_${n||"default"}`}var G="_ab_arm",K=7776e3,N="ab_experiment",U="ab_arm",B="ab_page";function j(t,e,i=Math.random){let n=e?.[t.id];if(n==="a"||n==="b")return n;let r=t.split;return !Number.isFinite(r)||r<1||r>99?"a":i()*100<r?"b":"a"}function $(t){if(!t)return {};let e=t;for(let i=0;i<3;i++)try{let n=JSON.parse(e);if(!n||typeof n!="object"||Array.isArray(n))return {};let r={};for(let[s,a]of Object.entries(n))(a==="a"||a==="b")&&(r[s]=a);return r}catch{try{let n=decodeURIComponent(e);if(n===e)return {};e=n;}catch{return {}}}return {}}function D(t,e=20){let i=Object.entries(t).slice(-e);return JSON.stringify(Object.fromEntries(i))}function W(t,e){let i=t.sectionIds;if(!Array.isArray(i)||i.length===0)return;let n={};for(let r of i)typeof r=="string"&&r&&(n[r]=e);return Object.keys(n).length>0?n:void 0}exports.AB_ARM_COOKIE=U;exports.AB_EXPERIMENT_COOKIE=N;exports.AB_PAGE_COOKIE=B;exports.ARM_COOKIE=G;exports.ARM_COOKIE_MAX_AGE_S=K;exports.MANIFEST_CACHE_TAG=p;exports.MANIFEST_TIMEOUT_MS=A;exports.MANIFEST_TTL_MS=u;exports.__resetManifestCache=S;exports.assignArm=j;exports.getManifest=T;exports.getManifestSync=C;exports.isExcludedPath=F;exports.matchExperiment=P;exports.matchesTargetUrls=O;exports.pageTypeOf=_;exports.readArmCookie=$;exports.selectionFor=W;exports.serialiseArmCookie=D;exports.templateKeyOf=R;
package/dist/index.mjs ADDED
@@ -0,0 +1 @@
1
+ var l=Object.freeze({masterEnabled:true,revision:0,experiments:Object.freeze([])}),u=1e4,p="editor-experiments",A=1500,x=5e3,c=new Map,o=new Map,h=64;function y(){if(o.size<h)return;let t=o.keys().next().value;t!==void 0&&(o.delete(t),c.delete(t));}var f=new Map;function I(t){return !t||typeof t!="object"?false:Array.isArray(t.experiments)}async function b(t){let e=t.fetchImpl??fetch,i=new AbortController,n=setTimeout(()=>i.abort(),t.timeoutMs??A);try{let r=`${t.baseUrl.replace(/\/$/,"")}/api/v1/experiments/active?themeId=${encodeURIComponent(t.themeId)}`,s={signal:i.signal,next:{revalidate:3600,tags:[p]}},a=await e(r,s);if(!a.ok)return null;let d=await a.json();return I(d?.data)?d.data:null}catch{return null}finally{clearTimeout(n);}}function M(t){return `${t.baseUrl}::${t.themeId}`}function g(t,e){let i=e.now??Date.now,n=e.failureBackoffMs??x,r=c.get(t);return r!==void 0&&i()-r<n}function E(t,e){let i=f.get(t);if(i)return i;let n=e.now??Date.now,r=b(e).then(s=>(s?(c.delete(t),y(),o.set(t,{at:n(),data:s})):c.set(t,n()),s)).catch(()=>(c.set(t,n()),null)).finally(()=>f.delete(t));return f.set(t,r),r}async function T(t){let e=t.now??Date.now,i=t.ttlMs??u,n=M(t),r=o.get(n);return r&&e()-r.at<i?r.data:g(n,t)?r?.data??l:await E(n,t)??r?.data??l}function C(t){let e=t.now??Date.now,i=t.ttlMs??u,n=M(t),r=o.get(n);return (!r||e()-r.at>=i)&&!g(n,t)&&E(n,t),r?.data??l}function S(){o.clear(),f.clear(),c.clear();}var w="home",v="products",k="collection";function _(t){let e=m(t);return e==="/"?w:e.includes("/products/")?v:e.startsWith("/collections/")?k:null}function m(t){let e=t||"/";return e.length>1&&e.endsWith("/")&&(e=e.slice(0,-1)),e||"/"}function O(t,e){if(!e||e.length===0)return true;let i=m(t);return e.some(n=>{let r=m(String(n??""));return r?r.endsWith("*")?i.startsWith(r.slice(0,-1)):i===r:false})}function P(t,e,i){if(!e||e.masterEnabled===false)return null;let n=e.experiments;if(!Array.isArray(n)||n.length===0)return null;let r=_(t);return r?n.find(s=>s&&s.pageType===r&&(i===void 0||s.templateKey===i)&&Array.isArray(s.sectionIds)&&s.sectionIds.length>0&&O(t,s.targetUrls))??null:null}function F(t){return t.startsWith("/_next")||t.startsWith("/api/")||t.includes("/variant/")||/\.[a-z0-9]+$/i.test(t)}function R(t,e,i){let n=typeof i=="string"?i.trim():"";return `${t}_${e}_${n||"default"}`}var G="_ab_arm",K=7776e3,N="ab_experiment",U="ab_arm",B="ab_page";function j(t,e,i=Math.random){let n=e?.[t.id];if(n==="a"||n==="b")return n;let r=t.split;return !Number.isFinite(r)||r<1||r>99?"a":i()*100<r?"b":"a"}function $(t){if(!t)return {};let e=t;for(let i=0;i<3;i++)try{let n=JSON.parse(e);if(!n||typeof n!="object"||Array.isArray(n))return {};let r={};for(let[s,a]of Object.entries(n))(a==="a"||a==="b")&&(r[s]=a);return r}catch{try{let n=decodeURIComponent(e);if(n===e)return {};e=n;}catch{return {}}}return {}}function D(t,e=20){let i=Object.entries(t).slice(-e);return JSON.stringify(Object.fromEntries(i))}function W(t,e){let i=t.sectionIds;if(!Array.isArray(i)||i.length===0)return;let n={};for(let r of i)typeof r=="string"&&r&&(n[r]=e);return Object.keys(n).length>0?n:void 0}export{U as AB_ARM_COOKIE,N as AB_EXPERIMENT_COOKIE,B as AB_PAGE_COOKIE,G as ARM_COOKIE,K as ARM_COOKIE_MAX_AGE_S,p as MANIFEST_CACHE_TAG,A as MANIFEST_TIMEOUT_MS,u as MANIFEST_TTL_MS,S as __resetManifestCache,j as assignArm,T as getManifest,C as getManifestSync,F as isExcludedPath,P as matchExperiment,O as matchesTargetUrls,_ as pageTypeOf,$ as readArmCookie,W as selectionFor,D as serialiseArmCookie,R as templateKeyOf};
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@shopkit/ab",
3
+ "version": "0.1.0",
4
+ "description": "Section-level A/B testing: reads the edge manifest, matches a request to an experiment, and assigns a visitor to an arm",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs",
13
+ "require": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsup",
22
+ "dev": "tsup --watch",
23
+ "typecheck": "tsc --noEmit",
24
+ "clean": "rm -rf dist",
25
+ "test": "vitest run",
26
+ "test:watch": "vitest"
27
+ },
28
+ "devDependencies": {
29
+ "tsup": "^8.0.0",
30
+ "typescript": "^5.3.0",
31
+ "vitest": "^1.0.0"
32
+ },
33
+ "keywords": [
34
+ "ab-testing",
35
+ "experiments",
36
+ "edge",
37
+ "middleware",
38
+ "shopkit"
39
+ ],
40
+ "license": "MIT",
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }