@replohq/sdk 2.10.1 → 2.11.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.
@@ -0,0 +1,24 @@
1
+ /** The cookie that records which variant of each experiment a visitor saw. */
2
+ export declare const EXPERIMENTS_COOKIE = "replo_experiments";
3
+ /** Marks a counted visit's response (`experimentKey:variantKey`); the pixel turns it into one `replo.experiment_exposure` and clears it. */
4
+ export declare const EXPERIMENT_EXPOSURE_COOKIE = "replo_experiment_exposure";
5
+ /** The durable visitor ID experiments hash to pick a variant. */
6
+ export declare const VISITOR_ID_COOKIE = "replo_visitor_id";
7
+ /** `?experiment=<experimentKey>:<variantKey>` pins a variant for QA; forced visits are left out of results. */
8
+ export declare const EXPERIMENT_OVERRIDE_PARAM = "experiment";
9
+ export declare const EXPERIMENT_KEY_PATTERN: RegExp;
10
+ export type ExperimentAssignment = {
11
+ variantKey: string;
12
+ /** Pinned with `?experiment=`, so the visit is QA rather than a sample. */
13
+ forced: boolean;
14
+ };
15
+ /** Assignments keyed by experiment key. */
16
+ export type ExperimentAssignments = Record<string, ExperimentAssignment>;
17
+ /** Reads a `replo_experiments` value (`bold-hero:bold!|pricing:control`), dropping malformed entries. */
18
+ export declare function parseExperimentCookie(value: string | null | undefined): ExperimentAssignments;
19
+ export declare function serializeExperimentCookie(assignments: ExperimentAssignments): string;
20
+ /** Reads a `?experiment=` value (`bold-hero:bold`); null when it isn't one experiment key and one variant key. */
21
+ export declare function parseExperimentOverride(value: string): {
22
+ experimentKey: string;
23
+ variantKey: string;
24
+ } | null;
@@ -0,0 +1 @@
1
+ export { EXPERIMENTS_COOKIE, EXPERIMENT_EXPOSURE_COOKIE, EXPERIMENT_KEY_PATTERN, EXPERIMENT_OVERRIDE_PARAM, VISITOR_ID_COOKIE, parseExperimentCookie, parseExperimentOverride, serializeExperimentCookie } from '../../../chunk-PH6IR743.mjs';
@@ -0,0 +1,73 @@
1
+ import * as z from "zod";
2
+ export declare const experimentKeySchema: z.ZodString;
3
+ export declare const experimentRouteSchema: z.ZodString;
4
+ export declare const experimentVariantSchema: z.ZodObject<{
5
+ variantKey: z.ZodString;
6
+ route: z.ZodString;
7
+ rolloutPercentage: z.ZodNumber;
8
+ }, z.core.$strip>;
9
+ export declare const experimentStatusSchema: z.ZodEnum<{
10
+ running: "running";
11
+ draft: "draft";
12
+ stopped: "stopped";
13
+ }>;
14
+ /** The routing view of one `agent_experiment` row, as the site's KV and `experiments.json` store it. */
15
+ export declare const experimentSchema: z.ZodObject<{
16
+ experimentKey: z.ZodString;
17
+ route: z.ZodString;
18
+ status: z.ZodEnum<{
19
+ running: "running";
20
+ draft: "draft";
21
+ stopped: "stopped";
22
+ }>;
23
+ variants: z.ZodArray<z.ZodObject<{
24
+ variantKey: z.ZodString;
25
+ route: z.ZodString;
26
+ rolloutPercentage: z.ZodNumber;
27
+ }, z.core.$strip>>;
28
+ shippedVariantKey: z.ZodNullable<z.ZodString>;
29
+ }, z.core.$strip>;
30
+ export declare const experimentsFileSchema: z.ZodObject<{
31
+ experiments: z.ZodArray<z.ZodObject<{
32
+ experimentKey: z.ZodString;
33
+ route: z.ZodString;
34
+ status: z.ZodEnum<{
35
+ running: "running";
36
+ draft: "draft";
37
+ stopped: "stopped";
38
+ }>;
39
+ variants: z.ZodArray<z.ZodObject<{
40
+ variantKey: z.ZodString;
41
+ route: z.ZodString;
42
+ rolloutPercentage: z.ZodNumber;
43
+ }, z.core.$strip>>;
44
+ shippedVariantKey: z.ZodNullable<z.ZodString>;
45
+ }, z.core.$strip>>;
46
+ liveRoutes: z.ZodOptional<z.ZodArray<z.ZodString>>;
47
+ }, z.core.$strip>;
48
+ /** The binding a published site's worker reads its own KV namespace through. */
49
+ export declare const SITE_KV_BINDING = "SITE_KV";
50
+ /** The site KV key holding the site's live experiment routing, an `ExperimentsFile` without `liveRoutes`. */
51
+ export declare const SITE_KV_EXPERIMENTS_KEY = "experiments";
52
+ export type ExperimentVariant = z.infer<typeof experimentVariantSchema>;
53
+ export type ExperimentStatus = z.infer<typeof experimentStatusSchema>;
54
+ export type Experiment = z.infer<typeof experimentSchema>;
55
+ export type ExperimentsFile = z.infer<typeof experimentsFileSchema>;
56
+ /** Hashes experiment key + visitor ID, so a visitor keeps one variant and each experiment shuffles independently. */
57
+ export declare function assignVariant({ visitorId, experiment, }: {
58
+ visitorId: string;
59
+ experiment: Pick<Experiment, "experimentKey" | "variants">;
60
+ }): ExperimentVariant;
61
+ /** The experiment that routes `pathname`: a running one, else one that shipped a non-control variant. */
62
+ export declare function findActiveExperiment({ experiments, pathname, }: {
63
+ experiments: readonly Experiment[];
64
+ pathname: string;
65
+ }): Experiment | null;
66
+ /** Whether a stopped experiment still routes every visitor to its shipped variant. */
67
+ export declare function hasNonControlShippedVariant(experiment: Experiment): boolean;
68
+ /** Whether the experiment routes traffic, and so belongs in the site's routing. */
69
+ export declare function isRoutingExperiment(experiment: Experiment): boolean;
70
+ /** The variants visitors are sent to: all of them while running, else the shipped one. */
71
+ export declare function findServedVariants(experiment: Experiment): ExperimentVariant[];
72
+ /** Trailing slashes carry no meaning, so `/landing` and `/landing/` are one route. */
73
+ export declare function normalizeRoute(pathname: string): string;
@@ -0,0 +1,164 @@
1
+ import { EXPERIMENT_KEY_PATTERN } from '../../../chunk-PH6IR743.mjs';
2
+ import * as z from 'zod';
3
+
4
+ var MIN_VARIANTS = 2;
5
+ var MAX_VARIANTS = 5;
6
+ var TOTAL_ROLLOUT_PERCENTAGE = 100;
7
+ var ASSIGNMENT_BUCKETS = 1e4;
8
+ var experimentKeySchema = z.string().regex(EXPERIMENT_KEY_PATTERN, {
9
+ message: "must be lowercase kebab-case, e.g. bold-hero"
10
+ });
11
+ var experimentRouteSchema = z.string().min(1).refine((route) => route.startsWith("/"), { message: "must start with /" });
12
+ var experimentVariantSchema = z.object({
13
+ variantKey: experimentKeySchema.describe(
14
+ "Stable identifier stored in the visitor's cookie and on every event"
15
+ ),
16
+ route: experimentRouteSchema.describe("The page this variant renders"),
17
+ rolloutPercentage: z.number().int().min(0).max(TOTAL_ROLLOUT_PERCENTAGE).describe("Share of traffic, in percent")
18
+ });
19
+ var experimentStatusSchema = z.enum(["draft", "running", "stopped"]);
20
+ var experimentSchema = z.object({
21
+ experimentKey: experimentKeySchema.describe(
22
+ "The experiment's key; changing it reassigns every visitor"
23
+ ),
24
+ route: experimentRouteSchema.describe(
25
+ "The shared URL visitors land on; the control variant renders it"
26
+ ),
27
+ status: experimentStatusSchema.describe(
28
+ "Only running experiments, and stopped ones with a shipped variant, route traffic"
29
+ ),
30
+ variants: z.array(experimentVariantSchema).min(MIN_VARIANTS).max(MAX_VARIANTS).describe("The control first, then up to four challengers"),
31
+ shippedVariantKey: experimentKeySchema.nullable().describe(
32
+ "Variant key every visitor gets once the experiment has stopped; null keeps the control"
33
+ )
34
+ }).superRefine((experiment, context) => {
35
+ const [control] = experiment.variants;
36
+ if (control && normalizeRoute(control.route) !== normalizeRoute(experiment.route)) {
37
+ context.addIssue({
38
+ code: "custom",
39
+ path: ["variants", 0, "route"],
40
+ message: "the first variant is the control and must use the experiment's route"
41
+ });
42
+ }
43
+ const totalRolloutPercentage = experiment.variants.reduce(
44
+ (sum, variant) => sum + variant.rolloutPercentage,
45
+ 0
46
+ );
47
+ if (totalRolloutPercentage !== TOTAL_ROLLOUT_PERCENTAGE) {
48
+ context.addIssue({
49
+ code: "custom",
50
+ path: ["variants"],
51
+ message: `variant rollout percentages must add up to ${TOTAL_ROLLOUT_PERCENTAGE}, not ${totalRolloutPercentage}`
52
+ });
53
+ }
54
+ addDuplicateIssues({
55
+ context,
56
+ values: experiment.variants.map((variant) => variant.variantKey),
57
+ path: ["variants"],
58
+ describe: (key) => `duplicate variantKey "${key}"`
59
+ });
60
+ addDuplicateIssues({
61
+ context,
62
+ values: experiment.variants.map((variant) => {
63
+ return normalizeRoute(variant.route);
64
+ }),
65
+ path: ["variants"],
66
+ describe: (route) => `duplicate route "${route}"`
67
+ });
68
+ if (experiment.shippedVariantKey !== null && !experiment.variants.some((variant) => {
69
+ return variant.variantKey === experiment.shippedVariantKey;
70
+ })) {
71
+ context.addIssue({
72
+ code: "custom",
73
+ path: ["shippedVariantKey"],
74
+ message: `"${experiment.shippedVariantKey}" is not one of this experiment's variant keys`
75
+ });
76
+ }
77
+ });
78
+ var experimentsFileSchema = z.object({
79
+ experiments: z.array(experimentSchema).describe("Every running or stopped experiment on the site"),
80
+ liveRoutes: z.array(experimentRouteSchema).optional().describe(
81
+ "Pages live in this build; the middleware never sends a visitor to a page outside this list"
82
+ )
83
+ }).superRefine(({ experiments }, context) => {
84
+ addDuplicateIssues({
85
+ context,
86
+ values: experiments.map((experiment) => experiment.experimentKey),
87
+ path: ["experiments"],
88
+ describe: (key) => `duplicate experimentKey "${key}"`
89
+ });
90
+ addDuplicateIssues({
91
+ context,
92
+ values: experiments.filter((experiment) => experiment.status === "running").map((experiment) => normalizeRoute(experiment.route)),
93
+ path: ["experiments"],
94
+ describe: (route) => `two running experiments share the route "${route}"`
95
+ });
96
+ });
97
+ var SITE_KV_BINDING = "SITE_KV";
98
+ var SITE_KV_EXPERIMENTS_KEY = "experiments";
99
+ function assignVariant({
100
+ visitorId,
101
+ experiment
102
+ }) {
103
+ const bucket = fnv1a32(`${experiment.experimentKey}:${visitorId}`) % ASSIGNMENT_BUCKETS;
104
+ const bucketsPerPercent = ASSIGNMENT_BUCKETS / TOTAL_ROLLOUT_PERCENTAGE;
105
+ let cumulative = 0;
106
+ for (const variant of experiment.variants) {
107
+ cumulative += variant.rolloutPercentage * bucketsPerPercent;
108
+ if (bucket < cumulative) {
109
+ return variant;
110
+ }
111
+ }
112
+ return experiment.variants.at(-1);
113
+ }
114
+ function findActiveExperiment({
115
+ experiments,
116
+ pathname
117
+ }) {
118
+ const route = normalizeRoute(pathname);
119
+ const onRoute = experiments.filter((experiment) => {
120
+ return normalizeRoute(experiment.route) === route;
121
+ });
122
+ return onRoute.find((experiment) => experiment.status === "running") ?? onRoute.find(hasNonControlShippedVariant) ?? null;
123
+ }
124
+ function hasNonControlShippedVariant(experiment) {
125
+ return experiment.status === "stopped" && experiment.shippedVariantKey !== null && experiment.shippedVariantKey !== experiment.variants[0]?.variantKey;
126
+ }
127
+ function isRoutingExperiment(experiment) {
128
+ return experiment.status === "running" || hasNonControlShippedVariant(experiment);
129
+ }
130
+ function findServedVariants(experiment) {
131
+ return experiment.status === "running" ? experiment.variants : experiment.variants.filter((variant) => {
132
+ return variant.variantKey === experiment.shippedVariantKey;
133
+ });
134
+ }
135
+ function normalizeRoute(pathname) {
136
+ const trimmed = pathname.replace(/\/+$/, "");
137
+ return trimmed === "" ? "/" : trimmed;
138
+ }
139
+ var FNV_OFFSET_BASIS = 2166136261;
140
+ var FNV_PRIME = 16777619;
141
+ function fnv1a32(value) {
142
+ let hash = FNV_OFFSET_BASIS;
143
+ for (let index = 0; index < value.length; index++) {
144
+ hash ^= value.charCodeAt(index);
145
+ hash = Math.imul(hash, FNV_PRIME);
146
+ }
147
+ return hash >>> 0;
148
+ }
149
+ function addDuplicateIssues({
150
+ context,
151
+ values,
152
+ path,
153
+ describe
154
+ }) {
155
+ const seen = /* @__PURE__ */ new Set();
156
+ for (const value of values) {
157
+ if (seen.has(value)) {
158
+ context.addIssue({ code: "custom", path, message: describe(value) });
159
+ }
160
+ seen.add(value);
161
+ }
162
+ }
163
+
164
+ export { SITE_KV_BINDING, SITE_KV_EXPERIMENTS_KEY, assignVariant, experimentKeySchema, experimentRouteSchema, experimentSchema, experimentStatusSchema, experimentVariantSchema, experimentsFileSchema, findActiveExperiment, findServedVariants, hasNonControlShippedVariant, isRoutingExperiment, normalizeRoute };
@@ -1,6 +1,7 @@
1
1
  "use server";
