@cascayd/experiment 0.3.20 → 0.3.22
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/dist/assignment.d.ts +20 -0
- package/dist/assignment.js +56 -0
- package/dist/client.d.ts +1 -7
- package/dist/client.js +5 -190
- package/dist/http.d.ts +2 -0
- package/dist/http.js +11 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/next-app/Experiment.d.ts +5 -0
- package/dist/next-app/Experiment.js +64 -0
- package/dist/next-app/ImpressionClient.d.ts +4 -0
- package/dist/next-app/ImpressionClient.js +14 -0
- package/dist/next-app/Variant.d.ts +9 -0
- package/dist/next-app/Variant.js +5 -0
- package/dist/next-app/index.d.ts +2 -0
- package/dist/next-app/index.js +2 -0
- package/dist/react/Experiment.js +22 -155
- package/dist/settings.d.ts +1 -0
- package/dist/settings.js +2 -0
- package/dist/types.d.ts +0 -3
- package/package.json +10 -4
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type ChildVariant = {
|
|
2
|
+
id: string;
|
|
3
|
+
weight?: number;
|
|
4
|
+
};
|
|
5
|
+
export type WeightMap = Record<string, number>;
|
|
6
|
+
export declare function buildWeights(children: ChildVariant[], weights: WeightMap | undefined): Array<{
|
|
7
|
+
id: string;
|
|
8
|
+
weight: number;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function resolveVariant(candidate: string | null | undefined, children: Array<{
|
|
11
|
+
id: string;
|
|
12
|
+
}>): string;
|
|
13
|
+
/**
|
|
14
|
+
* Deterministic roll based on (experimentId + ":" + seedKey).
|
|
15
|
+
* seedKey should be stable per user, eg sessionId from cookie.
|
|
16
|
+
*/
|
|
17
|
+
export declare function chooseByWeightDeterministic(items: Array<{
|
|
18
|
+
id: string;
|
|
19
|
+
weight: number;
|
|
20
|
+
}>, experimentId: string, seedKey: string): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export function buildWeights(children, weights) {
|
|
2
|
+
// Prefer server weights if present
|
|
3
|
+
if (weights && Object.keys(weights).length > 0) {
|
|
4
|
+
const subset = children
|
|
5
|
+
.map((child) => ({ id: child.id, weight: typeof weights[child.id] === 'number' ? weights[child.id] : 0 }))
|
|
6
|
+
.filter((item) => item.weight > 0);
|
|
7
|
+
if (subset.length > 0) {
|
|
8
|
+
const total = subset.reduce((sum, item) => sum + item.weight, 0) || 1;
|
|
9
|
+
return subset.map((item) => ({ id: item.id, weight: item.weight / total }));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// Fall back to provided per-child weights
|
|
13
|
+
const provided = children.filter((c) => typeof c.weight === 'number');
|
|
14
|
+
if (provided.length > 0) {
|
|
15
|
+
const sum = provided.reduce((acc, item) => acc + item.weight, 0) || 1;
|
|
16
|
+
return provided.map((item) => ({ id: item.id, weight: item.weight / sum }));
|
|
17
|
+
}
|
|
18
|
+
// Equal split
|
|
19
|
+
if (children.length === 0)
|
|
20
|
+
return [{ id: 'control', weight: 1 }];
|
|
21
|
+
const equal = 1 / children.length;
|
|
22
|
+
return children.map((c) => ({ id: c.id, weight: equal }));
|
|
23
|
+
}
|
|
24
|
+
export function resolveVariant(candidate, children) {
|
|
25
|
+
if (candidate && children.some((c) => c.id === candidate))
|
|
26
|
+
return candidate;
|
|
27
|
+
if (children.some((c) => c.id === 'control'))
|
|
28
|
+
return 'control';
|
|
29
|
+
return children[0]?.id ?? 'control';
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Deterministic roll based on (experimentId + ":" + seedKey).
|
|
33
|
+
* seedKey should be stable per user, eg sessionId from cookie.
|
|
34
|
+
*/
|
|
35
|
+
export function chooseByWeightDeterministic(items, experimentId, seedKey) {
|
|
36
|
+
const total = items.reduce((sum, item) => sum + item.weight, 0) || 1;
|
|
37
|
+
const seed = `${experimentId}:${seedKey}`;
|
|
38
|
+
const roll = hashToUnit(seed) * total;
|
|
39
|
+
let acc = 0;
|
|
40
|
+
for (const item of items) {
|
|
41
|
+
acc += item.weight;
|
|
42
|
+
if (roll <= acc)
|
|
43
|
+
return item.id;
|
|
44
|
+
}
|
|
45
|
+
return items[items.length - 1]?.id || 'control';
|
|
46
|
+
}
|
|
47
|
+
function hashToUnit(input) {
|
|
48
|
+
// FNV-1a 32-bit hash -> [0,1)
|
|
49
|
+
let hash = 2166136261;
|
|
50
|
+
for (let i = 0; i < input.length; i++) {
|
|
51
|
+
hash ^= input.charCodeAt(i);
|
|
52
|
+
hash = Math.imul(hash, 16777619);
|
|
53
|
+
}
|
|
54
|
+
// >>> 0 makes it unsigned
|
|
55
|
+
return (hash >>> 0) / 2 ** 32;
|
|
56
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,8 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export declare function initCascayd(opts: InitOptions): void;
|
|
3
|
-
export declare function getVariantStatus(experimentId: string, variantId: string): Promise<VariantStatusResponse>;
|
|
4
|
-
export declare function assignVariant(experimentId: string, detectedVariants?: string[], route?: string): Promise<{
|
|
5
|
-
variantId: string;
|
|
6
|
-
config: ExperimentConfigResponse;
|
|
7
|
-
}>;
|
|
1
|
+
import type { EventType, RecordOptions } from './types';
|
|
8
2
|
export declare function record(type: EventType, opts?: RecordOptions): Promise<void>;
|
package/dist/client.js
CHANGED
|
@@ -1,168 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
let API_KEY = '';
|
|
4
|
-
let BASE_URL = 'https://ab-mvp-backend.onrender.com';
|
|
5
|
-
const SDK_VERSION = '0.2.0-dev';
|
|
6
|
-
export function initCascayd(opts) {
|
|
7
|
-
API_KEY = opts.apiKey;
|
|
8
|
-
const masked = API_KEY ? API_KEY.slice(0, 3) + '***' + API_KEY.slice(-3) : '(empty)';
|
|
9
|
-
// Visible init log to confirm updated SDK loaded
|
|
10
|
-
// eslint-disable-next-line no-console
|
|
11
|
-
console.log('[cascayd-sdk]', SDK_VERSION, { baseUrl: BASE_URL, apiKey: masked });
|
|
12
|
-
console.log('[cascayd-sdk] init complete', {
|
|
13
|
-
version: SDK_VERSION,
|
|
14
|
-
url: BASE_URL,
|
|
15
|
-
});
|
|
16
|
-
}
|
|
17
|
-
async function fetchConfig(experimentId) {
|
|
18
|
-
//! SSR: fetch works in Node 18+ but may need request context (headers, cookies) for auth
|
|
19
|
-
const res = await fetch(`${BASE_URL}/experiments/${encodeURIComponent(experimentId)}/config`);
|
|
20
|
-
if (!res.ok)
|
|
21
|
-
throw new Error(`Config load failed: ${res.status}`);
|
|
22
|
-
return (await res.json());
|
|
23
|
-
}
|
|
24
|
-
export async function getVariantStatus(experimentId, variantId) {
|
|
25
|
-
console.log('[cascayd-sdk] getVariantStatus', experimentId, variantId);
|
|
26
|
-
//! SSR: fetch works in Node 18+ but may need request context (headers, cookies) for auth
|
|
27
|
-
const res = await fetch(`${BASE_URL}/experiments/${encodeURIComponent(experimentId)}/variants/${encodeURIComponent(variantId)}/status`);
|
|
28
|
-
const data = (await res.json());
|
|
29
|
-
console.log('[cascayd-sdk] getVariantStatus response', data);
|
|
30
|
-
if (!res.ok) {
|
|
31
|
-
const err = new Error(`Variant status failed: ${res.status}`);
|
|
32
|
-
err.payload = data;
|
|
33
|
-
throw err;
|
|
34
|
-
}
|
|
35
|
-
return data;
|
|
36
|
-
}
|
|
37
|
-
// Seeded random number generator using a simple linear congruential generator
|
|
38
|
-
function seededRandom(seed) {
|
|
39
|
-
// LCG parameters (same as used in many standard libraries)
|
|
40
|
-
const a = 1664525;
|
|
41
|
-
const c = 1013904223;
|
|
42
|
-
const m = Math.pow(2, 32);
|
|
43
|
-
seed = (a * seed + c) % m;
|
|
44
|
-
return seed / m;
|
|
45
|
-
}
|
|
46
|
-
function chooseByWeight(variants, experimentId) {
|
|
47
|
-
console.log('[cascayd-sdk] chooseByWeight called with variants:', variants);
|
|
48
|
-
const total = variants.reduce((s, v) => s + v.weight, 0);
|
|
49
|
-
console.log('[cascayd-sdk] chooseByWeight total weight:', total);
|
|
50
|
-
//! SSR: Date.now() is non-deterministic - variant will change on every render/request
|
|
51
|
-
// Create a seed based on current time (milliseconds) and experiment ID
|
|
52
|
-
// This makes it change over time while being deterministic for the same millisecond
|
|
53
|
-
const now = Date.now();
|
|
54
|
-
const seedString = `${experimentId}-${now}`;
|
|
55
|
-
// Simple hash function to convert string to number
|
|
56
|
-
let hash = 0;
|
|
57
|
-
for (let i = 0; i < seedString.length; i++) {
|
|
58
|
-
const char = seedString.charCodeAt(i);
|
|
59
|
-
hash = ((hash << 5) - hash) + char;
|
|
60
|
-
hash = hash & hash; // Convert to 32-bit integer
|
|
61
|
-
}
|
|
62
|
-
// Use seeded random instead of Math.random()
|
|
63
|
-
const r = seededRandom(Math.abs(hash)) * total;
|
|
64
|
-
console.log('[cascayd-sdk] chooseByWeight seed:', seedString, 'hash:', Math.abs(hash), 'random value:', r, 'total:', total);
|
|
65
|
-
let acc = 0;
|
|
66
|
-
for (const v of variants) {
|
|
67
|
-
acc += v.weight;
|
|
68
|
-
console.log('[cascayd-sdk] chooseByWeight checking variant:', v.id, 'weight:', v.weight, 'acc:', acc, 'r <= acc?', r <= acc);
|
|
69
|
-
if (r <= acc) {
|
|
70
|
-
console.log('[cascayd-sdk] chooseByWeight selected:', v.id);
|
|
71
|
-
return v.id;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
const fallback = variants[variants.length - 1]?.id ?? 'control';
|
|
75
|
-
console.log('[cascayd-sdk] chooseByWeight fallback to:', fallback);
|
|
76
|
-
return fallback;
|
|
77
|
-
}
|
|
78
|
-
export async function assignVariant(experimentId, detectedVariants, route) {
|
|
79
|
-
const baseStatus = await getVariantStatus(experimentId, 'control');
|
|
80
|
-
let weights = baseStatus.weights || {};
|
|
81
|
-
console.log('[cascayd-sdk] assignVariant weights received:', weights);
|
|
82
|
-
// If we have detected variants, ensure they exist in the database
|
|
83
|
-
if (detectedVariants && detectedVariants.length > 0) {
|
|
84
|
-
// Check if all detected variants exist in weights
|
|
85
|
-
const missingVariants = detectedVariants.filter(vid => !(vid in weights));
|
|
86
|
-
if (missingVariants.length > 0) {
|
|
87
|
-
console.log('[cascayd-sdk] assignVariant: Missing variants detected, ensuring they exist:', missingVariants);
|
|
88
|
-
try {
|
|
89
|
-
// Call ensure endpoint to create missing variants
|
|
90
|
-
const ensureBody = {
|
|
91
|
-
experiment_id: experimentId,
|
|
92
|
-
variant_ids: detectedVariants,
|
|
93
|
-
//! SSR: window.location.pathname is browser-only, route should be passed explicitly on server
|
|
94
|
-
route: route || (typeof window !== 'undefined' ? window.location.pathname : "/"),
|
|
95
|
-
};
|
|
96
|
-
//! SSR: fetch works in Node 18+ but may need request context (headers, cookies) for auth
|
|
97
|
-
const ensureRes = await fetch(`${BASE_URL}/experiments/ensure`, {
|
|
98
|
-
method: 'POST',
|
|
99
|
-
headers: {
|
|
100
|
-
'Content-Type': 'application/json',
|
|
101
|
-
Authorization: `Bearer ${API_KEY}`,
|
|
102
|
-
},
|
|
103
|
-
body: JSON.stringify(ensureBody),
|
|
104
|
-
});
|
|
105
|
-
if (ensureRes.ok) {
|
|
106
|
-
console.log('[cascayd-sdk] assignVariant: Successfully ensured variants exist');
|
|
107
|
-
// Fetch status again to get updated weights
|
|
108
|
-
const updatedStatus = await getVariantStatus(experimentId, 'control');
|
|
109
|
-
weights = updatedStatus.weights || {};
|
|
110
|
-
console.log('[cascayd-sdk] assignVariant: Updated weights after ensuring:', weights);
|
|
111
|
-
}
|
|
112
|
-
else {
|
|
113
|
-
console.warn('[cascayd-sdk] assignVariant: Failed to ensure variants, using fallback weights');
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
catch (error) {
|
|
117
|
-
console.error('[cascayd-sdk] assignVariant: Error ensuring variants:', error);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
// If weights only has control and we have detected variants, use detected variants with equal weights (fallback)
|
|
122
|
-
if (Object.keys(weights).length === 1 && weights.control === 1 && detectedVariants && detectedVariants.length > 1) {
|
|
123
|
-
console.log('[cascayd-sdk] assignVariant: Only control in weights, using detected variants as fallback:', detectedVariants);
|
|
124
|
-
const equalWeight = 1 / detectedVariants.length;
|
|
125
|
-
weights = {};
|
|
126
|
-
detectedVariants.forEach(vid => {
|
|
127
|
-
weights[vid] = equalWeight;
|
|
128
|
-
});
|
|
129
|
-
console.log('[cascayd-sdk] assignVariant: Created equal weights from detected variants:', weights);
|
|
130
|
-
}
|
|
131
|
-
console.log('[cascayd-sdk] assignVariant weights entries:', Object.entries(weights));
|
|
132
|
-
const variants = Object.entries(weights).map(([id, weight]) => ({ id, weight }));
|
|
133
|
-
console.log('[cascayd-sdk] assignVariant variants array:', variants);
|
|
134
|
-
let candidate = chooseByWeight(variants.length > 0 ? variants : baseStatusToVariants(baseStatus), experimentId);
|
|
135
|
-
console.log('[cascayd-sdk] assignVariant candidate selected:', candidate);
|
|
136
|
-
let finalStatus = null;
|
|
137
|
-
if (candidate) {
|
|
138
|
-
try {
|
|
139
|
-
finalStatus = await getVariantStatus(experimentId, candidate);
|
|
140
|
-
}
|
|
141
|
-
catch (error) {
|
|
142
|
-
console.error('[cascayd-sdk] getVariantStatus failed', { experimentId, candidate, error });
|
|
143
|
-
const weights = error?.payload?.weights;
|
|
144
|
-
if (weights) {
|
|
145
|
-
console.log('[cascayd-sdk] getVariantStatus failed weights', weights);
|
|
146
|
-
}
|
|
147
|
-
// swallow and fall back to candidate
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
const serving = finalStatus?.serving_variant_id || candidate || 'control';
|
|
151
|
-
const config = {
|
|
152
|
-
experiment_id: baseStatus.experiment_id,
|
|
153
|
-
variants: variants.length > 0 ? variants : baseStatusToVariants(baseStatus),
|
|
154
|
-
};
|
|
155
|
-
return { variantId: serving, config };
|
|
156
|
-
}
|
|
157
|
-
function baseStatusToVariants(status) {
|
|
158
|
-
const entries = Object.entries(status.weights || {});
|
|
159
|
-
if (entries.length === 0) {
|
|
160
|
-
return [{ id: 'control', weight: 1 }];
|
|
161
|
-
}
|
|
162
|
-
return entries.map(([id, weight]) => ({ id, weight }));
|
|
163
|
-
}
|
|
1
|
+
import { getOrCreateSession, readVariantChoice } from './cookies';
|
|
2
|
+
import { BASE_URL } from './settings';
|
|
164
3
|
export async function record(type, opts = {}) {
|
|
165
|
-
//! SSR: getOrCreateSession uses document.cookie
|
|
166
4
|
const sessionId = getOrCreateSession();
|
|
167
5
|
const body = {
|
|
168
6
|
type,
|
|
@@ -170,47 +8,24 @@ export async function record(type, opts = {}) {
|
|
|
170
8
|
};
|
|
171
9
|
if (opts.experimentId) {
|
|
172
10
|
body.experiment_id = opts.experimentId;
|
|
173
|
-
//! SSR: readVariantChoice uses document.cookie
|
|
174
11
|
const fromCookie = readVariantChoice(opts.experimentId);
|
|
175
12
|
const variantId = opts.variantId || fromCookie;
|
|
176
|
-
if (variantId)
|
|
13
|
+
if (variantId)
|
|
177
14
|
body.variant_id = variantId;
|
|
178
|
-
}
|
|
179
|
-
else if (opts.experimentId) {
|
|
180
|
-
console.warn(`[cascayd-sdk] No variant_id found for experiment ${opts.experimentId}`);
|
|
181
|
-
}
|
|
182
15
|
}
|
|
183
16
|
if (typeof opts.value === 'number')
|
|
184
17
|
body.value = opts.value;
|
|
185
18
|
if (opts.route) {
|
|
186
19
|
body.route = opts.route;
|
|
187
|
-
//! SSR: window.location.pathname is browser-only, route should be passed explicitly on server
|
|
188
20
|
}
|
|
189
|
-
else if (typeof window !== 'undefined'
|
|
21
|
+
else if (typeof window !== 'undefined') {
|
|
190
22
|
body.route = window.location.pathname;
|
|
191
23
|
}
|
|
192
|
-
//! SSR: fetch works in Node 18+ but may need request context (headers, cookies) for auth
|
|
193
24
|
const res = await fetch(`${BASE_URL}/events`, {
|
|
194
25
|
method: 'POST',
|
|
195
|
-
headers: {
|
|
196
|
-
'Content-Type': 'application/json',
|
|
197
|
-
Authorization: `Bearer ${API_KEY}`,
|
|
198
|
-
},
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
199
27
|
body: JSON.stringify(body),
|
|
200
28
|
});
|
|
201
29
|
if (!res.ok)
|
|
202
30
|
throw new Error(`Record failed: ${res.status}`);
|
|
203
31
|
}
|
|
204
|
-
//! SSR: Exposing to window object is browser-only, should be conditional or removed for SSR
|
|
205
|
-
// Expose functions to window for browser compatibility
|
|
206
|
-
if (typeof window !== 'undefined') {
|
|
207
|
-
;
|
|
208
|
-
window.initCascayd = initCascayd;
|
|
209
|
-
window.assignVariant = assignVariant;
|
|
210
|
-
window.record = record;
|
|
211
|
-
window.Cascayd = {
|
|
212
|
-
initCascayd,
|
|
213
|
-
assignVariant,
|
|
214
|
-
record,
|
|
215
|
-
};
|
|
216
|
-
}
|
package/dist/http.d.ts
ADDED
package/dist/http.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { BASE_URL } from './settings';
|
|
2
|
+
export async function getVariantStatus(experimentId, variantId) {
|
|
3
|
+
const res = await fetch(`${BASE_URL}/experiments/${encodeURIComponent(experimentId)}/variants/${encodeURIComponent(variantId)}/status`);
|
|
4
|
+
const data = (await res.json());
|
|
5
|
+
if (!res.ok) {
|
|
6
|
+
const err = new Error(`Variant status failed: ${res.status}`);
|
|
7
|
+
err.payload = data;
|
|
8
|
+
throw err;
|
|
9
|
+
}
|
|
10
|
+
return data;
|
|
11
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { initCascayd, record, assignVariant } from './client';
|
|
2
1
|
export { Experiment } from './react/Experiment';
|
|
3
2
|
export { Variant } from './react/Variant';
|
|
4
|
-
export
|
|
3
|
+
export { record } from './client';
|
|
4
|
+
export type * from './types';
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cookies } from 'next/headers';
|
|
3
|
+
import { getVariantStatus } from '../http';
|
|
4
|
+
import { buildWeights, chooseByWeightDeterministic, resolveVariant } from '../assignment';
|
|
5
|
+
import { ImpressionClient } from './ImpressionClient';
|
|
6
|
+
function getVariantCookieKey(experimentId) {
|
|
7
|
+
return `cascayd:${experimentId}`;
|
|
8
|
+
}
|
|
9
|
+
function getOrCreateSessionId(store) {
|
|
10
|
+
const key = 'cascayd:session';
|
|
11
|
+
const existing = store.get(key)?.value;
|
|
12
|
+
if (existing)
|
|
13
|
+
return existing;
|
|
14
|
+
const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
15
|
+
? crypto.randomUUID()
|
|
16
|
+
: `${Date.now()}-${Math.random()}`;
|
|
17
|
+
store.set(key, id, { path: '/', sameSite: 'lax' });
|
|
18
|
+
return id;
|
|
19
|
+
}
|
|
20
|
+
export async function Experiment({ id, children }) {
|
|
21
|
+
const store = cookies();
|
|
22
|
+
// Collect <Variant> children (with optional weights)
|
|
23
|
+
const childrenArray = [];
|
|
24
|
+
for (const child of [].concat(children)) {
|
|
25
|
+
if (!child || child.type?.displayName !== 'CascaydVariant')
|
|
26
|
+
continue;
|
|
27
|
+
childrenArray.push({ id: child.props.id, weight: child.props.weight, element: child });
|
|
28
|
+
}
|
|
29
|
+
const sessionId = getOrCreateSessionId(store);
|
|
30
|
+
const existing = store.get(getVariantCookieKey(id))?.value ?? null;
|
|
31
|
+
let chosen = null;
|
|
32
|
+
// If cookie exists, try to respect it (and resolve serving variant)
|
|
33
|
+
if (existing) {
|
|
34
|
+
try {
|
|
35
|
+
const status = await getVariantStatus(id, existing);
|
|
36
|
+
chosen = resolveVariant(status.serving_variant_id || existing, childrenArray);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// fall through to fresh assignment
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Fresh assignment
|
|
43
|
+
if (!chosen) {
|
|
44
|
+
const baseStatus = await getVariantStatus(id, 'control');
|
|
45
|
+
const weighted = buildWeights(childrenArray, baseStatus.weights);
|
|
46
|
+
const candidate = chooseByWeightDeterministic(weighted, id, sessionId);
|
|
47
|
+
let finalVariant = candidate;
|
|
48
|
+
try {
|
|
49
|
+
const status = await getVariantStatus(id, candidate);
|
|
50
|
+
finalVariant = status.serving_variant_id || candidate;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// keep candidate
|
|
54
|
+
}
|
|
55
|
+
chosen = resolveVariant(finalVariant, childrenArray);
|
|
56
|
+
}
|
|
57
|
+
// Persist variant choice cookie
|
|
58
|
+
store.set(getVariantCookieKey(id), chosen, { path: '/', sameSite: 'lax' });
|
|
59
|
+
// Render chosen variant immediately (SSR)
|
|
60
|
+
const match = childrenArray.find((c) => c.id === chosen) ?? childrenArray[0];
|
|
61
|
+
if (!match)
|
|
62
|
+
return null;
|
|
63
|
+
return (_jsxs(_Fragment, { children: [match.element, _jsx(ImpressionClient, { experimentId: id, variantId: chosen })] }));
|
|
64
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
import { record } from '../client';
|
|
4
|
+
const sent = new Set();
|
|
5
|
+
export function ImpressionClient(props) {
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const key = `${props.experimentId}:${props.variantId}`;
|
|
8
|
+
if (sent.has(key))
|
|
9
|
+
return;
|
|
10
|
+
sent.add(key);
|
|
11
|
+
void record('impression', { experimentId: props.experimentId, variantId: props.variantId });
|
|
12
|
+
}, [props.experimentId, props.variantId]);
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type VariantProps = {
|
|
2
|
+
id: string;
|
|
3
|
+
weight?: number;
|
|
4
|
+
children?: React.ReactNode;
|
|
5
|
+
};
|
|
6
|
+
export declare function Variant({ children }: VariantProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export declare namespace Variant {
|
|
8
|
+
var displayName: string;
|
|
9
|
+
}
|
package/dist/react/Experiment.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
//! SSR: Imports cookie functions that use document.cookie
|
|
2
|
+
import { getVariantStatus } from '../http';
|
|
3
|
+
import { record } from '../client';
|
|
5
4
|
import { getOrCreateSession, persistVariantChoice, readVariantChoice } from '../cookies';
|
|
5
|
+
import { buildWeights, chooseByWeightDeterministic, resolveVariant } from '../assignment';
|
|
6
6
|
const sentImpressions = new Set();
|
|
7
7
|
export function Experiment({ id, children }) {
|
|
8
|
-
//! SSR: Initial null state causes hydration mismatch - server renders null, client renders variant
|
|
9
8
|
const [active, setActive] = useState(null);
|
|
10
9
|
const hasRunRef = useRef(false);
|
|
11
|
-
// collect variant children (with optional weights)
|
|
12
10
|
const childrenArray = useMemo(() => {
|
|
13
11
|
const arr = [];
|
|
14
12
|
for (const child of [].concat(children)) {
|
|
@@ -18,134 +16,69 @@ export function Experiment({ id, children }) {
|
|
|
18
16
|
}
|
|
19
17
|
return arr;
|
|
20
18
|
}, [children]);
|
|
21
|
-
//! SSR: useEffect doesn't run during SSR, variant assignment happens only on client
|
|
22
19
|
useEffect(() => {
|
|
23
20
|
if (hasRunRef.current)
|
|
24
21
|
return;
|
|
25
22
|
hasRunRef.current = true;
|
|
26
|
-
//! SSR: readVariantChoice uses document.cookie
|
|
27
|
-
const existing = readVariantChoice(id);
|
|
28
23
|
let cancelled = false;
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
const existing = readVariantChoice(id);
|
|
25
|
+
// Create or read a stable session id first
|
|
26
|
+
const sessionId = getOrCreateSession();
|
|
31
27
|
void (async () => {
|
|
32
28
|
try {
|
|
33
29
|
if (existing) {
|
|
34
|
-
console.log('[cascayd-sdk] fetching variant status', {
|
|
35
|
-
experimentId: id,
|
|
36
|
-
variantId: existing,
|
|
37
|
-
});
|
|
38
|
-
console.log('[cascayd-sdk] current children', childrenArray.map((c) => c.id));
|
|
39
30
|
try {
|
|
40
|
-
//! SSR: getVariantStatus uses fetch without request context
|
|
41
31
|
const status = await getVariantStatus(id, existing);
|
|
42
32
|
if (cancelled)
|
|
43
33
|
return;
|
|
44
|
-
console.log('[cascayd-sdk] received variant status', status);
|
|
45
|
-
logWeights('[cascayd-sdk] existing variant weights', status.weights);
|
|
46
34
|
const serving = resolveVariant(status.serving_variant_id || existing, childrenArray);
|
|
47
35
|
applyAssignment(id, serving, setActive);
|
|
48
36
|
sendImpression(id, serving);
|
|
49
37
|
return;
|
|
50
38
|
}
|
|
51
|
-
catch
|
|
52
|
-
|
|
53
|
-
experimentId: id,
|
|
54
|
-
variantId: existing,
|
|
55
|
-
error,
|
|
56
|
-
});
|
|
57
|
-
logWeights('[cascayd-sdk] existing variant weights (error path)', errorWeights(error));
|
|
39
|
+
catch {
|
|
40
|
+
// fall through to base assignment
|
|
58
41
|
}
|
|
59
42
|
}
|
|
60
|
-
console.log('[cascayd-sdk] fetching base status', { experimentId: id, variantId: 'control' });
|
|
61
|
-
//! SSR: getVariantStatus uses fetch without request context
|
|
62
43
|
const baseStatus = await getVariantStatus(id, 'control');
|
|
63
44
|
if (cancelled)
|
|
64
45
|
return;
|
|
65
|
-
console.log('[cascayd-sdk] received base status', baseStatus);
|
|
66
|
-
logWeights('[cascayd-sdk] base weights', baseStatus.weights);
|
|
67
46
|
const weighted = buildWeights(childrenArray, baseStatus.weights);
|
|
68
|
-
|
|
69
|
-
logWeights('[cascayd-sdk] computed child weights', Object.fromEntries(weighted.map((w) => [w.id, w.weight])));
|
|
70
|
-
const candidate = chooseByWeight(weighted, id);
|
|
47
|
+
const candidate = chooseByWeightDeterministic(weighted, id, sessionId);
|
|
71
48
|
let finalVariant = candidate;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (cancelled)
|
|
81
|
-
return;
|
|
82
|
-
console.log('[cascayd-sdk] received candidate status', status);
|
|
83
|
-
logWeights('[cascayd-sdk] candidate weights', status.weights);
|
|
84
|
-
finalVariant = status.serving_variant_id || candidate;
|
|
85
|
-
}
|
|
86
|
-
catch (error) {
|
|
87
|
-
console.error('[cascayd-sdk] candidate status fetch failed, using fallback', {
|
|
88
|
-
experimentId: id,
|
|
89
|
-
variantId: candidate,
|
|
90
|
-
error,
|
|
91
|
-
});
|
|
92
|
-
logWeights('[cascayd-sdk] candidate weights (error path)', errorWeights(error));
|
|
93
|
-
}
|
|
49
|
+
try {
|
|
50
|
+
const status = await getVariantStatus(id, candidate);
|
|
51
|
+
if (cancelled)
|
|
52
|
+
return;
|
|
53
|
+
finalVariant = status.serving_variant_id || candidate;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// keep candidate
|
|
94
57
|
}
|
|
95
58
|
const serving = resolveVariant(finalVariant, childrenArray);
|
|
96
59
|
applyAssignment(id, serving, setActive);
|
|
97
60
|
sendImpression(id, serving);
|
|
98
61
|
}
|
|
99
62
|
catch (err) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
63
|
+
// last-ditch fallback, deterministic equal weight using session id
|
|
64
|
+
const weighted = buildWeights(childrenArray, undefined);
|
|
65
|
+
const chosen = chooseByWeightDeterministic(weighted, id, sessionId);
|
|
103
66
|
if (cancelled)
|
|
104
67
|
return;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
sendImpression(id, fallbackVariant);
|
|
68
|
+
applyAssignment(id, chosen, setActive);
|
|
69
|
+
sendImpression(id, chosen);
|
|
108
70
|
}
|
|
109
71
|
})();
|
|
110
72
|
return () => {
|
|
111
73
|
cancelled = true;
|
|
112
74
|
};
|
|
113
75
|
}, [id, childrenArray]);
|
|
114
|
-
//! SSR: Returning null on server causes hydration mismatch - server renders null, client renders variant
|
|
115
76
|
if (!active)
|
|
116
77
|
return null;
|
|
117
78
|
const match = childrenArray.find((c) => c.id === active);
|
|
118
79
|
return match ? match.element : null;
|
|
119
80
|
}
|
|
120
|
-
function buildWeights(children, weights) {
|
|
121
|
-
if (weights && Object.keys(weights).length > 0) {
|
|
122
|
-
const subset = children
|
|
123
|
-
.map((child) => ({ id: child.id, weight: typeof weights[child.id] === 'number' ? weights[child.id] : 0 }))
|
|
124
|
-
.filter((item) => item.weight > 0);
|
|
125
|
-
if (subset.length > 0) {
|
|
126
|
-
const total = subset.reduce((sum, item) => sum + item.weight, 0) || 1;
|
|
127
|
-
return subset.map((item) => ({ id: item.id, weight: item.weight / total }));
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
const provided = children.filter((c) => typeof c.weight === 'number');
|
|
131
|
-
if (provided.length > 0) {
|
|
132
|
-
const sum = provided.reduce((acc, item) => acc + item.weight, 0) || 1;
|
|
133
|
-
return provided.map((item) => ({ id: item.id, weight: item.weight / sum }));
|
|
134
|
-
}
|
|
135
|
-
if (children.length === 0)
|
|
136
|
-
return [{ id: 'control', weight: 1 }];
|
|
137
|
-
const equal = 1 / children.length;
|
|
138
|
-
return children.map((c) => ({ id: c.id, weight: equal }));
|
|
139
|
-
}
|
|
140
|
-
function resolveVariant(candidate, children) {
|
|
141
|
-
if (candidate && children.some((c) => c.id === candidate))
|
|
142
|
-
return candidate;
|
|
143
|
-
if (children.some((c) => c.id === 'control'))
|
|
144
|
-
return 'control';
|
|
145
|
-
return children[0]?.id ?? 'control';
|
|
146
|
-
}
|
|
147
81
|
function applyAssignment(experimentId, variantId, setActive) {
|
|
148
|
-
//! SSR: persistVariantChoice uses document.cookie
|
|
149
82
|
persistVariantChoice(experimentId, variantId);
|
|
150
83
|
setActive(variantId);
|
|
151
84
|
}
|
|
@@ -155,71 +88,5 @@ function sendImpression(experimentId, variantId) {
|
|
|
155
88
|
if (sentImpressions.has(`${experimentId}:${variantId}`))
|
|
156
89
|
return;
|
|
157
90
|
sentImpressions.add(`${experimentId}:${variantId}`);
|
|
158
|
-
//! SSR: record uses fetch and cookies without request context
|
|
159
91
|
void record('impression', { experimentId, variantId });
|
|
160
92
|
}
|
|
161
|
-
function fallbackAssign(children, experimentId) {
|
|
162
|
-
console.log('[cascayd-sdk] fallback assign start', children);
|
|
163
|
-
const weighted = buildWeights(children, undefined);
|
|
164
|
-
console.log('[cascayd-sdk] fallback weighted', weighted);
|
|
165
|
-
const chosen = chooseByWeight(weighted, experimentId);
|
|
166
|
-
console.log('[cascayd-sdk] fallback chosen', chosen);
|
|
167
|
-
return chosen;
|
|
168
|
-
}
|
|
169
|
-
// Seeded random number generator using a simple linear congruential generator
|
|
170
|
-
function seededRandom(seed) {
|
|
171
|
-
const a = 1664525;
|
|
172
|
-
const c = 1013904223;
|
|
173
|
-
const m = Math.pow(2, 32);
|
|
174
|
-
seed = (a * seed + c) % m;
|
|
175
|
-
return seed / m;
|
|
176
|
-
}
|
|
177
|
-
function chooseByWeight(items, experimentId) {
|
|
178
|
-
console.log('[cascayd-sdk] choosing by weight', items);
|
|
179
|
-
const total = items.reduce((sum, item) => sum + item.weight, 0) || 1;
|
|
180
|
-
//! SSR: Date.now() is non-deterministic - variant will change on every render/request
|
|
181
|
-
// Create a seed based on current time (milliseconds) and experiment ID
|
|
182
|
-
const now = Date.now();
|
|
183
|
-
const seedString = `${experimentId}-${now}`;
|
|
184
|
-
// Simple hash function to convert string to number
|
|
185
|
-
let hash = 0;
|
|
186
|
-
for (let i = 0; i < seedString.length; i++) {
|
|
187
|
-
const char = seedString.charCodeAt(i);
|
|
188
|
-
hash = ((hash << 5) - hash) + char;
|
|
189
|
-
hash = hash & hash; // Convert to 32-bit integer
|
|
190
|
-
}
|
|
191
|
-
// Use seeded random instead of Math.random()
|
|
192
|
-
const roll = seededRandom(Math.abs(hash)) * total;
|
|
193
|
-
console.log('[cascayd-sdk] total weight', total, 'roll', roll, 'seed', seedString);
|
|
194
|
-
let acc = 0;
|
|
195
|
-
for (const item of items) {
|
|
196
|
-
acc += item.weight;
|
|
197
|
-
console.log('[cascayd-sdk] accumulate', { item: item.id, acc });
|
|
198
|
-
if (roll <= acc)
|
|
199
|
-
return item.id;
|
|
200
|
-
}
|
|
201
|
-
return items[items.length - 1]?.id || 'control';
|
|
202
|
-
}
|
|
203
|
-
function logWeights(label, weights) {
|
|
204
|
-
if (!weights || Object.keys(weights).length === 0) {
|
|
205
|
-
console.log(label, '(none)');
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
console.log(label, weights);
|
|
209
|
-
console.table(Object.entries(weights).map(([variantId, weight]) => ({
|
|
210
|
-
variantId,
|
|
211
|
-
weight: Number(weight.toFixed(4)),
|
|
212
|
-
percentage: `${(weight * 100).toFixed(2)}%`,
|
|
213
|
-
})));
|
|
214
|
-
}
|
|
215
|
-
function errorWeights(error) {
|
|
216
|
-
if (!error)
|
|
217
|
-
return undefined;
|
|
218
|
-
if (typeof error === 'object' && error && 'weights' in error) {
|
|
219
|
-
const weights = error.weights;
|
|
220
|
-
if (weights && typeof weights === 'object') {
|
|
221
|
-
return weights;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
return undefined;
|
|
225
|
-
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const BASE_URL = "https://ab-mvp-backend-bnqr.onrender.com";
|
package/dist/settings.js
ADDED
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cascayd/experiment",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.22",
|
|
4
4
|
"description": "A lightweight A/B testing SDK for React applications with server-side analytics integration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ab-testing",
|
|
@@ -25,9 +25,15 @@
|
|
|
25
25
|
"default": "./dist/index.js"
|
|
26
26
|
},
|
|
27
27
|
"./react": {
|
|
28
|
-
"types": "./dist/react/
|
|
29
|
-
"import": "./dist/react/
|
|
30
|
-
"default": "./dist/react/
|
|
28
|
+
"types": "./dist/react/index.d.ts",
|
|
29
|
+
"import": "./dist/react/index.js",
|
|
30
|
+
"default": "./dist/react/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./next-app": {
|
|
33
|
+
"types": "./dist/next-app/index.d.ts",
|
|
34
|
+
"import": "./dist/next-app/index.js",
|
|
35
|
+
"react-server": "./dist/next-app/index.js",
|
|
36
|
+
"default": "./dist/next-app/index.js"
|
|
31
37
|
},
|
|
32
38
|
"./package.json": "./package.json"
|
|
33
39
|
},
|