@experiwall/react 0.2.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,24 @@
1
+ <p align="center">
2
+ <img
3
+ src=".github/experiwall.svg"
4
+ align="center"
5
+ width="100"
6
+ alt="Experiwall React SDK"
7
+ title="Experiwall React SDK"
8
+ />
9
+ <h1 align="center">๐Ÿ’™๐Ÿฅฝ Experiwall React SDK โš›๏ธ๐Ÿงช</h1>
10
+ </p>
11
+
12
+
13
+ <p align="center">
14
+ <img
15
+ src=".github/preview.png"
16
+ align="center"
17
+ alt="Experiwall React SDK"
18
+ title="Experiwall React SDK"
19
+ />
20
+ </p>
21
+
22
+ <p align="center">
23
+ ๐Ÿ’™ Find what your users love with social experiments ๐Ÿฅฝ
24
+ </p>
@@ -0,0 +1,120 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ReactNode } from 'react';
3
+
4
+ interface InitResponse {
5
+ user_seed: number;
6
+ assignments: Record<string, string>;
7
+ experiments?: Record<string, {
8
+ variants: {
9
+ key: string;
10
+ weight: number;
11
+ }[];
12
+ }>;
13
+ }
14
+ interface FlagRegistration {
15
+ flag_key: string;
16
+ variants: string[];
17
+ assigned_variant: string;
18
+ user_id?: string;
19
+ alias_id?: string;
20
+ }
21
+ interface FlagRegistrationResponse {
22
+ variant: string;
23
+ }
24
+ interface ExperiwallEvent {
25
+ event_name: string;
26
+ experiment_key?: string;
27
+ variant_key?: string;
28
+ timestamp?: string;
29
+ properties?: Record<string, unknown>;
30
+ }
31
+ interface ExperiwallConfig {
32
+ apiKey: string;
33
+ baseUrl?: string;
34
+ userId?: string;
35
+ aliasId?: string;
36
+ /**
37
+ * Force specific variants for QA and testing.
38
+ * Overridden flags skip exposure tracking, server registration,
39
+ * and bucketing โ€” no experiment data is contaminated.
40
+ *
41
+ * ```tsx
42
+ * <ExperiwallProvider
43
+ * apiKey="..."
44
+ * overrides={{ "checkout-flow": "new-checkout" }}
45
+ * >
46
+ * ```
47
+ */
48
+ overrides?: Record<string, string>;
49
+ /**
50
+ * Tag events with an environment label so the dashboard can
51
+ * segment dev traffic from production experiment results.
52
+ * Defaults to `"production"` if omitted.
53
+ *
54
+ * ```tsx
55
+ * <ExperiwallProvider
56
+ * apiKey="..."
57
+ * environment={process.env.NODE_ENV === "production" ? "production" : "development"}
58
+ * >
59
+ * ```
60
+ */
61
+ environment?: string;
62
+ }
63
+ interface UseExperimentOptions {
64
+ /**
65
+ * Force this hook to return a specific variant.
66
+ * Takes precedence over provider-level overrides.
67
+ * Skips exposure tracking and server registration.
68
+ */
69
+ force?: string;
70
+ }
71
+
72
+ interface ExperiwallContextValue {
73
+ userSeed: number | null;
74
+ assignments: Record<string, string>;
75
+ experiments: InitResponse["experiments"];
76
+ overrides: Record<string, string>;
77
+ isLoading: boolean;
78
+ error: Error | null;
79
+ trackEvent: (event: ExperiwallEvent) => void;
80
+ registerLocalFlag: (flagKey: string, variants: string[], assignedVariant: string) => void;
81
+ providerConfig: ExperiwallConfig;
82
+ }
83
+ declare function ExperiwallProvider({ children, ...config }: ExperiwallConfig & {
84
+ children: ReactNode;
85
+ }): react_jsx_runtime.JSX.Element;
86
+
87
+ /**
88
+ * Access the Experiwall SDK context.
89
+ * Must be used within an <ExperiwallProvider>.
90
+ */
91
+ declare function useExperiwall(): ExperiwallContextValue;
92
+
93
+ /**
94
+ * Get the assigned variant for an experiment flag.
95
+ *
96
+ * - Returns the variant string synchronously if the flag is known
97
+ * (either from /init or a previous call).
98
+ * - Returns `null` only during the initial /init fetch.
99
+ * - For unknown flags, buckets locally (sync) and registers with
100
+ * the server async.
101
+ * - Automatically tracks a `$exposure` event once per mount.
102
+ *
103
+ * Forced variants (via `options.force` or provider-level `overrides`)
104
+ * bypass exposure tracking, server registration, and bucketing
105
+ * entirely โ€” no experiment data is contaminated.
106
+ */
107
+ declare function useExperiment(flagKey: string, variants: string[], options?: UseExperimentOptions): string | null;
108
+
109
+ /**
110
+ * Returns a `track` function for sending custom events.
111
+ *
112
+ * Usage:
113
+ * ```tsx
114
+ * const track = useTrack();
115
+ * track('purchase', { revenue: 9.99 });
116
+ * ```
117
+ */
118
+ declare function useTrack(): (eventName: string, properties?: Record<string, unknown>) => void;
119
+
120
+ export { type ExperiwallConfig, type ExperiwallContextValue, type ExperiwallEvent, ExperiwallProvider, type FlagRegistration, type FlagRegistrationResponse, type InitResponse, type UseExperimentOptions, useExperiment, useExperiwall, useTrack };
@@ -0,0 +1,120 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ReactNode } from 'react';
3
+
4
+ interface InitResponse {
5
+ user_seed: number;
6
+ assignments: Record<string, string>;
7
+ experiments?: Record<string, {
8
+ variants: {
9
+ key: string;
10
+ weight: number;
11
+ }[];
12
+ }>;
13
+ }
14
+ interface FlagRegistration {
15
+ flag_key: string;
16
+ variants: string[];
17
+ assigned_variant: string;
18
+ user_id?: string;
19
+ alias_id?: string;
20
+ }
21
+ interface FlagRegistrationResponse {
22
+ variant: string;
23
+ }
24
+ interface ExperiwallEvent {
25
+ event_name: string;
26
+ experiment_key?: string;
27
+ variant_key?: string;
28
+ timestamp?: string;
29
+ properties?: Record<string, unknown>;
30
+ }
31
+ interface ExperiwallConfig {
32
+ apiKey: string;
33
+ baseUrl?: string;
34
+ userId?: string;
35
+ aliasId?: string;
36
+ /**
37
+ * Force specific variants for QA and testing.
38
+ * Overridden flags skip exposure tracking, server registration,
39
+ * and bucketing โ€” no experiment data is contaminated.
40
+ *
41
+ * ```tsx
42
+ * <ExperiwallProvider
43
+ * apiKey="..."
44
+ * overrides={{ "checkout-flow": "new-checkout" }}
45
+ * >
46
+ * ```
47
+ */
48
+ overrides?: Record<string, string>;
49
+ /**
50
+ * Tag events with an environment label so the dashboard can
51
+ * segment dev traffic from production experiment results.
52
+ * Defaults to `"production"` if omitted.
53
+ *
54
+ * ```tsx
55
+ * <ExperiwallProvider
56
+ * apiKey="..."
57
+ * environment={process.env.NODE_ENV === "production" ? "production" : "development"}
58
+ * >
59
+ * ```
60
+ */
61
+ environment?: string;
62
+ }
63
+ interface UseExperimentOptions {
64
+ /**
65
+ * Force this hook to return a specific variant.
66
+ * Takes precedence over provider-level overrides.
67
+ * Skips exposure tracking and server registration.
68
+ */
69
+ force?: string;
70
+ }
71
+
72
+ interface ExperiwallContextValue {
73
+ userSeed: number | null;
74
+ assignments: Record<string, string>;
75
+ experiments: InitResponse["experiments"];
76
+ overrides: Record<string, string>;
77
+ isLoading: boolean;
78
+ error: Error | null;
79
+ trackEvent: (event: ExperiwallEvent) => void;
80
+ registerLocalFlag: (flagKey: string, variants: string[], assignedVariant: string) => void;
81
+ providerConfig: ExperiwallConfig;
82
+ }
83
+ declare function ExperiwallProvider({ children, ...config }: ExperiwallConfig & {
84
+ children: ReactNode;
85
+ }): react_jsx_runtime.JSX.Element;
86
+
87
+ /**
88
+ * Access the Experiwall SDK context.
89
+ * Must be used within an <ExperiwallProvider>.
90
+ */
91
+ declare function useExperiwall(): ExperiwallContextValue;
92
+
93
+ /**
94
+ * Get the assigned variant for an experiment flag.
95
+ *
96
+ * - Returns the variant string synchronously if the flag is known
97
+ * (either from /init or a previous call).
98
+ * - Returns `null` only during the initial /init fetch.
99
+ * - For unknown flags, buckets locally (sync) and registers with
100
+ * the server async.
101
+ * - Automatically tracks a `$exposure` event once per mount.
102
+ *
103
+ * Forced variants (via `options.force` or provider-level `overrides`)
104
+ * bypass exposure tracking, server registration, and bucketing
105
+ * entirely โ€” no experiment data is contaminated.
106
+ */
107
+ declare function useExperiment(flagKey: string, variants: string[], options?: UseExperimentOptions): string | null;
108
+
109
+ /**
110
+ * Returns a `track` function for sending custom events.
111
+ *
112
+ * Usage:
113
+ * ```tsx
114
+ * const track = useTrack();
115
+ * track('purchase', { revenue: 9.99 });
116
+ * ```
117
+ */
118
+ declare function useTrack(): (eventName: string, properties?: Record<string, unknown>) => void;
119
+
120
+ export { type ExperiwallConfig, type ExperiwallContextValue, type ExperiwallEvent, ExperiwallProvider, type FlagRegistration, type FlagRegistrationResponse, type InitResponse, type UseExperimentOptions, useExperiment, useExperiwall, useTrack };
package/dist/index.js ADDED
@@ -0,0 +1,418 @@
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.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ ExperiwallProvider: () => ExperiwallProvider,
24
+ useExperiment: () => useExperiment,
25
+ useExperiwall: () => useExperiwall,
26
+ useTrack: () => useTrack
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // src/provider.tsx
31
+ var import_react = require("react");
32
+
33
+ // src/lib/api-client.ts
34
+ var DEFAULT_BASE_URL = "https://experiwall.com";
35
+ async function fetchInit(apiKey, options) {
36
+ const base = options?.baseUrl ?? DEFAULT_BASE_URL;
37
+ const url = new URL("/api/sdk/init", base);
38
+ if (options?.userId) url.searchParams.set("user_id", options.userId);
39
+ if (options?.aliasId) url.searchParams.set("alias_id", options.aliasId);
40
+ if (options?.environment) url.searchParams.set("environment", options.environment);
41
+ const res = await fetch(url.toString(), {
42
+ headers: { "x-api-key": apiKey }
43
+ });
44
+ if (!res.ok) {
45
+ throw new Error(`Experiwall API error: ${res.status}`);
46
+ }
47
+ return res.json();
48
+ }
49
+ async function registerFlag(apiKey, registration, options) {
50
+ const base = options?.baseUrl ?? DEFAULT_BASE_URL;
51
+ const url = new URL("/api/sdk/flags", base);
52
+ const res = await fetch(url.toString(), {
53
+ method: "POST",
54
+ headers: {
55
+ "x-api-key": apiKey,
56
+ "Content-Type": "application/json"
57
+ },
58
+ body: JSON.stringify({ ...registration, environment: options?.environment })
59
+ });
60
+ if (!res.ok) {
61
+ throw new Error(`Experiwall API error: ${res.status}`);
62
+ }
63
+ return res.json();
64
+ }
65
+ async function sendEvents(apiKey, events, options) {
66
+ const base = options?.baseUrl ?? DEFAULT_BASE_URL;
67
+ const url = new URL("/api/sdk/events", base);
68
+ const res = await fetch(url.toString(), {
69
+ method: "POST",
70
+ headers: {
71
+ "x-api-key": apiKey,
72
+ "Content-Type": "application/json"
73
+ },
74
+ body: JSON.stringify({
75
+ events,
76
+ user_id: options?.userId,
77
+ alias_id: options?.aliasId,
78
+ environment: options?.environment
79
+ }),
80
+ keepalive: options?.keepalive
81
+ });
82
+ if (!res.ok) {
83
+ const err = new Error(`Experiwall API error: ${res.status}`);
84
+ err.status = res.status;
85
+ throw err;
86
+ }
87
+ }
88
+
89
+ // src/lib/event-batcher.ts
90
+ var FLUSH_INTERVAL_MS = 3e4;
91
+ var MAX_QUEUE_SIZE = 1e3;
92
+ var MAX_BACKOFF_MS = 6e4;
93
+ var EventBatcher = class {
94
+ constructor(opts) {
95
+ this.queue = [];
96
+ this.timer = null;
97
+ this.backoffMs = 0;
98
+ this.backoffTimer = null;
99
+ this.flushing = false;
100
+ this.apiKey = opts.apiKey;
101
+ this.baseUrl = opts.baseUrl;
102
+ this.userId = opts.userId;
103
+ this.aliasId = opts.aliasId;
104
+ this.environment = opts.environment;
105
+ }
106
+ start() {
107
+ if (this.timer) return;
108
+ this.timer = setInterval(() => {
109
+ if (this.backoffMs > 0) return;
110
+ this.flush();
111
+ }, FLUSH_INTERVAL_MS);
112
+ }
113
+ stop() {
114
+ if (this.timer) {
115
+ clearInterval(this.timer);
116
+ this.timer = null;
117
+ }
118
+ if (this.backoffTimer) {
119
+ clearTimeout(this.backoffTimer);
120
+ this.backoffTimer = null;
121
+ }
122
+ this.flush(true);
123
+ }
124
+ push(event) {
125
+ if (this.queue.length >= MAX_QUEUE_SIZE) {
126
+ this.queue.shift();
127
+ }
128
+ this.queue.push({
129
+ ...event,
130
+ timestamp: event.timestamp ?? (/* @__PURE__ */ new Date()).toISOString()
131
+ });
132
+ }
133
+ async flush(keepalive = false) {
134
+ if (this.flushing || this.queue.length === 0) return;
135
+ this.flushing = true;
136
+ const batch = this.queue.splice(0);
137
+ try {
138
+ await sendEvents(this.apiKey, batch, {
139
+ baseUrl: this.baseUrl,
140
+ userId: this.userId,
141
+ aliasId: this.aliasId,
142
+ keepalive,
143
+ environment: this.environment
144
+ });
145
+ this.backoffMs = 0;
146
+ } catch (err) {
147
+ this.queue.unshift(...batch);
148
+ if (this.queue.length > MAX_QUEUE_SIZE) {
149
+ this.queue.splice(0, this.queue.length - MAX_QUEUE_SIZE);
150
+ }
151
+ const status = err.status;
152
+ if (status === 429) {
153
+ this.backoffMs = Math.min(
154
+ this.backoffMs === 0 ? 1e3 : this.backoffMs * 2,
155
+ MAX_BACKOFF_MS
156
+ );
157
+ this.backoffTimer = setTimeout(() => {
158
+ this.backoffTimer = null;
159
+ this.flush();
160
+ }, this.backoffMs);
161
+ }
162
+ } finally {
163
+ this.flushing = false;
164
+ }
165
+ }
166
+ };
167
+
168
+ // src/lib/cache.ts
169
+ var TTL_MS = 5 * 60 * 1e3;
170
+ function getCached(key) {
171
+ try {
172
+ const raw = localStorage.getItem(key);
173
+ if (!raw) return null;
174
+ const entry = JSON.parse(raw);
175
+ if (Date.now() - entry.timestamp > TTL_MS) {
176
+ localStorage.removeItem(key);
177
+ return null;
178
+ }
179
+ return entry.data;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+ function setCache(key, data) {
185
+ try {
186
+ const entry = { data, timestamp: Date.now() };
187
+ localStorage.setItem(key, JSON.stringify(entry));
188
+ } catch {
189
+ }
190
+ }
191
+
192
+ // src/provider.tsx
193
+ var import_jsx_runtime = require("react/jsx-runtime");
194
+ function getCacheKey(userId, aliasId, environment) {
195
+ const identity = userId || aliasId || "anon";
196
+ const env = environment || "production";
197
+ return `experiwall_init_${identity}_${env}`;
198
+ }
199
+ var ExperiwallContext = (0, import_react.createContext)(
200
+ null
201
+ );
202
+ function ExperiwallProvider({
203
+ children,
204
+ ...config
205
+ }) {
206
+ const [userSeed, setUserSeed] = (0, import_react.useState)(null);
207
+ const [assignments, setAssignments] = (0, import_react.useState)({});
208
+ const [experiments, setExperiments] = (0, import_react.useState)();
209
+ const [isLoading, setIsLoading] = (0, import_react.useState)(true);
210
+ const [error, setError] = (0, import_react.useState)(null);
211
+ const batcherRef = (0, import_react.useRef)(null);
212
+ (0, import_react.useEffect)(() => {
213
+ let cancelled = false;
214
+ const load = async () => {
215
+ setIsLoading(true);
216
+ setError(null);
217
+ const cacheKey = getCacheKey(config.userId, config.aliasId, config.environment);
218
+ const cached = getCached(cacheKey);
219
+ if (cached) {
220
+ setUserSeed(cached.user_seed);
221
+ setAssignments(cached.assignments);
222
+ setExperiments(cached.experiments);
223
+ setIsLoading(false);
224
+ return;
225
+ }
226
+ try {
227
+ const environment = config.environment ?? "production";
228
+ const data = await fetchInit(config.apiKey, {
229
+ baseUrl: config.baseUrl,
230
+ userId: config.userId,
231
+ aliasId: config.aliasId,
232
+ environment
233
+ });
234
+ if (!cancelled) {
235
+ setUserSeed(data.user_seed);
236
+ setAssignments(data.assignments);
237
+ setExperiments(data.experiments);
238
+ setCache(cacheKey, data);
239
+ }
240
+ } catch (err) {
241
+ if (!cancelled) {
242
+ setError(
243
+ err instanceof Error ? err : new Error("Failed to fetch init")
244
+ );
245
+ }
246
+ } finally {
247
+ if (!cancelled) {
248
+ setIsLoading(false);
249
+ }
250
+ }
251
+ };
252
+ load();
253
+ const batcher = new EventBatcher({
254
+ apiKey: config.apiKey,
255
+ baseUrl: config.baseUrl,
256
+ userId: config.userId,
257
+ aliasId: config.aliasId,
258
+ environment: config.environment ?? "production"
259
+ });
260
+ batcher.start();
261
+ batcherRef.current = batcher;
262
+ const handleVisibilityChange = () => {
263
+ if (document.visibilityState === "hidden") {
264
+ batcher.flush(true);
265
+ }
266
+ };
267
+ document.addEventListener("visibilitychange", handleVisibilityChange);
268
+ const handleBeforeUnload = () => batcher.flush(true);
269
+ window.addEventListener("beforeunload", handleBeforeUnload);
270
+ return () => {
271
+ cancelled = true;
272
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
273
+ window.removeEventListener("beforeunload", handleBeforeUnload);
274
+ batcher.stop();
275
+ };
276
+ }, [config.apiKey, config.userId, config.aliasId, config.environment]);
277
+ const trackEvent = (0, import_react.useCallback)((event) => {
278
+ batcherRef.current?.push(event);
279
+ }, []);
280
+ const registerLocalFlag = (0, import_react.useCallback)(
281
+ (flagKey, variants, assignedVariant) => {
282
+ setAssignments((prev) => ({ ...prev, [flagKey]: assignedVariant }));
283
+ registerFlag(
284
+ config.apiKey,
285
+ {
286
+ flag_key: flagKey,
287
+ variants,
288
+ assigned_variant: assignedVariant,
289
+ user_id: config.userId,
290
+ alias_id: config.aliasId
291
+ },
292
+ { baseUrl: config.baseUrl, environment: config.environment ?? "production" }
293
+ ).catch(() => {
294
+ });
295
+ },
296
+ [config.apiKey, config.baseUrl, config.userId, config.aliasId, config.environment]
297
+ );
298
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
299
+ ExperiwallContext.Provider,
300
+ {
301
+ value: {
302
+ userSeed,
303
+ assignments,
304
+ experiments,
305
+ overrides: config.overrides ?? {},
306
+ isLoading,
307
+ error,
308
+ trackEvent,
309
+ registerLocalFlag,
310
+ providerConfig: config
311
+ },
312
+ children
313
+ }
314
+ );
315
+ }
316
+
317
+ // src/hooks/use-experiwall.ts
318
+ var import_react2 = require("react");
319
+ function useExperiwall() {
320
+ const ctx = (0, import_react2.useContext)(ExperiwallContext);
321
+ if (!ctx) {
322
+ throw new Error("useExperiwall must be used within <ExperiwallProvider>");
323
+ }
324
+ return ctx;
325
+ }
326
+
327
+ // src/hooks/use-experiment.ts
328
+ var import_react3 = require("react");
329
+
330
+ // src/lib/bucketing.ts
331
+ function bucketLocally(variants, userSeed, weights) {
332
+ if (variants.length === 0) return null;
333
+ const count = variants.length;
334
+ if (weights && weights.length === count) {
335
+ let cumulative2 = 0;
336
+ for (let i = 0; i < count; i++) {
337
+ cumulative2 += weights[i];
338
+ if (userSeed < cumulative2) {
339
+ return variants[i];
340
+ }
341
+ }
342
+ return null;
343
+ }
344
+ const baseWeight = Math.floor(100 / count);
345
+ let cumulative = 0;
346
+ for (let i = 0; i < count; i++) {
347
+ const weight = i === count - 1 ? 100 - baseWeight * (count - 1) : baseWeight;
348
+ cumulative += weight;
349
+ if (userSeed < cumulative) {
350
+ return variants[i];
351
+ }
352
+ }
353
+ return null;
354
+ }
355
+
356
+ // src/hooks/use-experiment.ts
357
+ function useExperiment(flagKey, variants, options) {
358
+ const {
359
+ userSeed,
360
+ assignments,
361
+ experiments,
362
+ overrides,
363
+ trackEvent,
364
+ registerLocalFlag
365
+ } = useExperiwall();
366
+ const exposureTrackedRef = (0, import_react3.useRef)(false);
367
+ const registeredRef = (0, import_react3.useRef)(false);
368
+ const forced = options?.force ?? overrides[flagKey];
369
+ const isOverridden = forced !== void 0;
370
+ let variant = null;
371
+ if (isOverridden) {
372
+ variant = forced;
373
+ } else if (assignments[flagKey]) {
374
+ variant = assignments[flagKey];
375
+ } else if (userSeed !== null) {
376
+ const expData = experiments?.[flagKey];
377
+ const weights = expData?.variants.map((v) => v.weight);
378
+ variant = bucketLocally(variants, userSeed, weights);
379
+ if (variant && !registeredRef.current) {
380
+ registeredRef.current = true;
381
+ registerLocalFlag(flagKey, variants, variant);
382
+ }
383
+ }
384
+ (0, import_react3.useEffect)(() => {
385
+ if (isOverridden) return;
386
+ if (variant && !exposureTrackedRef.current) {
387
+ exposureTrackedRef.current = true;
388
+ trackEvent({
389
+ event_name: "$exposure",
390
+ experiment_key: flagKey,
391
+ variant_key: variant
392
+ });
393
+ }
394
+ }, [variant, flagKey, trackEvent, isOverridden]);
395
+ return variant;
396
+ }
397
+
398
+ // src/hooks/use-track.ts
399
+ var import_react4 = require("react");
400
+ function useTrack() {
401
+ const { trackEvent } = useExperiwall();
402
+ return (0, import_react4.useCallback)(
403
+ (eventName, properties) => {
404
+ trackEvent({
405
+ event_name: eventName,
406
+ properties
407
+ });
408
+ },
409
+ [trackEvent]
410
+ );
411
+ }
412
+ // Annotate the CommonJS export names for ESM import in node:
413
+ 0 && (module.exports = {
414
+ ExperiwallProvider,
415
+ useExperiment,
416
+ useExperiwall,
417
+ useTrack
418
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,394 @@
1
+ // src/provider.tsx
2
+ import {
3
+ createContext,
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ useState
8
+ } from "react";
9
+
10
+ // src/lib/api-client.ts
11
+ var DEFAULT_BASE_URL = "https://experiwall.com";
12
+ async function fetchInit(apiKey, options) {
13
+ const base = options?.baseUrl ?? DEFAULT_BASE_URL;
14
+ const url = new URL("/api/sdk/init", base);
15
+ if (options?.userId) url.searchParams.set("user_id", options.userId);
16
+ if (options?.aliasId) url.searchParams.set("alias_id", options.aliasId);
17
+ if (options?.environment) url.searchParams.set("environment", options.environment);
18
+ const res = await fetch(url.toString(), {
19
+ headers: { "x-api-key": apiKey }
20
+ });
21
+ if (!res.ok) {
22
+ throw new Error(`Experiwall API error: ${res.status}`);
23
+ }
24
+ return res.json();
25
+ }
26
+ async function registerFlag(apiKey, registration, options) {
27
+ const base = options?.baseUrl ?? DEFAULT_BASE_URL;
28
+ const url = new URL("/api/sdk/flags", base);
29
+ const res = await fetch(url.toString(), {
30
+ method: "POST",
31
+ headers: {
32
+ "x-api-key": apiKey,
33
+ "Content-Type": "application/json"
34
+ },
35
+ body: JSON.stringify({ ...registration, environment: options?.environment })
36
+ });
37
+ if (!res.ok) {
38
+ throw new Error(`Experiwall API error: ${res.status}`);
39
+ }
40
+ return res.json();
41
+ }
42
+ async function sendEvents(apiKey, events, options) {
43
+ const base = options?.baseUrl ?? DEFAULT_BASE_URL;
44
+ const url = new URL("/api/sdk/events", base);
45
+ const res = await fetch(url.toString(), {
46
+ method: "POST",
47
+ headers: {
48
+ "x-api-key": apiKey,
49
+ "Content-Type": "application/json"
50
+ },
51
+ body: JSON.stringify({
52
+ events,
53
+ user_id: options?.userId,
54
+ alias_id: options?.aliasId,
55
+ environment: options?.environment
56
+ }),
57
+ keepalive: options?.keepalive
58
+ });
59
+ if (!res.ok) {
60
+ const err = new Error(`Experiwall API error: ${res.status}`);
61
+ err.status = res.status;
62
+ throw err;
63
+ }
64
+ }
65
+
66
+ // src/lib/event-batcher.ts
67
+ var FLUSH_INTERVAL_MS = 3e4;
68
+ var MAX_QUEUE_SIZE = 1e3;
69
+ var MAX_BACKOFF_MS = 6e4;
70
+ var EventBatcher = class {
71
+ constructor(opts) {
72
+ this.queue = [];
73
+ this.timer = null;
74
+ this.backoffMs = 0;
75
+ this.backoffTimer = null;
76
+ this.flushing = false;
77
+ this.apiKey = opts.apiKey;
78
+ this.baseUrl = opts.baseUrl;
79
+ this.userId = opts.userId;
80
+ this.aliasId = opts.aliasId;
81
+ this.environment = opts.environment;
82
+ }
83
+ start() {
84
+ if (this.timer) return;
85
+ this.timer = setInterval(() => {
86
+ if (this.backoffMs > 0) return;
87
+ this.flush();
88
+ }, FLUSH_INTERVAL_MS);
89
+ }
90
+ stop() {
91
+ if (this.timer) {
92
+ clearInterval(this.timer);
93
+ this.timer = null;
94
+ }
95
+ if (this.backoffTimer) {
96
+ clearTimeout(this.backoffTimer);
97
+ this.backoffTimer = null;
98
+ }
99
+ this.flush(true);
100
+ }
101
+ push(event) {
102
+ if (this.queue.length >= MAX_QUEUE_SIZE) {
103
+ this.queue.shift();
104
+ }
105
+ this.queue.push({
106
+ ...event,
107
+ timestamp: event.timestamp ?? (/* @__PURE__ */ new Date()).toISOString()
108
+ });
109
+ }
110
+ async flush(keepalive = false) {
111
+ if (this.flushing || this.queue.length === 0) return;
112
+ this.flushing = true;
113
+ const batch = this.queue.splice(0);
114
+ try {
115
+ await sendEvents(this.apiKey, batch, {
116
+ baseUrl: this.baseUrl,
117
+ userId: this.userId,
118
+ aliasId: this.aliasId,
119
+ keepalive,
120
+ environment: this.environment
121
+ });
122
+ this.backoffMs = 0;
123
+ } catch (err) {
124
+ this.queue.unshift(...batch);
125
+ if (this.queue.length > MAX_QUEUE_SIZE) {
126
+ this.queue.splice(0, this.queue.length - MAX_QUEUE_SIZE);
127
+ }
128
+ const status = err.status;
129
+ if (status === 429) {
130
+ this.backoffMs = Math.min(
131
+ this.backoffMs === 0 ? 1e3 : this.backoffMs * 2,
132
+ MAX_BACKOFF_MS
133
+ );
134
+ this.backoffTimer = setTimeout(() => {
135
+ this.backoffTimer = null;
136
+ this.flush();
137
+ }, this.backoffMs);
138
+ }
139
+ } finally {
140
+ this.flushing = false;
141
+ }
142
+ }
143
+ };
144
+
145
+ // src/lib/cache.ts
146
+ var TTL_MS = 5 * 60 * 1e3;
147
+ function getCached(key) {
148
+ try {
149
+ const raw = localStorage.getItem(key);
150
+ if (!raw) return null;
151
+ const entry = JSON.parse(raw);
152
+ if (Date.now() - entry.timestamp > TTL_MS) {
153
+ localStorage.removeItem(key);
154
+ return null;
155
+ }
156
+ return entry.data;
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+ function setCache(key, data) {
162
+ try {
163
+ const entry = { data, timestamp: Date.now() };
164
+ localStorage.setItem(key, JSON.stringify(entry));
165
+ } catch {
166
+ }
167
+ }
168
+
169
+ // src/provider.tsx
170
+ import { jsx } from "react/jsx-runtime";
171
+ function getCacheKey(userId, aliasId, environment) {
172
+ const identity = userId || aliasId || "anon";
173
+ const env = environment || "production";
174
+ return `experiwall_init_${identity}_${env}`;
175
+ }
176
+ var ExperiwallContext = createContext(
177
+ null
178
+ );
179
+ function ExperiwallProvider({
180
+ children,
181
+ ...config
182
+ }) {
183
+ const [userSeed, setUserSeed] = useState(null);
184
+ const [assignments, setAssignments] = useState({});
185
+ const [experiments, setExperiments] = useState();
186
+ const [isLoading, setIsLoading] = useState(true);
187
+ const [error, setError] = useState(null);
188
+ const batcherRef = useRef(null);
189
+ useEffect(() => {
190
+ let cancelled = false;
191
+ const load = async () => {
192
+ setIsLoading(true);
193
+ setError(null);
194
+ const cacheKey = getCacheKey(config.userId, config.aliasId, config.environment);
195
+ const cached = getCached(cacheKey);
196
+ if (cached) {
197
+ setUserSeed(cached.user_seed);
198
+ setAssignments(cached.assignments);
199
+ setExperiments(cached.experiments);
200
+ setIsLoading(false);
201
+ return;
202
+ }
203
+ try {
204
+ const environment = config.environment ?? "production";
205
+ const data = await fetchInit(config.apiKey, {
206
+ baseUrl: config.baseUrl,
207
+ userId: config.userId,
208
+ aliasId: config.aliasId,
209
+ environment
210
+ });
211
+ if (!cancelled) {
212
+ setUserSeed(data.user_seed);
213
+ setAssignments(data.assignments);
214
+ setExperiments(data.experiments);
215
+ setCache(cacheKey, data);
216
+ }
217
+ } catch (err) {
218
+ if (!cancelled) {
219
+ setError(
220
+ err instanceof Error ? err : new Error("Failed to fetch init")
221
+ );
222
+ }
223
+ } finally {
224
+ if (!cancelled) {
225
+ setIsLoading(false);
226
+ }
227
+ }
228
+ };
229
+ load();
230
+ const batcher = new EventBatcher({
231
+ apiKey: config.apiKey,
232
+ baseUrl: config.baseUrl,
233
+ userId: config.userId,
234
+ aliasId: config.aliasId,
235
+ environment: config.environment ?? "production"
236
+ });
237
+ batcher.start();
238
+ batcherRef.current = batcher;
239
+ const handleVisibilityChange = () => {
240
+ if (document.visibilityState === "hidden") {
241
+ batcher.flush(true);
242
+ }
243
+ };
244
+ document.addEventListener("visibilitychange", handleVisibilityChange);
245
+ const handleBeforeUnload = () => batcher.flush(true);
246
+ window.addEventListener("beforeunload", handleBeforeUnload);
247
+ return () => {
248
+ cancelled = true;
249
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
250
+ window.removeEventListener("beforeunload", handleBeforeUnload);
251
+ batcher.stop();
252
+ };
253
+ }, [config.apiKey, config.userId, config.aliasId, config.environment]);
254
+ const trackEvent = useCallback((event) => {
255
+ batcherRef.current?.push(event);
256
+ }, []);
257
+ const registerLocalFlag = useCallback(
258
+ (flagKey, variants, assignedVariant) => {
259
+ setAssignments((prev) => ({ ...prev, [flagKey]: assignedVariant }));
260
+ registerFlag(
261
+ config.apiKey,
262
+ {
263
+ flag_key: flagKey,
264
+ variants,
265
+ assigned_variant: assignedVariant,
266
+ user_id: config.userId,
267
+ alias_id: config.aliasId
268
+ },
269
+ { baseUrl: config.baseUrl, environment: config.environment ?? "production" }
270
+ ).catch(() => {
271
+ });
272
+ },
273
+ [config.apiKey, config.baseUrl, config.userId, config.aliasId, config.environment]
274
+ );
275
+ return /* @__PURE__ */ jsx(
276
+ ExperiwallContext.Provider,
277
+ {
278
+ value: {
279
+ userSeed,
280
+ assignments,
281
+ experiments,
282
+ overrides: config.overrides ?? {},
283
+ isLoading,
284
+ error,
285
+ trackEvent,
286
+ registerLocalFlag,
287
+ providerConfig: config
288
+ },
289
+ children
290
+ }
291
+ );
292
+ }
293
+
294
+ // src/hooks/use-experiwall.ts
295
+ import { useContext } from "react";
296
+ function useExperiwall() {
297
+ const ctx = useContext(ExperiwallContext);
298
+ if (!ctx) {
299
+ throw new Error("useExperiwall must be used within <ExperiwallProvider>");
300
+ }
301
+ return ctx;
302
+ }
303
+
304
+ // src/hooks/use-experiment.ts
305
+ import { useEffect as useEffect2, useRef as useRef2 } from "react";
306
+
307
+ // src/lib/bucketing.ts
308
+ function bucketLocally(variants, userSeed, weights) {
309
+ if (variants.length === 0) return null;
310
+ const count = variants.length;
311
+ if (weights && weights.length === count) {
312
+ let cumulative2 = 0;
313
+ for (let i = 0; i < count; i++) {
314
+ cumulative2 += weights[i];
315
+ if (userSeed < cumulative2) {
316
+ return variants[i];
317
+ }
318
+ }
319
+ return null;
320
+ }
321
+ const baseWeight = Math.floor(100 / count);
322
+ let cumulative = 0;
323
+ for (let i = 0; i < count; i++) {
324
+ const weight = i === count - 1 ? 100 - baseWeight * (count - 1) : baseWeight;
325
+ cumulative += weight;
326
+ if (userSeed < cumulative) {
327
+ return variants[i];
328
+ }
329
+ }
330
+ return null;
331
+ }
332
+
333
+ // src/hooks/use-experiment.ts
334
+ function useExperiment(flagKey, variants, options) {
335
+ const {
336
+ userSeed,
337
+ assignments,
338
+ experiments,
339
+ overrides,
340
+ trackEvent,
341
+ registerLocalFlag
342
+ } = useExperiwall();
343
+ const exposureTrackedRef = useRef2(false);
344
+ const registeredRef = useRef2(false);
345
+ const forced = options?.force ?? overrides[flagKey];
346
+ const isOverridden = forced !== void 0;
347
+ let variant = null;
348
+ if (isOverridden) {
349
+ variant = forced;
350
+ } else if (assignments[flagKey]) {
351
+ variant = assignments[flagKey];
352
+ } else if (userSeed !== null) {
353
+ const expData = experiments?.[flagKey];
354
+ const weights = expData?.variants.map((v) => v.weight);
355
+ variant = bucketLocally(variants, userSeed, weights);
356
+ if (variant && !registeredRef.current) {
357
+ registeredRef.current = true;
358
+ registerLocalFlag(flagKey, variants, variant);
359
+ }
360
+ }
361
+ useEffect2(() => {
362
+ if (isOverridden) return;
363
+ if (variant && !exposureTrackedRef.current) {
364
+ exposureTrackedRef.current = true;
365
+ trackEvent({
366
+ event_name: "$exposure",
367
+ experiment_key: flagKey,
368
+ variant_key: variant
369
+ });
370
+ }
371
+ }, [variant, flagKey, trackEvent, isOverridden]);
372
+ return variant;
373
+ }
374
+
375
+ // src/hooks/use-track.ts
376
+ import { useCallback as useCallback2 } from "react";
377
+ function useTrack() {
378
+ const { trackEvent } = useExperiwall();
379
+ return useCallback2(
380
+ (eventName, properties) => {
381
+ trackEvent({
382
+ event_name: eventName,
383
+ properties
384
+ });
385
+ },
386
+ [trackEvent]
387
+ );
388
+ }
389
+ export {
390
+ ExperiwallProvider,
391
+ useExperiment,
392
+ useExperiwall,
393
+ useTrack
394
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@experiwall/react",
3
+ "version": "0.2.0",
4
+ "description": "Experiwall React SDK โ€” code-first experimentation and A/B testing",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
13
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch"
14
+ },
15
+ "peerDependencies": {
16
+ "react": ">=18",
17
+ "react-dom": ">=18"
18
+ },
19
+ "devDependencies": {
20
+ "@types/react": "^19.2.14",
21
+ "@types/react-dom": "^19.2.3",
22
+ "react": "^19.0.0",
23
+ "react-dom": "^19.0.0",
24
+ "tsup": "^8.0.0",
25
+ "typescript": "^5.0.0"
26
+ },
27
+ "keywords": [
28
+ "experiwall",
29
+ "ui-experimentation",
30
+ "a-b-testing",
31
+ "remote-ui",
32
+ "screen-builder",
33
+ "react"
34
+ ],
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/marcelo-earth/experiwall-react"
39
+ }
40
+ }