@replohq/sdk 2.10.2 → 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.
- package/_vendor/schemas/routing/experimentCookie.d.ts +24 -0
- package/_vendor/schemas/routing/experimentCookie.mjs +1 -0
- package/_vendor/schemas/routing/experiments.d.ts +73 -0
- package/_vendor/schemas/routing/experiments.mjs +164 -0
- package/chunk-PH6IR743.mjs +50 -0
- package/consent/cookie.d.ts +11 -0
- package/consent/cookie.js +11 -0
- package/env.d.ts +9 -0
- package/env.js +14 -1
- package/lib/buildMetadata.js +3 -3
- package/package.json +8 -4
- package/routing/evaluate.d.ts +29 -1
- package/routing/evaluate.js +56 -21
- package/routing/experiments.d.ts +35 -0
- package/routing/experiments.js +215 -0
- package/routing/live-experiments.d.ts +16 -0
- package/routing/live-experiments.js +59 -0
- package/routing/visitor-id.d.ts +8 -5
- package/routing/visitor-id.js +4 -13
|
@@ -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 };
|
|
@@ -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 };
|
package/consent/cookie.d.ts
CHANGED
|
@@ -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
|
};
|
package/lib/buildMetadata.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const BUILD_METADATA = {
|
|
2
2
|
"packageName": "@replohq/sdk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"branch": "HEAD",
|
|
5
|
-
"commit": "
|
|
6
|
-
"builtAt": "2026-10-
|
|
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.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"reploBuild": {
|
|
5
5
|
"packageName": "@replohq/sdk",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.11.0",
|
|
7
7
|
"branch": "HEAD",
|
|
8
|
-
"commit": "
|
|
9
|
-
"builtAt": "2026-10-
|
|
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"
|
package/routing/evaluate.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/routing/evaluate.js
CHANGED
|
@@ -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
|
|
19
|
-
if (
|
|
20
|
-
return
|
|
21
|
+
const response2 = evaluateRedirect({ request, rule, localeContext });
|
|
22
|
+
if (response2) {
|
|
23
|
+
return response2;
|
|
21
24
|
}
|
|
22
25
|
}
|
|
23
|
-
|
|
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
|
|
130
|
-
|
|
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(
|
|
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
|
|
142
|
-
|
|
143
|
-
return NextResponse.redirect(
|
|
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
|
|
153
|
-
|
|
154
|
-
const response = NextResponse.redirect(
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
+
};
|
package/routing/visitor-id.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import type {
|
|
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:
|
|
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:
|
|
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 {};
|
package/routing/visitor-id.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
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
|