2
2
  import { cookies } from "next/headers";
3
3
  import { getCheckoutProvider } from "../lib/integration-utils";
4
+ import { REPLO_ATTRIBUTION_PROPERTY } from "./cart-types";
4
5
  import {
5
6
  getCartCookieOptions,
6
7
  REPLO_CART_COOKIE_NAME
@@ -116,7 +117,17 @@ async function createCartAction(options) {
116
117
  }
117
118
  async function addToCartAction(lines, cartId) {
118
119
  const cartGateway = await getCartGateway();
119
- return await cartGateway.addToCart({ lines, cartId });
120
+ const cart = await cartGateway.addToCart({ lines, cartId });
121
+ if (!cart || await getCheckoutProvider() !== "shopify" || cart.attributes?.some(
122
+ (attribute) => attribute.key === REPLO_ATTRIBUTION_PROPERTY.key
123
+ )) {
124
+ return cart;
125
+ }
126
+ const attributedCart = await cartGateway.replaceCartAttributes({
127
+ cartId: cart.id,
128
+ attributes: [...cart.attributes ?? [], REPLO_ATTRIBUTION_PROPERTY]
129
+ });
130
+ return attributedCart ?? cart;
120
131
  }
121
132
  async function updateCartLineItemAction(line, cartId) {
122
133
  const cartGateway = await getCartGateway();
@@ -29,7 +29,10 @@ export interface CartLineAttribute {
29
29
  key: string;
30
30
  value: string | number | boolean;
31
31
  }
32
- /** Replo attribution property added to cart/buy-now line items for order attribution in Shopify. */
32
+ /**
33
+ * Replo order attribution. Shopify carts the SDK adds to carry it as a cart
34
+ * attribute; buy-now and Stripe checkout lines carry it as a line property.
35
+ */
33
36
  export declare const REPLO_ATTRIBUTION_PROPERTY: CartLineAttribute;
34
37
  export interface SellingPlanPriceAdjustment {
35
38
  compareAtPrice: {
@@ -4,7 +4,6 @@ import { useQueryClient } from "@tanstack/react-query";
4
4
  import { fromMajorUnitsToMinorUnits } from "../../_vendor/schemas/moneyFormat.mjs";
5
5
  import { DATA_LOADER_KEYS } from "../../loaders/loader-keys";
6
6
  import { useCartInternal } from "../cart-provider";
7
- import { REPLO_ATTRIBUTION_PROPERTY } from "../cart-types";
8
7
  import { toCartError } from "../utils/cart-error";
9
8
  const PRODUCT_LOADER_KEYS = [
10
9
  DATA_LOADER_KEYS.SHOPIFY_PRODUCT,
@@ -90,10 +89,6 @@ function useAddToCart() {
90
89
  setIsAdding(true);
91
90
  try {
92
91
  const cartLines = items.map((item) => {
93
- const hasReploAttribution = item.properties?.some(
94
- (prop) => prop.key === REPLO_ATTRIBUTION_PROPERTY.key
95
- );
96
- const properties = hasReploAttribution ? item.properties ?? [] : [REPLO_ATTRIBUTION_PROPERTY, ...item.properties ?? []];
97
92
  const merchandise = item.merchandise ?? findMerchandiseInQueryCache({
98
93
  queryClient,
99
94
  merchandiseId: item.merchandiseId
@@ -102,7 +97,7 @@ function useAddToCart() {
102
97
  id: item.merchandiseId,
103
98
  quantity: item.quantity ?? 1,
104
99
  sellingPlanId: item.sellingPlanId ?? void 0,
105
- properties,
100
+ properties: item.properties,
106
101
  merchandise
107
102
  };
108
103
  });
@@ -0,0 +1,50 @@
1
+ var EXPERIMENTS_COOKIE = "replo_experiments";
2
+ var EXPERIMENT_EXPOSURE_COOKIE = "replo_experiment_exposure";
3
+ var VISITOR_ID_COOKIE = "replo_visitor_id";
4
+ var EXPERIMENT_OVERRIDE_PARAM = "experiment";
5
+ var EXPERIMENT_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
6
+ var ENTRY_SEPARATOR = "|";
7
+ var KEY_SEPARATOR = ":";
8
+ var FORCED_SUFFIX = "!";
9
+ function parseExperimentCookie(value) {
10
+ const assignments = {};
11
+ if (!value) {
12
+ return assignments;
13
+ }
14
+ for (const entry of decodeCookieValue(value).split(ENTRY_SEPARATOR)) {
15
+ const forced = entry.endsWith(FORCED_SUFFIX);
16
+ const parsed = parseExperimentOverride(
17
+ forced ? entry.slice(0, -FORCED_SUFFIX.length) : entry
18
+ );
19
+ if (parsed) {
20
+ assignments[parsed.experimentKey] = {
21
+ variantKey: parsed.variantKey,
22
+ forced
23
+ };
24
+ }
25
+ }
26
+ return assignments;
27
+ }
28
+ function serializeExperimentCookie(assignments) {
29
+ return Object.entries(assignments).map(([experimentKey, { variantKey, forced }]) => {
30
+ return `${experimentKey}${KEY_SEPARATOR}${variantKey}${forced ? FORCED_SUFFIX : ""}`;
31
+ }).join(ENTRY_SEPARATOR);
32
+ }
33
+ function parseExperimentOverride(value) {
34
+ const separatorIndex = value.indexOf(KEY_SEPARATOR);
35
+ const experimentKey = value.slice(0, separatorIndex);
36
+ const variantKey = value.slice(separatorIndex + 1);
37
+ return separatorIndex !== -1 && EXPERIMENT_KEY_PATTERN.test(experimentKey) && EXPERIMENT_KEY_PATTERN.test(variantKey) ? { experimentKey, variantKey } : null;
38
+ }
39
+ function decodeCookieValue(value) {
40
+ if (!value.includes("%")) {
41
+ return value;
42
+ }
43
+ try {
44
+ return decodeURIComponent(value);
45
+ } catch {
46
+ return value;
47
+ }
48
+ }
49
+
50
+ export { EXPERIMENTS_COOKIE, EXPERIMENT_EXPOSURE_COOKIE, EXPERIMENT_KEY_PATTERN, EXPERIMENT_OVERRIDE_PARAM, VISITOR_ID_COOKIE, parseExperimentCookie, parseExperimentOverride, serializeExperimentCookie };
@@ -36,4 +36,15 @@ export declare function serializeConsentCookieValue({ categories, decidedAt, con
36
36
  decidedAt: number;
37
37
  consentId?: string;
38
38
  }): string;
39
+ /** A consent category a feature waits for; `null` when it needs none. */
40
+ export type RequiredConsent = Exclude<ConsentCategory, "necessary"> | null;
41
+ /** Whether the visitor's consent cookie grants `requiredConsent`. */
42
+ export declare function isConsentGranted({ cookies, requiredConsent, }: {
43
+ cookies: {
44
+ get: (name: string) => {
45
+ value: string;
46
+ } | undefined;
47
+ };
48
+ requiredConsent: RequiredConsent;
49
+ }): boolean;
39
50
  export {};
package/consent/cookie.js CHANGED
@@ -70,12 +70,23 @@ function serializeConsentCookieValue({
70
70
  };
71
71
  return encodeURIComponent(JSON.stringify(payload));
72
72
  }
73
+ function isConsentGranted({
74
+ cookies,
75
+ requiredConsent
76
+ }) {
77
+ if (requiredConsent === null) {
78
+ return true;
79
+ }
80
+ const consent = parseConsentCookie(cookies.get(CONSENT_COOKIE_NAME)?.value);
81
+ return consent?.categories[requiredConsent] ?? false;
82
+ }
73
83
  export {
74
84
  CONSENT_COOKIE_MAX_AGE_SECONDS,
75
85
  CONSENT_COOKIE_NAME,
76
86
  CONSENT_COOKIE_VERSION,
77
87
  DENIED_CATEGORIES,
78
88
  GRANTED_CATEGORIES,
89
+ isConsentGranted,
79
90
  parseConsentCookie,
80
91
  serializeConsentCookieValue
81
92
  };
package/env.d.ts CHANGED
@@ -6,5 +6,14 @@ type Env = {
6
6
  REPLO_SITE_VERSION?: string;
7
7
  CHECKOUT_PROVIDER?: "shopify" | "stripe" | "none";
8
8
  };
9
+ /** The read side of a Workers KV binding, as much as the SDK uses. */
10
+ export type SiteKV = {
11
+ get(key: string, options: {
12
+ type: "json";
13
+ cacheTtl: number;
14
+ }): Promise<unknown>;
15
+ };
16
+ /** The site's own KV namespace; null outside the deployed worker or on a build publish didn't bind one to. */
17
+ export declare const getSiteKV: () => Promise<SiteKV | null>;
9
18
  export declare const getEnv: () => Promise<Env>;
10
19
  export {};
package/env.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { getCloudflareContext } from "@opennextjs/cloudflare";
2
+ import { SITE_KV_BINDING } from "./_vendor/schemas/routing/experiments.mjs";
2
3
  import JSONC from "tiny-jsonc";
3
4
  import * as z from "zod";
4
5
  import { CanopyError } from "./lib/canopy-error";
@@ -16,6 +17,17 @@ class MissingWranglerConfigError extends CanopyError {
16
17
  const isDeployedWorker = () => {
17
18
  return process.env.NODE_ENV === "production" && process.env.BUILD_ENV !== "build" && process.env.NEXT_PHASE !== "phase-production-build";
18
19
  };
20
+ function isSiteKV(value) {
21
+ return typeof value === "object" && value !== null && "get" in value && typeof value.get === "function";
22
+ }
23
+ const getSiteKV = async () => {
24
+ if (globalThis.navigator?.userAgent !== "Cloudflare-Workers") {
25
+ return null;
26
+ }
27
+ const { env } = await getCloudflareContext({ async: true });
28
+ const binding = Reflect.get(env, SITE_KV_BINDING);
29
+ return isSiteKV(binding) ? binding : null;
30
+ };
19
31
  const getEnv = async () => {
20
32
  if (isDeployedWorker()) {
21
33
  const { env } = await getCloudflareContext({ async: true });
@@ -38,5 +50,6 @@ const getEnv = async () => {
38
50
  return wranglerConfigSchema.parse(JSONC.parse(raw)).vars;
39
51
  };
40
52
  export {
41
- getEnv
53
+ getEnv,
54
+ getSiteKV
42
55
  };
@@ -1,7 +1,7 @@
1
1
  export const BUILD_METADATA = {
2
2
  "packageName": "@replohq/sdk",
3
- "version": "2.10.1",
3
+ "version": "2.11.0",
4
4
  "branch": "HEAD",
5
- "commit": "65e727be083f9c9b51cc9aa247d471777050f32c",
6
- "builtAt": "2026-10-26T23:28:14.346Z"
5
+ "commit": "a6f8205ab3f0f29d31f2c5f4301a1f9fd81c179f",
6
+ "builtAt": "2026-10-28T03:16:19.137Z"
7
7
  };
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@replohq/sdk",
3
- "version": "2.10.1",
3
+ "version": "2.11.0",
4
4
  "reploBuild": {
5
5
  "packageName": "@replohq/sdk",
6
- "version": "2.10.1",
6
+ "version": "2.11.0",
7
7
  "branch": "HEAD",
8
- "commit": "65e727be083f9c9b51cc9aa247d471777050f32c",
9
- "builtAt": "2026-10-26T23:28:14.346Z"
8
+ "commit": "a6f8205ab3f0f29d31f2c5f4301a1f9fd81c179f",
9
+ "builtAt": "2026-10-28T03:16:19.137Z"
10
10
  },
11
11
  "description": "Replo SDK — cart, analytics, and data loaders for agent-built Next.js sites.",
12
12
  "license": "SEE LICENSE IN LICENSE",
@@ -313,6 +313,10 @@
313
313
  "types": "./routing/visitor-id.d.ts",
314
314
  "default": "./routing/visitor-id.js"
315
315
  },
316
+ "./routing/live-experiments": {
317
+ "types": "./routing/live-experiments.d.ts",
318
+ "default": "./routing/live-experiments.js"
319
+ },
316
320
  "./_vendor/schemas/loaderKeys": {
317
321
  "types": "./_vendor/schemas/loaderKeys.d.ts",
318
322
  "default": "./_vendor/schemas/loaderKeys.mjs"
@@ -1,9 +1,37 @@
1
1
  import type { NextRequest } from "next/server";
2
2
  import type { LocaleRoutingConfig } from "../_vendor/schemas/routing/locale";
3
3
  import type { RedirectRule } from "../_vendor/schemas/routing/rules";
4
+ import type { RequiredConsent } from "../consent/cookie";
4
5
  import { NextResponse } from "next/server";
5
- export declare function evaluateRouting({ request, rules, localeRouting, }: {
6
+ /**
7
+ * Routes a request in the site's `middleware.ts`: redirect rules first, then
8
+ * experiments, then locale routing.
9
+ *
10
+ * Pass the site's imported `experiments.json` through `liveExperiments` as
11
+ * `experiments`, so experiments start and stop without a publish; Replo's
12
+ * publish generates the file from the site's experiments. On a running
13
+ * experiment's route, each visitor is bucketed by a hash of their
14
+ * `replo_visitor_id` cookie, so they see the same variant on every visit, and
15
+ * the request is rewritten to their variant's page while the address bar keeps
16
+ * the shared URL. The assignment is recorded in the `replo_experiments` cookie
17
+ * for the analytics pixel and kept for the browser session, even if the
18
+ * rollout percentages change, and each counted visit sets a short-lived
19
+ * `replo_experiment_exposure` cookie that the pixel turns into a
20
+ * `replo.experiment_exposure` event. `?experiment=<experiment key>:<variant key>`
21
+ * pins a variant for QA for the browser session; pinned visits are left out of
22
+ * results. A stopped
23
+ * experiment with a shipped variant sends every visitor to that page. A
24
+ * challenger page under `/v/` is served with `X-Robots-Tag: noindex`. An
25
+ * invalid `experiments.json` is ignored, so every page renders as if no
26
+ * experiment existed.
27
+ *
28
+ * With an `experimentConsent` category, a visitor who hasn't granted it in
29
+ * Replo's consent banner sees the control and gets no experiment cookies.
30
+ */
31
+ export declare function evaluateRouting({ request, rules, localeRouting, experiments, experimentConsent, }: {
6
32
  request: NextRequest;
7
33
  rules: readonly RedirectRule[];
8
34
  localeRouting?: LocaleRoutingConfig;
35
+ experiments?: unknown;
36
+ experimentConsent?: RequiredConsent;
9
37
  }): NextResponse;
@@ -7,20 +7,39 @@ import {
7
7
  withLocalePrefix
8
8
  } from "../_vendor/schemas/routing/localeNegotiation.mjs";
9
9
  import { applyParams, matchSource } from "../_vendor/schemas/routing/match.mjs";
10
+ import { applyExperimentDecision, decideExperiment } from "./experiments";
10
11
  import { ipCountry } from "./geo";
11
12
  function evaluateRouting({
12
13
  request,
13
14
  rules,
14
- localeRouting
15
+ localeRouting,
16
+ experiments,
17
+ experimentConsent = null
15
18
  }) {
16
19
  const localeContext = localeRouting ? getLocaleContext({ request, config: localeRouting }) : null;
17
20
  for (const rule of rules) {
18
- const response = evaluateRedirect({ request, rule, localeContext });
19
- if (response) {
20
- return response;
21
+ const response2 = evaluateRedirect({ request, rule, localeContext });
22
+ if (response2) {
23
+ return response2;
21
24
  }
22
25
  }
23
- return evaluateLocalePhase({ request, localeContext });
26
+ const experimentDecision = experiments === void 0 || isLocaleExemptPath(request.nextUrl.pathname) ? null : decideExperiment({
27
+ request,
28
+ experiments,
29
+ logicalPath: localeContext?.pathLocale?.logicalPath ?? request.nextUrl.pathname,
30
+ requiredConsent: experimentConsent
31
+ });
32
+ const response = evaluateLocalePhase({
33
+ request,
34
+ localeContext,
35
+ servedPath: experimentDecision?.servedPath ?? null
36
+ });
37
+ return experimentDecision ? applyExperimentDecision({
38
+ request,
39
+ response,
40
+ decision: experimentDecision,
41
+ requiredConsent: experimentConsent
42
+ }) : response;
24
43
  }
25
44
  function getLocaleContext({
26
45
  request,
@@ -114,10 +133,11 @@ function applyLocaleToDestination({
114
133
  }
115
134
  function evaluateLocalePhase({
116
135
  request,
117
- localeContext
136
+ localeContext,
137
+ servedPath
118
138
  }) {
119
139
  if (!localeContext) {
120
- return NextResponse.next();
140
+ return servedPath === null ? NextResponse.next() : rewriteTo({ request, pathname: servedPath });
121
141
  }
122
142
  const { pathname } = request.nextUrl;
123
143
  if (isLocaleExemptPath(pathname)) {
@@ -126,42 +146,57 @@ function evaluateLocalePhase({
126
146
  const { urlMode, pathLocale, negotiated, scope, config } = localeContext;
127
147
  if (urlMode === "prefixed") {
128
148
  if (!pathLocale) {
129
- const url2 = request.nextUrl.clone();
130
- url2.pathname = withLocalePrefix({
149
+ const url = request.nextUrl.clone();
150
+ url.pathname = withLocalePrefix({
131
151
  locale: negotiated.locale,
132
152
  path: pathname
133
153
  });
134
- return NextResponse.redirect(url2, 307);
154
+ return NextResponse.redirect(url, 307);
135
155
  }
136
156
  const canonicalPath = canonicalPrefixedPathname({
137
157
  pathLocale,
138
158
  locales: config.locales
139
159
  });
140
160
  if (canonicalPath !== pathname) {
141
- const url2 = request.nextUrl.clone();
142
- url2.pathname = canonicalPath;
143
- return NextResponse.redirect(url2, 308);
161
+ const url = request.nextUrl.clone();
162
+ url.pathname = canonicalPath;
163
+ return NextResponse.redirect(url, 308);
144
164
  }
145
- const response = NextResponse.next();
165
+ const response = servedPath === null ? NextResponse.next() : rewriteTo({
166
+ request,
167
+ pathname: withLocalePrefix({
168
+ locale: pathLocale.locale,
169
+ path: servedPath
170
+ })
171
+ });
146
172
  if (pathLocale.locale !== negotiated.locale && request.cookies.get(REPLO_LOCALE_COOKIE)?.value !== pathLocale.locale && scope.locales.includes(pathLocale.locale)) {
147
173
  setLocaleCookie({ response, locale: pathLocale.locale });
148
174
  }
149
175
  return response;
150
176
  }
151
177
  if (pathLocale) {
152
- const url2 = request.nextUrl.clone();
153
- url2.pathname = pathLocale.logicalPath;
154
- const response = NextResponse.redirect(url2, 307);
178
+ const url = request.nextUrl.clone();
179
+ url.pathname = pathLocale.logicalPath;
180
+ const response = NextResponse.redirect(url, 307);
155
181
  if (scope.locales.includes(pathLocale.locale)) {
156
182
  setLocaleCookie({ response, locale: pathLocale.locale });
157
183
  }
158
184
  return response;
159
185
  }
160
- const url = request.nextUrl.clone();
161
- url.pathname = withLocalePrefix({
162
- locale: negotiated.locale,
163
- path: pathname
186
+ return rewriteTo({
187
+ request,
188
+ pathname: withLocalePrefix({
189
+ locale: negotiated.locale,
190
+ path: servedPath ?? pathname
191
+ })
164
192
  });
193
+ }
194
+ function rewriteTo({
195
+ request,
196
+ pathname
197
+ }) {
198
+ const url = request.nextUrl.clone();
199
+ url.pathname = pathname;
165
200
  return NextResponse.rewrite(url);
166
201
  }
167
202
  function canonicalPrefixedPathname({
@@ -0,0 +1,35 @@
1
+ import type { NextRequest, NextResponse } from "next/server";
2
+ import type { ExperimentAssignments } from "../_vendor/schemas/routing/experimentCookie";
3
+ import type { RequiredConsent } from "../consent/cookie";
4
+ /** The experiment phase's decision; `servedPath` is the locale-free route to render instead, or null for the requested page. */
5
+ export type ExperimentDecision = {
6
+ type: "assigned";
7
+ servedPath: string | null;
8
+ visitorId: string;
9
+ assignments: ExperimentAssignments;
10
+ /** `experimentKey:variantKey` of the counted exposure this response is; null for a pinned QA visit. */
11
+ exposure: string | null;
12
+ } | {
13
+ type: "shippedVariant";
14
+ servedPath: string | null;
15
+ } | {
16
+ type: "variantRoute";
17
+ servedPath: null;
18
+ } | {
19
+ type: "consentWithdrawn";
20
+ servedPath: null;
21
+ };
22
+ /** The variant to serve on an experiment's route; null when none applies, including for an invalid file, so it never takes the site down. */
23
+ export declare function decideExperiment({ request, experiments: rawExperiments, logicalPath, requiredConsent, }: {
24
+ request: NextRequest;
25
+ experiments: unknown;
26
+ logicalPath: string;
27
+ requiredConsent: RequiredConsent;
28
+ }): ExperimentDecision | null;
29
+ /** Stamps the decision onto the routed response; redirects are left alone, since the rendering request buckets. */
30
+ export declare function applyExperimentDecision({ request, response, decision, requiredConsent, }: {
31
+ request: NextRequest;
32
+ response: NextResponse;
33
+ decision: ExperimentDecision;
34
+ requiredConsent: RequiredConsent;
35
+ }): NextResponse;
@@ -0,0 +1,215 @@
1
+ import {
2
+ EXPERIMENT_EXPOSURE_COOKIE,
3
+ EXPERIMENT_OVERRIDE_PARAM,
4
+ EXPERIMENTS_COOKIE,
5
+ parseExperimentCookie,
6
+ parseExperimentOverride,
7
+ serializeExperimentCookie,
8
+ VISITOR_ID_COOKIE
9
+ } from "../_vendor/schemas/routing/experimentCookie.mjs";
10
+ import {
11
+ assignVariant,
12
+ experimentsFileSchema,
13
+ findActiveExperiment,
14
+ findServedVariants,
15
+ isRoutingExperiment,
16
+ normalizeRoute
17
+ } from "../_vendor/schemas/routing/experiments.mjs";
18
+ import { isConsentGranted } from "../consent/cookie";
19
+ import { ensureVisitorId } from "./visitor-id";
20
+ const parsedFiles = /* @__PURE__ */ new WeakMap();
21
+ function decideExperiment({
22
+ request,
23
+ experiments: rawExperiments,
24
+ logicalPath,
25
+ requiredConsent
26
+ }) {
27
+ if (request.method !== "GET" && request.method !== "HEAD") {
28
+ return null;
29
+ }
30
+ const experiments = parseExperiments(rawExperiments);
31
+ if (!experiments) {
32
+ return null;
33
+ }
34
+ const isPreview = process.env.NODE_ENV === "development";
35
+ if (isPreview && !request.nextUrl.searchParams.has(EXPERIMENT_OVERRIDE_PARAM)) {
36
+ return null;
37
+ }
38
+ const experiment = findActiveExperiment({
39
+ experiments,
40
+ pathname: logicalPath
41
+ });
42
+ if (!experiment) {
43
+ return logicalPath.startsWith("/v/") && isChallengerRoute({ experiments, logicalPath }) ? { type: "variantRoute", servedPath: null } : null;
44
+ }
45
+ if (experiment.status === "stopped") {
46
+ const shippedVariant = experiment.variants.find((variant2) => {
47
+ return variant2.variantKey === experiment.shippedVariantKey;
48
+ });
49
+ return shippedVariant ? {
50
+ type: "shippedVariant",
51
+ servedPath: servedPathFor({ variant: shippedVariant, logicalPath })
52
+ } : null;
53
+ }
54
+ if (!isConsentGranted({ cookies: request.cookies, requiredConsent })) {
55
+ return request.cookies.has(EXPERIMENTS_COOKIE) || request.cookies.has(EXPERIMENT_EXPOSURE_COOKIE) ? { type: "consentWithdrawn", servedPath: null } : null;
56
+ }
57
+ const existingVisitorId = request.cookies.get(VISITOR_ID_COOKIE)?.value;
58
+ const visitorId = existingVisitorId ? existingVisitorId : crypto.randomUUID();
59
+ const existing = isPreview ? {} : parseExperimentCookie(request.cookies.get(EXPERIMENTS_COOKIE)?.value);
60
+ const override = readOverride({ request, experiment });
61
+ const stored = existing[experiment.experimentKey];
62
+ const pinnedKey = stored?.forced ? stored.variantKey : null;
63
+ const forcedKey = override === void 0 ? pinnedKey : override;
64
+ const forcedVariant = experiment.variants.find((variant2) => variant2.variantKey === forcedKey) ?? null;
65
+ const keptVariant = stored && !stored.forced && override === void 0 ? experiment.variants.find((candidate) => {
66
+ return candidate.variantKey === stored.variantKey;
67
+ }) : void 0;
68
+ const variant = forcedVariant ?? keptVariant ?? assignVariant({ visitorId, experiment });
69
+ return {
70
+ type: "assigned",
71
+ servedPath: servedPathFor({ variant, logicalPath }),
72
+ visitorId,
73
+ assignments: {
74
+ ...keepRunningAssignments({ experiments, assignments: existing }),
75
+ [experiment.experimentKey]: {
76
+ variantKey: variant.variantKey,
77
+ forced: Boolean(forcedVariant)
78
+ }
79
+ },
80
+ exposure: forcedVariant ? null : `${experiment.experimentKey}:${variant.variantKey}`
81
+ };
82
+ }
83
+ function applyExperimentDecision({
84
+ request,
85
+ response,
86
+ decision,
87
+ requiredConsent
88
+ }) {
89
+ if (response.status >= 300 && response.status < 400) {
90
+ return response;
91
+ }
92
+ if (decision.type === "variantRoute") {
93
+ response.headers.set("X-Robots-Tag", "noindex");
94
+ return response;
95
+ }
96
+ if (decision.type === "shippedVariant") {
97
+ return response;
98
+ }
99
+ if (decision.type === "consentWithdrawn") {
100
+ response.cookies.delete(EXPERIMENTS_COOKIE);
101
+ response.cookies.delete(EXPERIMENT_EXPOSURE_COOKIE);
102
+ return response;
103
+ }
104
+ const value = serializeExperimentCookie(decision.assignments);
105
+ if (value !== request.cookies.get(EXPERIMENTS_COOKIE)?.value) {
106
+ response.cookies.set({
107
+ name: EXPERIMENTS_COOKIE,
108
+ value,
109
+ path: "/",
110
+ sameSite: "lax",
111
+ secure: request.nextUrl.protocol === "https:",
112
+ httpOnly: false
113
+ });
114
+ }
115
+ if (decision.exposure !== null) {
116
+ response.cookies.set({
117
+ name: EXPERIMENT_EXPOSURE_COOKIE,
118
+ value: decision.exposure,
119
+ path: "/",
120
+ sameSite: "lax",
121
+ secure: request.nextUrl.protocol === "https:",
122
+ httpOnly: false,
123
+ maxAge: 60
124
+ });
125
+ }
126
+ ensureVisitorId({
127
+ request,
128
+ response,
129
+ cookieName: VISITOR_ID_COOKIE,
130
+ cookieDomain: null,
131
+ requiredConsent,
132
+ visitorId: decision.visitorId
133
+ });
134
+ response.headers.set("Cache-Control", "private, no-store");
135
+ return response;
136
+ }
137
+ function parseExperiments(rawExperiments) {
138
+ const isObject = typeof rawExperiments === "object" && rawExperiments !== null;
139
+ if (isObject && parsedFiles.has(rawExperiments)) {
140
+ return parsedFiles.get(rawExperiments) ?? null;
141
+ }
142
+ const parsed = experimentsFileSchema.safeParse(rawExperiments);
143
+ const experiments = parsed.success ? withLivePagesOnly({
144
+ experiments: parsed.data.experiments,
145
+ liveRoutes: parsed.data.liveRoutes
146
+ }) : null;
147
+ if (isObject) {
148
+ parsedFiles.set(rawExperiments, experiments);
149
+ }
150
+ return experiments;
151
+ }
152
+ function withLivePagesOnly({
153
+ experiments,
154
+ liveRoutes
155
+ }) {
156
+ if (!liveRoutes) {
157
+ return experiments;
158
+ }
159
+ const live = new Set(liveRoutes.map(normalizeRoute));
160
+ return experiments.filter((experiment) => {
161
+ return findServedVariants(experiment).every((variant) => {
162
+ return live.has(normalizeRoute(variant.route));
163
+ });
164
+ });
165
+ }
166
+ function readOverride({
167
+ request,
168
+ experiment
169
+ }) {
170
+ const value = request.nextUrl.searchParams.get(EXPERIMENT_OVERRIDE_PARAM);
171
+ if (value === null) {
172
+ return void 0;
173
+ }
174
+ const override = parseExperimentOverride(value);
175
+ if (override && override.experimentKey !== experiment.experimentKey) {
176
+ return void 0;
177
+ }
178
+ return override?.variantKey ?? null;
179
+ }
180
+ function keepRunningAssignments({
181
+ experiments,
182
+ assignments
183
+ }) {
184
+ const runningKeys = new Set(
185
+ experiments.filter((experiment) => experiment.status === "running").map((experiment) => experiment.experimentKey)
186
+ );
187
+ const kept = {};
188
+ for (const [experimentKey, assignment] of Object.entries(assignments)) {
189
+ if (runningKeys.has(experimentKey)) {
190
+ kept[experimentKey] = assignment;
191
+ }
192
+ }
193
+ return kept;
194
+ }
195
+ function isChallengerRoute({
196
+ experiments,
197
+ logicalPath
198
+ }) {
199
+ const route = normalizeRoute(logicalPath);
200
+ return experiments.filter(isRoutingExperiment).some((experiment) => {
201
+ return experiment.variants.slice(1).some((variant) => {
202
+ return normalizeRoute(variant.route) === route;
203
+ });
204
+ });
205
+ }
206
+ function servedPathFor({
207
+ variant,
208
+ logicalPath
209
+ }) {
210
+ return normalizeRoute(variant.route) === normalizeRoute(logicalPath) ? null : variant.route;
211
+ }
212
+ export {
213
+ applyExperimentDecision,
214
+ decideExperiment
215
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Returns the site's experiment routing as it is in Replo right now, so
3
+ * launching, ending or re-splitting an experiment reaches visitors without a
4
+ * publish. Pass the site's imported `experiments.json` and hand the result to
5
+ * `evaluateRouting` as `experiments`.
6
+ *
7
+ * On a published site the routing is read from the site's own KV store and
8
+ * kept in memory for 30 seconds; the first request after that rereads it,
9
+ * waiting up to 800ms before using the copy it has. The first request in a
10
+ * fresh worker waits the same way, and if the store can't be read, no experiment
11
+ * runs and every visitor gets the original page. In local development and the Site Builder preview, which
12
+ * have no store, the imported file is used as is, so `?experiment=` previews work.
13
+ * Either way, only pages listed in the file's `liveRoutes` are ever served,
14
+ * so an experiment never sends a visitor to a page this build doesn't have.
15
+ */
16
+ export declare function liveExperiments(experiments: unknown): Promise<unknown>;
@@ -0,0 +1,59 @@
1
+ import {
2
+ experimentsFileSchema,
3
+ SITE_KV_EXPERIMENTS_KEY
4
+ } from "../_vendor/schemas/routing/experiments.mjs";
5
+ import { getSiteKV } from "../env";
6
+ const REFRESH_AFTER_MS = 3e4;
7
+ const KV_CACHE_TTL_SECONDS = 60;
8
+ const READ_TIMEOUT_MS = 800;
9
+ const RETRY_AFTER_MS = 5e3;
10
+ let cache = null;
11
+ async function liveExperiments(experiments) {
12
+ const bundled = experimentsFileSchema.safeParse(experiments);
13
+ if (!bundled.success) {
14
+ return experiments;
15
+ }
16
+ const kv = await getSiteKV().catch(() => null);
17
+ if (!kv) {
18
+ return experiments;
19
+ }
20
+ const { experiments: _bundledExperiments, ...build } = bundled.data;
21
+ const now = Date.now();
22
+ if (cache) {
23
+ const isStale = now - cache.fetchedAt > REFRESH_AFTER_MS;
24
+ const isRefreshing = cache.refreshStartedAt !== null && now - cache.refreshStartedAt < RETRY_AFTER_MS;
25
+ if (!isStale || isRefreshing) {
26
+ return cache.value;
27
+ }
28
+ const stale = cache.value;
29
+ cache.refreshStartedAt = now;
30
+ return await readWithinTimeout({ kv, build }) ?? stale;
31
+ }
32
+ const noTests = { ...build, experiments: [] };
33
+ cache = { value: noTests, fetchedAt: 0, refreshStartedAt: now };
34
+ return await readWithinTimeout({ kv, build }) ?? noTests;
35
+ }
36
+ function readWithinTimeout(args) {
37
+ return Promise.race([
38
+ refresh(args).catch(() => null),
39
+ new Promise((resolve) => {
40
+ setTimeout(() => resolve(null), READ_TIMEOUT_MS);
41
+ })
42
+ ]);
43
+ }
44
+ async function refresh({
45
+ kv,
46
+ build
47
+ }) {
48
+ const stored = await kv.get(SITE_KV_EXPERIMENTS_KEY, {
49
+ type: "json",
50
+ cacheTtl: KV_CACHE_TTL_SECONDS
51
+ });
52
+ const experiments = stored === null ? [] : experimentsFileSchema.parse(stored).experiments;
53
+ const value = { ...build, experiments };
54
+ cache = { value, fetchedAt: Date.now(), refreshStartedAt: null };
55
+ return value;
56
+ }
57
+ export {
58
+ liveExperiments
59
+ };
@@ -1,5 +1,5 @@
1
1
  import type { NextRequest, NextResponse } from "next/server";
2
- import type { ConsentCategory } from "../consent/types";
2
+ import type { RequiredConsent } from "../consent/cookie";
3
3
  /**
4
4
  * Gives a first-time visitor a durable visitor ID (a UUID) in the `cookieName`
5
5
  * cookie, so experiments bucket every page view of theirs the same way. Call it
@@ -16,13 +16,17 @@ import type { ConsentCategory } from "../consent/types";
16
16
  * With a `requiredConsent` category, the ID is only created once the visitor
17
17
  * has granted that category in Replo's consent banner. Pass `null` when the
18
18
  * site needs no consent for it.
19
+ *
20
+ * Pass `visitorId` when the middleware already bucketed this request with a
21
+ * freshly generated ID, so the cookie stores the ID the response was built for.
19
22
  */
20
- export declare function ensureVisitorId({ request, response, cookieName, cookieDomain, requiredConsent, }: {
23
+ export declare function ensureVisitorId({ request, response, cookieName, cookieDomain, requiredConsent, visitorId, }: {
21
24
  request: NextRequest;
22
25
  response: NextResponse;
23
26
  cookieName: string;
24
27
  cookieDomain: string | null;
25
- requiredConsent: VisitorIdConsent;
28
+ requiredConsent: RequiredConsent;
29
+ visitorId?: string;
26
30
  }): NextResponse;
27
31
  /**
28
32
  * Reads the visitor ID for an experiment evaluation, or `null` when the
@@ -34,8 +38,7 @@ export declare function ensureVisitorId({ request, response, cookieName, cookieD
34
38
  export declare function findVisitorId({ cookies, cookieName, requiredConsent, }: {
35
39
  cookies: VisitorIdCookies;
36
40
  cookieName: string;
37
- requiredConsent: VisitorIdConsent;
41
+ requiredConsent: RequiredConsent;
38
42
  }): string | null;
39
43
  type VisitorIdCookies = Pick<NextRequest["cookies"], "get">;
40
- type VisitorIdConsent = Exclude<ConsentCategory, "necessary"> | null;
41
44
  export {};
@@ -1,4 +1,4 @@
1
- import { CONSENT_COOKIE_NAME, parseConsentCookie } from "../consent/cookie";
1
+ import { isConsentGranted } from "../consent/cookie";
2
2
  const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;
3
3
  const SHARED_REPLO_DOMAINS = /* @__PURE__ */ new Set([
4
4
  "reploshops.com",
@@ -22,7 +22,8 @@ function ensureVisitorId({
22
22
  response,
23
23
  cookieName,
24
24
  cookieDomain,
25
- requiredConsent
25
+ requiredConsent,
26
+ visitorId = crypto.randomUUID()
26
27
  }) {
27
28
  const isRedirect = response.status >= 300 && response.status < 400;
28
29
  const isAlreadySet = response.headers.getSetCookie().some((setCookie) => {
@@ -40,7 +41,7 @@ function ensureVisitorId({
40
41
  });
41
42
  response.cookies.set({
42
43
  name: cookieName,
43
- value: crypto.randomUUID(),
44
+ value: visitorId,
44
45
  path: "/",
45
46
  maxAge: ONE_YEAR_SECONDS,
46
47
  sameSite: "lax",
@@ -65,16 +66,6 @@ function findVisitorId({
65
66
  }
66
67
  return visitorId;
67
68
  }
68
- function isConsentGranted({
69
- cookies,
70
- requiredConsent
71
- }) {
72
- if (requiredConsent === null) {
73
- return true;
74
- }
75
- const consent = parseConsentCookie(cookies.get(CONSENT_COOKIE_NAME)?.value);
76
- return consent?.categories[requiredConsent] ?? false;
77
- }
78
69
  function findCookieDomain({
79
70
  hostname,
80
71
  cookieDomain