@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.
@@ -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 };
@@ -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
+ }