@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 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
+
@@ -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;
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ export { initCascayd, record, assignVariant } from './client';
2
+ export { Experiment } from './react/Experiment';
3
+ export { Variant } from './react/Variant';
4
+ export type { InitOptions, EventType, RecordOptions } from './types';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { initCascayd, record, assignVariant } from './client';
2
+ export { Experiment } from './react/Experiment';
3
+ export { Variant } from './react/Variant';
@@ -0,0 +1,5 @@
1
+ export type ExperimentProps = {
2
+ id: string;
3
+ children: React.ReactNode;
4
+ };
5
+ export declare function Experiment({ id, children }: ExperimentProps): import("react").ReactElement<any, string | import("react").JSXElementConstructor<any>> | null;
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ export function Variant({ id, children }) {
3
+ return _jsx(_Fragment, { children: children });
4
+ }
5
+ Variant.displayName = 'CascaydVariant';
@@ -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
+ }