@cascayd/experiment 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -0
- package/dist/client.d.ts +7 -0
- package/dist/client.js +60 -0
- package/dist/cookies.d.ts +4 -0
- package/dist/cookies.js +33 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/react/Experiment.d.ts +5 -0
- package/dist/react/Experiment.js +75 -0
- package/dist/react/Variant.d.ts +9 -0
- package/dist/react/Variant.js +5 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.js +1 -0
- package/package.json +60 -0
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# @cascayd/experiment
|
|
2
|
+
|
|
3
|
+
A lightweight A/B testing SDK for React applications with server-side analytics integration.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @cascayd/experiment
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
### 1. Initialize the SDK
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { initCascayd } from '@cascayd/experiment'
|
|
17
|
+
|
|
18
|
+
initCascayd({
|
|
19
|
+
apiKey: 'your-api-key',
|
|
20
|
+
baseUrl: 'https://your-api-url.com' // optional, defaults to http://localhost:8000
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### 2. Use React Components
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { Experiment, Variant } from '@cascayd/experiment'
|
|
28
|
+
|
|
29
|
+
function MyComponent() {
|
|
30
|
+
return (
|
|
31
|
+
<Experiment id="button-color-test">
|
|
32
|
+
<Variant id="blue">
|
|
33
|
+
<button style={{ backgroundColor: 'blue' }}>Click me</button>
|
|
34
|
+
</Variant>
|
|
35
|
+
<Variant id="red">
|
|
36
|
+
<button style={{ backgroundColor: 'red' }}>Click me</button>
|
|
37
|
+
</Variant>
|
|
38
|
+
</Experiment>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 3. Record Conversions
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import { record } from '@cascayd/experiment'
|
|
47
|
+
|
|
48
|
+
// Record a conversion event
|
|
49
|
+
await record('conversion', {
|
|
50
|
+
experimentId: 'button-color-test',
|
|
51
|
+
value: 29.99 // optional numeric value
|
|
52
|
+
})
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## API Reference
|
|
56
|
+
|
|
57
|
+
### `initCascayd(options: InitOptions)`
|
|
58
|
+
|
|
59
|
+
Initializes the SDK with your API credentials.
|
|
60
|
+
|
|
61
|
+
**Options:**
|
|
62
|
+
- `apiKey` (required): Your API key for authentication
|
|
63
|
+
- `baseUrl` (optional): Base URL for your API endpoint (defaults to `http://localhost:8000`)
|
|
64
|
+
|
|
65
|
+
### `Experiment` Component
|
|
66
|
+
|
|
67
|
+
Wraps your variants and handles variant assignment automatically.
|
|
68
|
+
|
|
69
|
+
**Props:**
|
|
70
|
+
- `id` (required): Unique identifier for the experiment
|
|
71
|
+
|
|
72
|
+
**Children:**
|
|
73
|
+
- One or more `Variant` components
|
|
74
|
+
|
|
75
|
+
### `Variant` Component
|
|
76
|
+
|
|
77
|
+
Defines a variant in your experiment.
|
|
78
|
+
|
|
79
|
+
**Props:**
|
|
80
|
+
- `id` (required): Unique identifier for this variant
|
|
81
|
+
- `weight` (optional): Weight for this variant (defaults to equal distribution)
|
|
82
|
+
|
|
83
|
+
**Example with weights:**
|
|
84
|
+
```tsx
|
|
85
|
+
<Experiment id="pricing-test">
|
|
86
|
+
<Variant id="control" weight={0.5}>
|
|
87
|
+
<OldPricing />
|
|
88
|
+
</Variant>
|
|
89
|
+
<Variant id="new-pricing" weight={0.5}>
|
|
90
|
+
<NewPricing />
|
|
91
|
+
</Variant>
|
|
92
|
+
</Experiment>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### `record(type: EventType, options?: RecordOptions)`
|
|
96
|
+
|
|
97
|
+
Records an event for analytics.
|
|
98
|
+
|
|
99
|
+
**Event Types:**
|
|
100
|
+
- `'impression'`: When a variant is shown (automatically recorded)
|
|
101
|
+
- `'conversion'`: Custom conversion events
|
|
102
|
+
|
|
103
|
+
**Options:**
|
|
104
|
+
- `experimentId` (optional): ID of the experiment
|
|
105
|
+
- `variantId` (optional): ID of the variant (auto-detected if not provided)
|
|
106
|
+
- `value` (optional): Numeric value associated with the event
|
|
107
|
+
|
|
108
|
+
### `assignVariant(experimentId: string)`
|
|
109
|
+
|
|
110
|
+
Programmatically assign a variant for an experiment (server-side variant assignment).
|
|
111
|
+
|
|
112
|
+
**Returns:** Promise with `{ variantId: string, config: ExperimentConfigResponse }`
|
|
113
|
+
|
|
114
|
+
## Server-Side Usage
|
|
115
|
+
|
|
116
|
+
For server-side rendering or programmatic variant assignment:
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
import { assignVariant } from '@cascayd/experiment'
|
|
120
|
+
|
|
121
|
+
const { variantId, config } = await assignVariant('my-experiment-id')
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## TypeScript Support
|
|
125
|
+
|
|
126
|
+
This package includes full TypeScript definitions. Types are exported from the main entry point:
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
import type { InitOptions, EventType, RecordOptions } from '@cascayd/experiment'
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Requirements
|
|
133
|
+
|
|
134
|
+
- React >= 18
|
|
135
|
+
- Node.js >= 18
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT
|
|
140
|
+
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { InitOptions, ExperimentConfigResponse, EventType, RecordOptions } from './types';
|
|
2
|
+
export declare function initCascayd(opts: InitOptions): void;
|
|
3
|
+
export declare function assignVariant(experimentId: string): Promise<{
|
|
4
|
+
variantId: string;
|
|
5
|
+
config: ExperimentConfigResponse;
|
|
6
|
+
}>;
|
|
7
|
+
export declare function record(type: EventType, opts?: RecordOptions): Promise<void>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { getOrCreateSession, readVariantChoice } from './cookies';
|
|
2
|
+
let API_KEY = '';
|
|
3
|
+
let BASE_URL = 'http://localhost:8000';
|
|
4
|
+
const SDK_VERSION = '0.1.0-dev';
|
|
5
|
+
export function initCascayd(opts) {
|
|
6
|
+
API_KEY = opts.apiKey;
|
|
7
|
+
BASE_URL = opts.baseUrl ?? BASE_URL;
|
|
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
|
+
}
|
|
13
|
+
async function fetchConfig(experimentId) {
|
|
14
|
+
const res = await fetch(`${BASE_URL}/experiments/${encodeURIComponent(experimentId)}/config`);
|
|
15
|
+
if (!res.ok)
|
|
16
|
+
throw new Error(`Config load failed: ${res.status}`);
|
|
17
|
+
return (await res.json());
|
|
18
|
+
}
|
|
19
|
+
function chooseByWeight(variants) {
|
|
20
|
+
const total = variants.reduce((s, v) => s + v.weight, 0);
|
|
21
|
+
const r = Math.random() * total;
|
|
22
|
+
let acc = 0;
|
|
23
|
+
for (const v of variants) {
|
|
24
|
+
acc += v.weight;
|
|
25
|
+
if (r <= acc)
|
|
26
|
+
return v.id;
|
|
27
|
+
}
|
|
28
|
+
return variants[variants.length - 1]?.id ?? 'control';
|
|
29
|
+
}
|
|
30
|
+
export async function assignVariant(experimentId) {
|
|
31
|
+
const cfg = await fetchConfig(experimentId);
|
|
32
|
+
const variantId = chooseByWeight(cfg.variants);
|
|
33
|
+
return { variantId, config: cfg };
|
|
34
|
+
}
|
|
35
|
+
export async function record(type, opts = {}) {
|
|
36
|
+
const sessionId = getOrCreateSession();
|
|
37
|
+
const body = {
|
|
38
|
+
type,
|
|
39
|
+
session_id: sessionId,
|
|
40
|
+
};
|
|
41
|
+
if (opts.experimentId) {
|
|
42
|
+
body.experiment_id = opts.experimentId;
|
|
43
|
+
const fromCookie = readVariantChoice(opts.experimentId);
|
|
44
|
+
const variantId = opts.variantId || fromCookie;
|
|
45
|
+
if (variantId)
|
|
46
|
+
body.variant_id = variantId;
|
|
47
|
+
}
|
|
48
|
+
if (typeof opts.value === 'number')
|
|
49
|
+
body.value = opts.value;
|
|
50
|
+
const res = await fetch(`${BASE_URL}/events`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': 'application/json',
|
|
54
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify(body),
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok)
|
|
59
|
+
throw new Error(`Record failed: ${res.status}`);
|
|
60
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function getOrCreateSession(): string;
|
|
2
|
+
export declare function getVariantCookieKey(experimentId: string): string;
|
|
3
|
+
export declare function readVariantChoice(experimentId: string): string | null;
|
|
4
|
+
export declare function persistVariantChoice(experimentId: string, variantId: string): void;
|
package/dist/cookies.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
function getCookie(name) {
|
|
2
|
+
const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.$?*|{}()\[\]\\\/\+^]/g, '\\$&') + '=([^;]*)'));
|
|
3
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
4
|
+
}
|
|
5
|
+
function setCookie(name, value) {
|
|
6
|
+
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; SameSite=Lax`;
|
|
7
|
+
}
|
|
8
|
+
function randomId() {
|
|
9
|
+
// lightweight uuid v4-ish
|
|
10
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
11
|
+
const r = (crypto.getRandomValues(new Uint8Array(1))[0] & 0xf) >>> 0;
|
|
12
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
13
|
+
return v.toString(16);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function getOrCreateSession() {
|
|
17
|
+
const key = 'cascayd:session';
|
|
18
|
+
let id = getCookie(key);
|
|
19
|
+
if (id)
|
|
20
|
+
return id;
|
|
21
|
+
id = randomId();
|
|
22
|
+
setCookie(key, id);
|
|
23
|
+
return id;
|
|
24
|
+
}
|
|
25
|
+
export function getVariantCookieKey(experimentId) {
|
|
26
|
+
return `cascayd:${experimentId}`;
|
|
27
|
+
}
|
|
28
|
+
export function readVariantChoice(experimentId) {
|
|
29
|
+
return getCookie(getVariantCookieKey(experimentId));
|
|
30
|
+
}
|
|
31
|
+
export function persistVariantChoice(experimentId, variantId) {
|
|
32
|
+
setCookie(getVariantCookieKey(experimentId), variantId);
|
|
33
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { record } from '../client';
|
|
3
|
+
import { getOrCreateSession, persistVariantChoice, readVariantChoice } from '../cookies';
|
|
4
|
+
const sentImpressions = new Set();
|
|
5
|
+
export function Experiment({ id, children }) {
|
|
6
|
+
const [active, setActive] = useState(null);
|
|
7
|
+
const hasRunRef = useRef(false);
|
|
8
|
+
// collect variant children (with optional weights)
|
|
9
|
+
const childrenArray = useMemo(() => {
|
|
10
|
+
const arr = [];
|
|
11
|
+
for (const child of [].concat(children)) {
|
|
12
|
+
if (!child || child.type?.displayName !== 'CascaydVariant')
|
|
13
|
+
continue;
|
|
14
|
+
arr.push({ id: child.props.id, weight: child.props.weight, element: child });
|
|
15
|
+
}
|
|
16
|
+
return arr;
|
|
17
|
+
}, [children]);
|
|
18
|
+
function chooseByWeight(items) {
|
|
19
|
+
const total = items.reduce((s, i) => s + i.weight, 0) || 1;
|
|
20
|
+
const r = Math.random() * total;
|
|
21
|
+
let acc = 0;
|
|
22
|
+
for (const it of items) {
|
|
23
|
+
acc += it.weight;
|
|
24
|
+
if (r <= acc)
|
|
25
|
+
return it.id;
|
|
26
|
+
}
|
|
27
|
+
return items[items.length - 1]?.id || 'control';
|
|
28
|
+
}
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (hasRunRef.current)
|
|
31
|
+
return;
|
|
32
|
+
hasRunRef.current = true;
|
|
33
|
+
const existing = readVariantChoice(id);
|
|
34
|
+
if (existing) {
|
|
35
|
+
setActive(existing);
|
|
36
|
+
if (!sentImpressions.has(id)) {
|
|
37
|
+
sentImpressions.add(id);
|
|
38
|
+
void record('impression', { experimentId: id, variantId: existing });
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
let cancelled = false;
|
|
43
|
+
getOrCreateSession();
|
|
44
|
+
// Decide assignment: use provided weights if any, else split evenly among variant children
|
|
45
|
+
const provided = childrenArray.filter((c) => typeof c.weight === 'number');
|
|
46
|
+
let chosen = null;
|
|
47
|
+
if (provided.length > 0) {
|
|
48
|
+
const normalized = (() => {
|
|
49
|
+
const sum = provided.reduce((s, c) => s + c.weight, 0) || 1;
|
|
50
|
+
return provided.map((c) => ({ id: c.id, weight: c.weight / sum }));
|
|
51
|
+
})();
|
|
52
|
+
chosen = chooseByWeight(normalized);
|
|
53
|
+
}
|
|
54
|
+
else if (childrenArray.length > 0) {
|
|
55
|
+
const equal = 1 / childrenArray.length;
|
|
56
|
+
const items = childrenArray.map((c) => ({ id: c.id, weight: equal }));
|
|
57
|
+
chosen = chooseByWeight(items);
|
|
58
|
+
}
|
|
59
|
+
if (chosen && !cancelled) {
|
|
60
|
+
persistVariantChoice(id, chosen);
|
|
61
|
+
setActive(chosen);
|
|
62
|
+
if (!sentImpressions.has(id)) {
|
|
63
|
+
sentImpressions.add(id);
|
|
64
|
+
void record('impression', { experimentId: id, variantId: chosen });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return () => {
|
|
68
|
+
cancelled = true;
|
|
69
|
+
};
|
|
70
|
+
}, [id]);
|
|
71
|
+
if (!active)
|
|
72
|
+
return null;
|
|
73
|
+
const match = childrenArray.find((c) => c.id === active);
|
|
74
|
+
return match ? match.element : null;
|
|
75
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type VariantProps = {
|
|
2
|
+
id: string;
|
|
3
|
+
weight?: number;
|
|
4
|
+
children?: React.ReactNode;
|
|
5
|
+
};
|
|
6
|
+
export declare function Variant({ id, children }: VariantProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export declare namespace Variant {
|
|
8
|
+
var displayName: string;
|
|
9
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type InitOptions = {
|
|
2
|
+
apiKey: string;
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
};
|
|
5
|
+
export type VariantConfig = {
|
|
6
|
+
id: string;
|
|
7
|
+
weight: number;
|
|
8
|
+
};
|
|
9
|
+
export type ExperimentConfigResponse = {
|
|
10
|
+
experiment_id: string;
|
|
11
|
+
variants: VariantConfig[];
|
|
12
|
+
};
|
|
13
|
+
export type EventType = 'impression' | 'conversion';
|
|
14
|
+
export type RecordOptions = {
|
|
15
|
+
experimentId?: string;
|
|
16
|
+
value?: number;
|
|
17
|
+
variantId?: string;
|
|
18
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cascayd/experiment",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A lightweight A/B testing SDK for React applications with server-side analytics integration",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ab-testing",
|
|
7
|
+
"experimentation",
|
|
8
|
+
"react",
|
|
9
|
+
"sdk",
|
|
10
|
+
"analytics",
|
|
11
|
+
"variant",
|
|
12
|
+
"split-testing"
|
|
13
|
+
],
|
|
14
|
+
"author": "cascayd",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"module": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js",
|
|
24
|
+
"require": "./dist/index.js",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./react": {
|
|
28
|
+
"types": "./dist/react/Experiment.d.ts",
|
|
29
|
+
"import": "./dist/react/Experiment.js",
|
|
30
|
+
"default": "./dist/react/Experiment.js"
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"README.md"
|
|
38
|
+
],
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "https://github.com/Ertersy40/ab-mvp-sdk.git"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"prepare": "npm run build",
|
|
46
|
+
"prepublishOnly": "npm run build"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"react": ">=18",
|
|
50
|
+
"react-dom": ">=18"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/react": "^18.2.0",
|
|
54
|
+
"@types/react-dom": "^18.2.0",
|
|
55
|
+
"typescript": "^5.6.2"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=18"
|
|
59
|
+
}
|
|
60
|
+
}
|