@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 +24 -0
- package/dist/index.d.mts +120 -0
- package/dist/index.d.ts +120 -0
- package/dist/index.js +418 -0
- package/dist/index.mjs +394 -0
- package/package.json +40 -0
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>
|
package/dist/index.d.mts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|