@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.
@@ -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 { InitOptions, ExperimentConfigResponse, EventType, RecordOptions, VariantStatusResponse } from './types';
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
- //! SSR: Imports cookie functions that use document.cookie
2
- import { getOrCreateSession, readVariantChoice } from './cookies.js';
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' && window.location) {
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
@@ -0,0 +1,2 @@
1
+ import type { VariantStatusResponse } from './types';
2
+ export declare function getVariantStatus(experimentId: string, variantId: string): Promise<VariantStatusResponse>;
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 type { InitOptions, EventType, RecordOptions } from './types';
3
+ export { record } from './client';
4
+ export type * from './types';
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { initCascayd, record, assignVariant } from './client';
2
1
  export { Experiment } from './react/Experiment';
3
2
  export { Variant } from './react/Variant';
3
+ export { record } from './client';
@@ -0,0 +1,5 @@
1
+ export type ExperimentProps = {
2
+ id: string;
3
+ children: React.ReactNode;
4
+ };
5
+ export declare function Experiment({ id, children }: ExperimentProps): Promise<import("react/jsx-runtime").JSX.Element | null>;
@@ -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,4 @@
1
+ export declare function ImpressionClient(props: {
2
+ experimentId: string;
3
+ variantId: string;
4
+ }): null;
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ export function Variant({ children }) {
3
+ return _jsx(_Fragment, { children: children });
4
+ }
5
+ Variant.displayName = 'CascaydVariant';
@@ -0,0 +1,2 @@
1
+ export { Experiment } from './Experiment';
2
+ export { Variant } from './Variant';
@@ -0,0 +1,2 @@
1
+ export { Experiment } from './Experiment';
2
+ export { Variant } from './Variant';
@@ -1,14 +1,12 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react';
2
- //! SSR: Imports client functions that use fetch without request context
3
- import { getVariantStatus, record } from '../client';
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
- //! SSR: getOrCreateSession uses document.cookie
30
- getOrCreateSession();
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 (error) {
52
- console.error('[cascayd-sdk] existing variant status fetch failed', {
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
- console.log('[cascayd-sdk] child weights entries', weighted);
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
- if (candidate) {
73
- console.log('[cascayd-sdk] verifying candidate status', {
74
- experimentId: id,
75
- variantId: candidate,
76
- });
77
- try {
78
- //! SSR: getVariantStatus uses fetch without request context
79
- const status = await getVariantStatus(id, candidate);
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
- console.error('[cascayd-sdk] variant assignment error, using fallback assignment', err);
101
- console.log('[cascayd-sdk] fallback children', childrenArray);
102
- const fallbackVariant = fallbackAssign(childrenArray, id);
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
- console.log('[cascayd-sdk] fallback assignment chosen', fallbackVariant);
106
- applyAssignment(id, fallbackVariant, setActive);
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";
@@ -0,0 +1,2 @@
1
+ // settings.ts
2
+ export const BASE_URL = 'https://ab-mvp-backend-bnqr.onrender.com';
package/dist/types.d.ts CHANGED
@@ -1,6 +1,3 @@
1
- export type InitOptions = {
2
- apiKey: string;
3
- };
4
1
  export type VariantConfig = {
5
2
  id: string;
6
3
  weight: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cascayd/experiment",
3
- "version": "0.3.20",
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/Experiment.d.ts",
29
- "import": "./dist/react/Experiment.js",
30
- "default": "./dist/react/Experiment.js"
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
  },