@iggycodes/experimentlab-react 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/dist/index.d.mts +123 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +326 -0
- package/dist/index.mjs +294 -0
- package/package.json +42 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
declare const RuleSchema: z.ZodObject<{
|
|
6
|
+
attribute: z.ZodString;
|
|
7
|
+
operator: z.ZodEnum<["eq", "neq", "contains", "in", "nin"]>;
|
|
8
|
+
value: z.ZodAny;
|
|
9
|
+
}, "strip", z.ZodTypeAny, {
|
|
10
|
+
attribute: string;
|
|
11
|
+
operator: "eq" | "neq" | "contains" | "in" | "nin";
|
|
12
|
+
value?: any;
|
|
13
|
+
}, {
|
|
14
|
+
attribute: string;
|
|
15
|
+
operator: "eq" | "neq" | "contains" | "in" | "nin";
|
|
16
|
+
value?: any;
|
|
17
|
+
}>;
|
|
18
|
+
declare const VariantSchema: z.ZodObject<{
|
|
19
|
+
id: z.ZodString;
|
|
20
|
+
name: z.ZodString;
|
|
21
|
+
weight: z.ZodNumber;
|
|
22
|
+
}, "strip", z.ZodTypeAny, {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
weight: number;
|
|
26
|
+
}, {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
weight: number;
|
|
30
|
+
}>;
|
|
31
|
+
type Rule = z.infer<typeof RuleSchema>;
|
|
32
|
+
type Variant = z.infer<typeof VariantSchema>;
|
|
33
|
+
|
|
34
|
+
interface ExperimentManifest {
|
|
35
|
+
flags: Array<{
|
|
36
|
+
key: string;
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
rollout?: number;
|
|
39
|
+
rules?: Rule[];
|
|
40
|
+
}>;
|
|
41
|
+
experiments: Array<{
|
|
42
|
+
key: string;
|
|
43
|
+
variants: Variant[];
|
|
44
|
+
rollout: number;
|
|
45
|
+
status: string;
|
|
46
|
+
metrics?: string[];
|
|
47
|
+
winningVariantId?: string | null;
|
|
48
|
+
rules?: Rule[];
|
|
49
|
+
}>;
|
|
50
|
+
}
|
|
51
|
+
type Listener = () => void;
|
|
52
|
+
declare const EMPTY_STATE: {
|
|
53
|
+
manifest: ExperimentManifest | null;
|
|
54
|
+
loading: boolean;
|
|
55
|
+
};
|
|
56
|
+
interface Event {
|
|
57
|
+
type: "exposure" | "track";
|
|
58
|
+
userId: string;
|
|
59
|
+
name: string;
|
|
60
|
+
variantId?: string;
|
|
61
|
+
payload?: Record<string, any>;
|
|
62
|
+
timestamp: string;
|
|
63
|
+
}
|
|
64
|
+
declare class ExperimentClient {
|
|
65
|
+
private publishableKey;
|
|
66
|
+
private userId;
|
|
67
|
+
private baseUrl;
|
|
68
|
+
private context;
|
|
69
|
+
private manifest;
|
|
70
|
+
private listeners;
|
|
71
|
+
private loading;
|
|
72
|
+
private state;
|
|
73
|
+
private eventQueue;
|
|
74
|
+
private seenExposures;
|
|
75
|
+
private flushTimer;
|
|
76
|
+
private assignments;
|
|
77
|
+
private STORAGE_KEY;
|
|
78
|
+
private onEvent?;
|
|
79
|
+
constructor(options: {
|
|
80
|
+
publishableKey: string;
|
|
81
|
+
userId: string;
|
|
82
|
+
baseUrl?: string;
|
|
83
|
+
context?: Record<string, any>;
|
|
84
|
+
onEvent?: (event: Event) => void;
|
|
85
|
+
});
|
|
86
|
+
private loadAssignments;
|
|
87
|
+
private saveAssignments;
|
|
88
|
+
setContext(context: Record<string, any>): void;
|
|
89
|
+
init(): Promise<void>;
|
|
90
|
+
private updateState;
|
|
91
|
+
subscribe(listener: Listener): () => boolean;
|
|
92
|
+
private notify;
|
|
93
|
+
getFlag(key: string): boolean;
|
|
94
|
+
getExperimentVariant(key: string): Variant | null;
|
|
95
|
+
track(name: string, payload?: Record<string, any>): void;
|
|
96
|
+
private trackExposure;
|
|
97
|
+
private enqueue;
|
|
98
|
+
flush(): Promise<void>;
|
|
99
|
+
getSnapshot(): {
|
|
100
|
+
manifest: ExperimentManifest | null;
|
|
101
|
+
loading: boolean;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface ExperimentProviderProps {
|
|
106
|
+
client?: ExperimentClient;
|
|
107
|
+
publishableKey?: string;
|
|
108
|
+
userId?: string;
|
|
109
|
+
baseUrl?: string;
|
|
110
|
+
context?: Record<string, any>;
|
|
111
|
+
children: React.ReactNode;
|
|
112
|
+
}
|
|
113
|
+
declare function ExperimentProvider({ client, publishableKey, userId, baseUrl, context, children }: ExperimentProviderProps): react_jsx_runtime.JSX.Element;
|
|
114
|
+
declare function useExperimentClient(): ExperimentClient | null;
|
|
115
|
+
declare function useFlag(key: string): boolean;
|
|
116
|
+
declare function useExperiment(key: string): Variant | null;
|
|
117
|
+
declare function useTrack(): (name: string, payload?: Record<string, any>) => void;
|
|
118
|
+
declare function useManifest(): {
|
|
119
|
+
manifest: ExperimentManifest | null;
|
|
120
|
+
loading: boolean;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export { EMPTY_STATE, type Event, ExperimentClient, type ExperimentManifest, ExperimentProvider, type ExperimentProviderProps, type Variant, useExperiment, useExperimentClient, useFlag, useManifest, useTrack };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
declare const RuleSchema: z.ZodObject<{
|
|
6
|
+
attribute: z.ZodString;
|
|
7
|
+
operator: z.ZodEnum<["eq", "neq", "contains", "in", "nin"]>;
|
|
8
|
+
value: z.ZodAny;
|
|
9
|
+
}, "strip", z.ZodTypeAny, {
|
|
10
|
+
attribute: string;
|
|
11
|
+
operator: "eq" | "neq" | "contains" | "in" | "nin";
|
|
12
|
+
value?: any;
|
|
13
|
+
}, {
|
|
14
|
+
attribute: string;
|
|
15
|
+
operator: "eq" | "neq" | "contains" | "in" | "nin";
|
|
16
|
+
value?: any;
|
|
17
|
+
}>;
|
|
18
|
+
declare const VariantSchema: z.ZodObject<{
|
|
19
|
+
id: z.ZodString;
|
|
20
|
+
name: z.ZodString;
|
|
21
|
+
weight: z.ZodNumber;
|
|
22
|
+
}, "strip", z.ZodTypeAny, {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
weight: number;
|
|
26
|
+
}, {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
weight: number;
|
|
30
|
+
}>;
|
|
31
|
+
type Rule = z.infer<typeof RuleSchema>;
|
|
32
|
+
type Variant = z.infer<typeof VariantSchema>;
|
|
33
|
+
|
|
34
|
+
interface ExperimentManifest {
|
|
35
|
+
flags: Array<{
|
|
36
|
+
key: string;
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
rollout?: number;
|
|
39
|
+
rules?: Rule[];
|
|
40
|
+
}>;
|
|
41
|
+
experiments: Array<{
|
|
42
|
+
key: string;
|
|
43
|
+
variants: Variant[];
|
|
44
|
+
rollout: number;
|
|
45
|
+
status: string;
|
|
46
|
+
metrics?: string[];
|
|
47
|
+
winningVariantId?: string | null;
|
|
48
|
+
rules?: Rule[];
|
|
49
|
+
}>;
|
|
50
|
+
}
|
|
51
|
+
type Listener = () => void;
|
|
52
|
+
declare const EMPTY_STATE: {
|
|
53
|
+
manifest: ExperimentManifest | null;
|
|
54
|
+
loading: boolean;
|
|
55
|
+
};
|
|
56
|
+
interface Event {
|
|
57
|
+
type: "exposure" | "track";
|
|
58
|
+
userId: string;
|
|
59
|
+
name: string;
|
|
60
|
+
variantId?: string;
|
|
61
|
+
payload?: Record<string, any>;
|
|
62
|
+
timestamp: string;
|
|
63
|
+
}
|
|
64
|
+
declare class ExperimentClient {
|
|
65
|
+
private publishableKey;
|
|
66
|
+
private userId;
|
|
67
|
+
private baseUrl;
|
|
68
|
+
private context;
|
|
69
|
+
private manifest;
|
|
70
|
+
private listeners;
|
|
71
|
+
private loading;
|
|
72
|
+
private state;
|
|
73
|
+
private eventQueue;
|
|
74
|
+
private seenExposures;
|
|
75
|
+
private flushTimer;
|
|
76
|
+
private assignments;
|
|
77
|
+
private STORAGE_KEY;
|
|
78
|
+
private onEvent?;
|
|
79
|
+
constructor(options: {
|
|
80
|
+
publishableKey: string;
|
|
81
|
+
userId: string;
|
|
82
|
+
baseUrl?: string;
|
|
83
|
+
context?: Record<string, any>;
|
|
84
|
+
onEvent?: (event: Event) => void;
|
|
85
|
+
});
|
|
86
|
+
private loadAssignments;
|
|
87
|
+
private saveAssignments;
|
|
88
|
+
setContext(context: Record<string, any>): void;
|
|
89
|
+
init(): Promise<void>;
|
|
90
|
+
private updateState;
|
|
91
|
+
subscribe(listener: Listener): () => boolean;
|
|
92
|
+
private notify;
|
|
93
|
+
getFlag(key: string): boolean;
|
|
94
|
+
getExperimentVariant(key: string): Variant | null;
|
|
95
|
+
track(name: string, payload?: Record<string, any>): void;
|
|
96
|
+
private trackExposure;
|
|
97
|
+
private enqueue;
|
|
98
|
+
flush(): Promise<void>;
|
|
99
|
+
getSnapshot(): {
|
|
100
|
+
manifest: ExperimentManifest | null;
|
|
101
|
+
loading: boolean;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface ExperimentProviderProps {
|
|
106
|
+
client?: ExperimentClient;
|
|
107
|
+
publishableKey?: string;
|
|
108
|
+
userId?: string;
|
|
109
|
+
baseUrl?: string;
|
|
110
|
+
context?: Record<string, any>;
|
|
111
|
+
children: React.ReactNode;
|
|
112
|
+
}
|
|
113
|
+
declare function ExperimentProvider({ client, publishableKey, userId, baseUrl, context, children }: ExperimentProviderProps): react_jsx_runtime.JSX.Element;
|
|
114
|
+
declare function useExperimentClient(): ExperimentClient | null;
|
|
115
|
+
declare function useFlag(key: string): boolean;
|
|
116
|
+
declare function useExperiment(key: string): Variant | null;
|
|
117
|
+
declare function useTrack(): (name: string, payload?: Record<string, any>) => void;
|
|
118
|
+
declare function useManifest(): {
|
|
119
|
+
manifest: ExperimentManifest | null;
|
|
120
|
+
loading: boolean;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export { EMPTY_STATE, type Event, ExperimentClient, type ExperimentManifest, ExperimentProvider, type ExperimentProviderProps, type Variant, useExperiment, useExperimentClient, useFlag, useManifest, useTrack };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.tsx
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
EMPTY_STATE: () => EMPTY_STATE,
|
|
24
|
+
ExperimentClient: () => ExperimentClient,
|
|
25
|
+
ExperimentProvider: () => ExperimentProvider,
|
|
26
|
+
useExperiment: () => useExperiment,
|
|
27
|
+
useExperimentClient: () => useExperimentClient,
|
|
28
|
+
useFlag: () => useFlag,
|
|
29
|
+
useManifest: () => useManifest,
|
|
30
|
+
useTrack: () => useTrack
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(index_exports);
|
|
33
|
+
var import_react = require("react");
|
|
34
|
+
|
|
35
|
+
// ../sdk-core/src/index.ts
|
|
36
|
+
function stableHash(input) {
|
|
37
|
+
let hash = 5381;
|
|
38
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
39
|
+
hash = hash * 33 ^ input.charCodeAt(i);
|
|
40
|
+
}
|
|
41
|
+
return Math.abs(hash >>> 0);
|
|
42
|
+
}
|
|
43
|
+
function bucket(userId, key) {
|
|
44
|
+
return stableHash(`${userId}:${key}`) % 100;
|
|
45
|
+
}
|
|
46
|
+
function evaluateRules(rules, context) {
|
|
47
|
+
if (!rules || rules.length === 0) return true;
|
|
48
|
+
return rules.every((rule) => {
|
|
49
|
+
const attrValue = context[rule.attribute];
|
|
50
|
+
if (attrValue === void 0) return false;
|
|
51
|
+
switch (rule.operator) {
|
|
52
|
+
case "eq":
|
|
53
|
+
return attrValue === rule.value;
|
|
54
|
+
case "neq":
|
|
55
|
+
return attrValue !== rule.value;
|
|
56
|
+
case "contains":
|
|
57
|
+
return String(attrValue).includes(String(rule.value));
|
|
58
|
+
case "in":
|
|
59
|
+
return Array.isArray(rule.value) && rule.value.includes(attrValue);
|
|
60
|
+
case "nin":
|
|
61
|
+
return Array.isArray(rule.value) && !rule.value.includes(attrValue);
|
|
62
|
+
default:
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
var EMPTY_STATE = { manifest: null, loading: false };
|
|
68
|
+
var ExperimentClient = class {
|
|
69
|
+
publishableKey;
|
|
70
|
+
userId;
|
|
71
|
+
baseUrl;
|
|
72
|
+
context;
|
|
73
|
+
manifest = null;
|
|
74
|
+
listeners = /* @__PURE__ */ new Set();
|
|
75
|
+
loading = false;
|
|
76
|
+
state = EMPTY_STATE;
|
|
77
|
+
eventQueue = [];
|
|
78
|
+
seenExposures = /* @__PURE__ */ new Set();
|
|
79
|
+
flushTimer = null;
|
|
80
|
+
assignments = {};
|
|
81
|
+
STORAGE_KEY = "exp_assignments";
|
|
82
|
+
onEvent;
|
|
83
|
+
constructor(options) {
|
|
84
|
+
this.publishableKey = options.publishableKey;
|
|
85
|
+
this.userId = options.userId;
|
|
86
|
+
this.baseUrl = options.baseUrl || "";
|
|
87
|
+
this.context = options.context || {};
|
|
88
|
+
this.onEvent = options.onEvent;
|
|
89
|
+
if (typeof window !== "undefined") {
|
|
90
|
+
this.loadAssignments();
|
|
91
|
+
window.addEventListener("visibilitychange", () => {
|
|
92
|
+
if (window.document.visibilityState === "hidden") {
|
|
93
|
+
this.flush();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
loadAssignments() {
|
|
99
|
+
try {
|
|
100
|
+
const raw = localStorage.getItem(this.STORAGE_KEY);
|
|
101
|
+
if (raw) this.assignments = JSON.parse(raw);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
console.error("[Experiment SDK] Failed to load assignments", e);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
saveAssignments() {
|
|
107
|
+
if (typeof window === "undefined") return;
|
|
108
|
+
try {
|
|
109
|
+
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.assignments));
|
|
110
|
+
} catch (e) {
|
|
111
|
+
console.error("[Experiment SDK] Failed to save assignments", e);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
setContext(context) {
|
|
115
|
+
this.context = { ...this.context, ...context };
|
|
116
|
+
this.notify();
|
|
117
|
+
}
|
|
118
|
+
async init() {
|
|
119
|
+
if (this.loading) return;
|
|
120
|
+
this.loading = true;
|
|
121
|
+
this.updateState();
|
|
122
|
+
try {
|
|
123
|
+
const res = await fetch(`${this.baseUrl}/api/v1/client/manifest?apiKey=${this.publishableKey}`);
|
|
124
|
+
if (!res.ok) throw new Error("Failed to fetch manifest");
|
|
125
|
+
this.manifest = await res.json();
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error("[Experiment SDK] Initialization failed", err);
|
|
128
|
+
} finally {
|
|
129
|
+
this.loading = false;
|
|
130
|
+
this.updateState();
|
|
131
|
+
this.notify();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
updateState() {
|
|
135
|
+
this.state = {
|
|
136
|
+
manifest: this.manifest,
|
|
137
|
+
loading: this.loading
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
subscribe(listener) {
|
|
141
|
+
this.listeners.add(listener);
|
|
142
|
+
return () => this.listeners.delete(listener);
|
|
143
|
+
}
|
|
144
|
+
notify() {
|
|
145
|
+
this.listeners.forEach((l) => l());
|
|
146
|
+
}
|
|
147
|
+
getFlag(key) {
|
|
148
|
+
if (!this.manifest) return false;
|
|
149
|
+
const flag = this.manifest.flags.find((f) => f.key === key);
|
|
150
|
+
if (!flag || !flag.enabled) return false;
|
|
151
|
+
if (!evaluateRules(flag.rules, this.context)) return false;
|
|
152
|
+
const rollout = flag.rollout ?? 100;
|
|
153
|
+
return bucket(this.userId, key) < rollout;
|
|
154
|
+
}
|
|
155
|
+
getExperimentVariant(key) {
|
|
156
|
+
if (!this.manifest) return null;
|
|
157
|
+
const experiment = this.manifest.experiments.find((e) => e.key === key);
|
|
158
|
+
if (!experiment) return null;
|
|
159
|
+
if (this.assignments[key]) {
|
|
160
|
+
const existing = experiment.variants.find((v) => v.id === this.assignments[key]);
|
|
161
|
+
if (existing) {
|
|
162
|
+
this.trackExposure(key, existing.id);
|
|
163
|
+
return existing;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (experiment.status === "completed" && experiment.winningVariantId) {
|
|
167
|
+
const winner = experiment.variants.find((v) => v.id === experiment.winningVariantId) ?? null;
|
|
168
|
+
if (winner) {
|
|
169
|
+
this.assignments[key] = winner.id;
|
|
170
|
+
this.saveAssignments();
|
|
171
|
+
}
|
|
172
|
+
return winner;
|
|
173
|
+
}
|
|
174
|
+
if (experiment.status !== "active") return null;
|
|
175
|
+
if (!evaluateRules(experiment.rules, this.context)) return null;
|
|
176
|
+
const value = bucket(this.userId, key);
|
|
177
|
+
if (value >= experiment.rollout) return null;
|
|
178
|
+
let cursor = 0;
|
|
179
|
+
const selected = experiment.variants.find((variant) => {
|
|
180
|
+
cursor += variant.weight;
|
|
181
|
+
return value < cursor;
|
|
182
|
+
}) ?? experiment.variants[0];
|
|
183
|
+
this.assignments[key] = selected.id;
|
|
184
|
+
this.saveAssignments();
|
|
185
|
+
this.trackExposure(key, selected.id);
|
|
186
|
+
return selected;
|
|
187
|
+
}
|
|
188
|
+
track(name, payload) {
|
|
189
|
+
this.enqueue({
|
|
190
|
+
type: "track",
|
|
191
|
+
userId: this.userId,
|
|
192
|
+
name,
|
|
193
|
+
payload,
|
|
194
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
trackExposure(experimentKey, variantId) {
|
|
198
|
+
if (this.seenExposures.has(experimentKey)) return;
|
|
199
|
+
this.seenExposures.add(experimentKey);
|
|
200
|
+
this.enqueue({
|
|
201
|
+
type: "exposure",
|
|
202
|
+
userId: this.userId,
|
|
203
|
+
name: experimentKey,
|
|
204
|
+
variantId,
|
|
205
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
enqueue(event) {
|
|
209
|
+
this.eventQueue.push(event);
|
|
210
|
+
if (this.onEvent) {
|
|
211
|
+
this.onEvent(event);
|
|
212
|
+
}
|
|
213
|
+
if (this.eventQueue.length >= 10) {
|
|
214
|
+
this.flush();
|
|
215
|
+
} else if (!this.flushTimer) {
|
|
216
|
+
this.flushTimer = setTimeout(() => this.flush(), 5e3);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async flush() {
|
|
220
|
+
if (this.flushTimer) {
|
|
221
|
+
clearTimeout(this.flushTimer);
|
|
222
|
+
this.flushTimer = null;
|
|
223
|
+
}
|
|
224
|
+
if (this.eventQueue.length === 0) return;
|
|
225
|
+
const events = [...this.eventQueue];
|
|
226
|
+
this.eventQueue = [];
|
|
227
|
+
try {
|
|
228
|
+
await fetch(`${this.baseUrl}/api/v1/client/events?apiKey=${this.publishableKey}`, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "Content-Type": "application/json" },
|
|
231
|
+
body: JSON.stringify(events)
|
|
232
|
+
});
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.error("[Experiment SDK] Failed to flush events", err);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
getSnapshot() {
|
|
238
|
+
return this.state;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// src/index.tsx
|
|
243
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
244
|
+
var ExperimentContext = (0, import_react.createContext)(null);
|
|
245
|
+
function ExperimentProvider({
|
|
246
|
+
client,
|
|
247
|
+
publishableKey,
|
|
248
|
+
userId,
|
|
249
|
+
baseUrl,
|
|
250
|
+
context,
|
|
251
|
+
children
|
|
252
|
+
}) {
|
|
253
|
+
const resolvedClient = (0, import_react.useMemo)(() => {
|
|
254
|
+
if (client) return client;
|
|
255
|
+
if (publishableKey) {
|
|
256
|
+
return new ExperimentClient({
|
|
257
|
+
publishableKey,
|
|
258
|
+
userId: userId || "anonymous",
|
|
259
|
+
baseUrl: baseUrl || "",
|
|
260
|
+
context: context || {}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}, [client, publishableKey, userId, baseUrl]);
|
|
265
|
+
(0, import_react.useEffect)(() => {
|
|
266
|
+
if (resolvedClient) {
|
|
267
|
+
resolvedClient.init().catch(() => {
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}, [resolvedClient]);
|
|
271
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ExperimentContext.Provider, { value: resolvedClient, children });
|
|
272
|
+
}
|
|
273
|
+
function useExperimentClient() {
|
|
274
|
+
return (0, import_react.useContext)(ExperimentContext);
|
|
275
|
+
}
|
|
276
|
+
function useFlag(key) {
|
|
277
|
+
const client = useExperimentClient();
|
|
278
|
+
(0, import_react.useSyncExternalStore)(
|
|
279
|
+
(l) => client ? client.subscribe(l) : () => {
|
|
280
|
+
},
|
|
281
|
+
() => client ? client.getSnapshot() : EMPTY_STATE,
|
|
282
|
+
() => client ? client.getSnapshot() : EMPTY_STATE
|
|
283
|
+
);
|
|
284
|
+
if (!client) return false;
|
|
285
|
+
return client.getFlag(key);
|
|
286
|
+
}
|
|
287
|
+
function useExperiment(key) {
|
|
288
|
+
const client = useExperimentClient();
|
|
289
|
+
(0, import_react.useSyncExternalStore)(
|
|
290
|
+
(l) => client ? client.subscribe(l) : () => {
|
|
291
|
+
},
|
|
292
|
+
() => client ? client.getSnapshot() : EMPTY_STATE,
|
|
293
|
+
() => client ? client.getSnapshot() : EMPTY_STATE
|
|
294
|
+
);
|
|
295
|
+
if (!client) return null;
|
|
296
|
+
return client.getExperimentVariant(key);
|
|
297
|
+
}
|
|
298
|
+
function useTrack() {
|
|
299
|
+
const client = useExperimentClient();
|
|
300
|
+
return (name, payload) => {
|
|
301
|
+
if (client) {
|
|
302
|
+
client.track(name, payload);
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function useManifest() {
|
|
307
|
+
const client = useExperimentClient();
|
|
308
|
+
const state = (0, import_react.useSyncExternalStore)(
|
|
309
|
+
(l) => client ? client.subscribe(l) : () => {
|
|
310
|
+
},
|
|
311
|
+
() => client ? client.getSnapshot() : EMPTY_STATE,
|
|
312
|
+
() => client ? client.getSnapshot() : EMPTY_STATE
|
|
313
|
+
);
|
|
314
|
+
return { manifest: state.manifest, loading: state.loading };
|
|
315
|
+
}
|
|
316
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
317
|
+
0 && (module.exports = {
|
|
318
|
+
EMPTY_STATE,
|
|
319
|
+
ExperimentClient,
|
|
320
|
+
ExperimentProvider,
|
|
321
|
+
useExperiment,
|
|
322
|
+
useExperimentClient,
|
|
323
|
+
useFlag,
|
|
324
|
+
useManifest,
|
|
325
|
+
useTrack
|
|
326
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// src/index.tsx
|
|
2
|
+
import { createContext, useContext, useEffect, useMemo, useSyncExternalStore } from "react";
|
|
3
|
+
|
|
4
|
+
// ../sdk-core/src/index.ts
|
|
5
|
+
function stableHash(input) {
|
|
6
|
+
let hash = 5381;
|
|
7
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
8
|
+
hash = hash * 33 ^ input.charCodeAt(i);
|
|
9
|
+
}
|
|
10
|
+
return Math.abs(hash >>> 0);
|
|
11
|
+
}
|
|
12
|
+
function bucket(userId, key) {
|
|
13
|
+
return stableHash(`${userId}:${key}`) % 100;
|
|
14
|
+
}
|
|
15
|
+
function evaluateRules(rules, context) {
|
|
16
|
+
if (!rules || rules.length === 0) return true;
|
|
17
|
+
return rules.every((rule) => {
|
|
18
|
+
const attrValue = context[rule.attribute];
|
|
19
|
+
if (attrValue === void 0) return false;
|
|
20
|
+
switch (rule.operator) {
|
|
21
|
+
case "eq":
|
|
22
|
+
return attrValue === rule.value;
|
|
23
|
+
case "neq":
|
|
24
|
+
return attrValue !== rule.value;
|
|
25
|
+
case "contains":
|
|
26
|
+
return String(attrValue).includes(String(rule.value));
|
|
27
|
+
case "in":
|
|
28
|
+
return Array.isArray(rule.value) && rule.value.includes(attrValue);
|
|
29
|
+
case "nin":
|
|
30
|
+
return Array.isArray(rule.value) && !rule.value.includes(attrValue);
|
|
31
|
+
default:
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
var EMPTY_STATE = { manifest: null, loading: false };
|
|
37
|
+
var ExperimentClient = class {
|
|
38
|
+
publishableKey;
|
|
39
|
+
userId;
|
|
40
|
+
baseUrl;
|
|
41
|
+
context;
|
|
42
|
+
manifest = null;
|
|
43
|
+
listeners = /* @__PURE__ */ new Set();
|
|
44
|
+
loading = false;
|
|
45
|
+
state = EMPTY_STATE;
|
|
46
|
+
eventQueue = [];
|
|
47
|
+
seenExposures = /* @__PURE__ */ new Set();
|
|
48
|
+
flushTimer = null;
|
|
49
|
+
assignments = {};
|
|
50
|
+
STORAGE_KEY = "exp_assignments";
|
|
51
|
+
onEvent;
|
|
52
|
+
constructor(options) {
|
|
53
|
+
this.publishableKey = options.publishableKey;
|
|
54
|
+
this.userId = options.userId;
|
|
55
|
+
this.baseUrl = options.baseUrl || "";
|
|
56
|
+
this.context = options.context || {};
|
|
57
|
+
this.onEvent = options.onEvent;
|
|
58
|
+
if (typeof window !== "undefined") {
|
|
59
|
+
this.loadAssignments();
|
|
60
|
+
window.addEventListener("visibilitychange", () => {
|
|
61
|
+
if (window.document.visibilityState === "hidden") {
|
|
62
|
+
this.flush();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
loadAssignments() {
|
|
68
|
+
try {
|
|
69
|
+
const raw = localStorage.getItem(this.STORAGE_KEY);
|
|
70
|
+
if (raw) this.assignments = JSON.parse(raw);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.error("[Experiment SDK] Failed to load assignments", e);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
saveAssignments() {
|
|
76
|
+
if (typeof window === "undefined") return;
|
|
77
|
+
try {
|
|
78
|
+
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.assignments));
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.error("[Experiment SDK] Failed to save assignments", e);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
setContext(context) {
|
|
84
|
+
this.context = { ...this.context, ...context };
|
|
85
|
+
this.notify();
|
|
86
|
+
}
|
|
87
|
+
async init() {
|
|
88
|
+
if (this.loading) return;
|
|
89
|
+
this.loading = true;
|
|
90
|
+
this.updateState();
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(`${this.baseUrl}/api/v1/client/manifest?apiKey=${this.publishableKey}`);
|
|
93
|
+
if (!res.ok) throw new Error("Failed to fetch manifest");
|
|
94
|
+
this.manifest = await res.json();
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error("[Experiment SDK] Initialization failed", err);
|
|
97
|
+
} finally {
|
|
98
|
+
this.loading = false;
|
|
99
|
+
this.updateState();
|
|
100
|
+
this.notify();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
updateState() {
|
|
104
|
+
this.state = {
|
|
105
|
+
manifest: this.manifest,
|
|
106
|
+
loading: this.loading
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
subscribe(listener) {
|
|
110
|
+
this.listeners.add(listener);
|
|
111
|
+
return () => this.listeners.delete(listener);
|
|
112
|
+
}
|
|
113
|
+
notify() {
|
|
114
|
+
this.listeners.forEach((l) => l());
|
|
115
|
+
}
|
|
116
|
+
getFlag(key) {
|
|
117
|
+
if (!this.manifest) return false;
|
|
118
|
+
const flag = this.manifest.flags.find((f) => f.key === key);
|
|
119
|
+
if (!flag || !flag.enabled) return false;
|
|
120
|
+
if (!evaluateRules(flag.rules, this.context)) return false;
|
|
121
|
+
const rollout = flag.rollout ?? 100;
|
|
122
|
+
return bucket(this.userId, key) < rollout;
|
|
123
|
+
}
|
|
124
|
+
getExperimentVariant(key) {
|
|
125
|
+
if (!this.manifest) return null;
|
|
126
|
+
const experiment = this.manifest.experiments.find((e) => e.key === key);
|
|
127
|
+
if (!experiment) return null;
|
|
128
|
+
if (this.assignments[key]) {
|
|
129
|
+
const existing = experiment.variants.find((v) => v.id === this.assignments[key]);
|
|
130
|
+
if (existing) {
|
|
131
|
+
this.trackExposure(key, existing.id);
|
|
132
|
+
return existing;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (experiment.status === "completed" && experiment.winningVariantId) {
|
|
136
|
+
const winner = experiment.variants.find((v) => v.id === experiment.winningVariantId) ?? null;
|
|
137
|
+
if (winner) {
|
|
138
|
+
this.assignments[key] = winner.id;
|
|
139
|
+
this.saveAssignments();
|
|
140
|
+
}
|
|
141
|
+
return winner;
|
|
142
|
+
}
|
|
143
|
+
if (experiment.status !== "active") return null;
|
|
144
|
+
if (!evaluateRules(experiment.rules, this.context)) return null;
|
|
145
|
+
const value = bucket(this.userId, key);
|
|
146
|
+
if (value >= experiment.rollout) return null;
|
|
147
|
+
let cursor = 0;
|
|
148
|
+
const selected = experiment.variants.find((variant) => {
|
|
149
|
+
cursor += variant.weight;
|
|
150
|
+
return value < cursor;
|
|
151
|
+
}) ?? experiment.variants[0];
|
|
152
|
+
this.assignments[key] = selected.id;
|
|
153
|
+
this.saveAssignments();
|
|
154
|
+
this.trackExposure(key, selected.id);
|
|
155
|
+
return selected;
|
|
156
|
+
}
|
|
157
|
+
track(name, payload) {
|
|
158
|
+
this.enqueue({
|
|
159
|
+
type: "track",
|
|
160
|
+
userId: this.userId,
|
|
161
|
+
name,
|
|
162
|
+
payload,
|
|
163
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
trackExposure(experimentKey, variantId) {
|
|
167
|
+
if (this.seenExposures.has(experimentKey)) return;
|
|
168
|
+
this.seenExposures.add(experimentKey);
|
|
169
|
+
this.enqueue({
|
|
170
|
+
type: "exposure",
|
|
171
|
+
userId: this.userId,
|
|
172
|
+
name: experimentKey,
|
|
173
|
+
variantId,
|
|
174
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
enqueue(event) {
|
|
178
|
+
this.eventQueue.push(event);
|
|
179
|
+
if (this.onEvent) {
|
|
180
|
+
this.onEvent(event);
|
|
181
|
+
}
|
|
182
|
+
if (this.eventQueue.length >= 10) {
|
|
183
|
+
this.flush();
|
|
184
|
+
} else if (!this.flushTimer) {
|
|
185
|
+
this.flushTimer = setTimeout(() => this.flush(), 5e3);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async flush() {
|
|
189
|
+
if (this.flushTimer) {
|
|
190
|
+
clearTimeout(this.flushTimer);
|
|
191
|
+
this.flushTimer = null;
|
|
192
|
+
}
|
|
193
|
+
if (this.eventQueue.length === 0) return;
|
|
194
|
+
const events = [...this.eventQueue];
|
|
195
|
+
this.eventQueue = [];
|
|
196
|
+
try {
|
|
197
|
+
await fetch(`${this.baseUrl}/api/v1/client/events?apiKey=${this.publishableKey}`, {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers: { "Content-Type": "application/json" },
|
|
200
|
+
body: JSON.stringify(events)
|
|
201
|
+
});
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.error("[Experiment SDK] Failed to flush events", err);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
getSnapshot() {
|
|
207
|
+
return this.state;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// src/index.tsx
|
|
212
|
+
import { jsx } from "react/jsx-runtime";
|
|
213
|
+
var ExperimentContext = createContext(null);
|
|
214
|
+
function ExperimentProvider({
|
|
215
|
+
client,
|
|
216
|
+
publishableKey,
|
|
217
|
+
userId,
|
|
218
|
+
baseUrl,
|
|
219
|
+
context,
|
|
220
|
+
children
|
|
221
|
+
}) {
|
|
222
|
+
const resolvedClient = useMemo(() => {
|
|
223
|
+
if (client) return client;
|
|
224
|
+
if (publishableKey) {
|
|
225
|
+
return new ExperimentClient({
|
|
226
|
+
publishableKey,
|
|
227
|
+
userId: userId || "anonymous",
|
|
228
|
+
baseUrl: baseUrl || "",
|
|
229
|
+
context: context || {}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}, [client, publishableKey, userId, baseUrl]);
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (resolvedClient) {
|
|
236
|
+
resolvedClient.init().catch(() => {
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}, [resolvedClient]);
|
|
240
|
+
return /* @__PURE__ */ jsx(ExperimentContext.Provider, { value: resolvedClient, children });
|
|
241
|
+
}
|
|
242
|
+
function useExperimentClient() {
|
|
243
|
+
return useContext(ExperimentContext);
|
|
244
|
+
}
|
|
245
|
+
function useFlag(key) {
|
|
246
|
+
const client = useExperimentClient();
|
|
247
|
+
useSyncExternalStore(
|
|
248
|
+
(l) => client ? client.subscribe(l) : () => {
|
|
249
|
+
},
|
|
250
|
+
() => client ? client.getSnapshot() : EMPTY_STATE,
|
|
251
|
+
() => client ? client.getSnapshot() : EMPTY_STATE
|
|
252
|
+
);
|
|
253
|
+
if (!client) return false;
|
|
254
|
+
return client.getFlag(key);
|
|
255
|
+
}
|
|
256
|
+
function useExperiment(key) {
|
|
257
|
+
const client = useExperimentClient();
|
|
258
|
+
useSyncExternalStore(
|
|
259
|
+
(l) => client ? client.subscribe(l) : () => {
|
|
260
|
+
},
|
|
261
|
+
() => client ? client.getSnapshot() : EMPTY_STATE,
|
|
262
|
+
() => client ? client.getSnapshot() : EMPTY_STATE
|
|
263
|
+
);
|
|
264
|
+
if (!client) return null;
|
|
265
|
+
return client.getExperimentVariant(key);
|
|
266
|
+
}
|
|
267
|
+
function useTrack() {
|
|
268
|
+
const client = useExperimentClient();
|
|
269
|
+
return (name, payload) => {
|
|
270
|
+
if (client) {
|
|
271
|
+
client.track(name, payload);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function useManifest() {
|
|
276
|
+
const client = useExperimentClient();
|
|
277
|
+
const state = useSyncExternalStore(
|
|
278
|
+
(l) => client ? client.subscribe(l) : () => {
|
|
279
|
+
},
|
|
280
|
+
() => client ? client.getSnapshot() : EMPTY_STATE,
|
|
281
|
+
() => client ? client.getSnapshot() : EMPTY_STATE
|
|
282
|
+
);
|
|
283
|
+
return { manifest: state.manifest, loading: state.loading };
|
|
284
|
+
}
|
|
285
|
+
export {
|
|
286
|
+
EMPTY_STATE,
|
|
287
|
+
ExperimentClient,
|
|
288
|
+
ExperimentProvider,
|
|
289
|
+
useExperiment,
|
|
290
|
+
useExperimentClient,
|
|
291
|
+
useFlag,
|
|
292
|
+
useManifest,
|
|
293
|
+
useTrack
|
|
294
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@iggycodes/experimentlab-react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React hooks and context provider for ExperimentLab feature flags and A/B experiments",
|
|
5
|
+
"main": "./dist/index.cjs",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.cjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup src/index.tsx --format cjs,esm --dts --clean --external react,react-dom --no-splitting",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "vitest run"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@iggycodes/experimentlab-core": "^0.1.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"react": ">=18.0.0",
|
|
28
|
+
"react-dom": ">=18.0.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/react": "^19.0.0",
|
|
32
|
+
"@types/react-dom": "^19.0.0",
|
|
33
|
+
"react": "^19.0.0",
|
|
34
|
+
"react-dom": "^19.0.0",
|
|
35
|
+
"tsup": "^8.3.5",
|
|
36
|
+
"typescript": "^5.6.3",
|
|
37
|
+
"vitest": "^2.1.8"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|