@pattonwebz/split-tester-client 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/LICENSE +21 -0
- package/README.md +64 -0
- package/package.json +35 -0
- package/src/analytics/ga4.js +110 -0
- package/src/engine/contract.js +84 -0
- package/src/engine/runtime.js +274 -0
- package/src/experiments/schema.js +161 -0
- package/src/index.js +11 -0
- package/src/runtime/index.js +100 -0
- package/src/storage/assignment-store.js +78 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patton Webz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# @pattonwebz/split-tester-client
|
|
2
|
+
|
|
3
|
+
Client-side split-testing engine for manually defined JavaScript experiments with GA4-friendly analytics hooks.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @pattonwebz/split-tester-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## ESM-only
|
|
12
|
+
|
|
13
|
+
This package is published as ESM-only.
|
|
14
|
+
|
|
15
|
+
## Public API
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import {
|
|
19
|
+
createEngine,
|
|
20
|
+
createRuntime,
|
|
21
|
+
installRuntime,
|
|
22
|
+
createGa4Adapter,
|
|
23
|
+
defineExperiments
|
|
24
|
+
} from "@pattonwebz/split-tester-client";
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Preferred usage
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
import { installRuntime, createGa4Adapter } from "@pattonwebz/split-tester-client";
|
|
31
|
+
|
|
32
|
+
const splitTester = installRuntime({
|
|
33
|
+
analytics: createGa4Adapter({ gtag: window.gtag }),
|
|
34
|
+
getUserContext: () => ({
|
|
35
|
+
url: { pathname: window.location.pathname },
|
|
36
|
+
user: { id: window.__userId }
|
|
37
|
+
})
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
splitTester.define(({ context }) => ({
|
|
41
|
+
id: "hero-copy-test",
|
|
42
|
+
status: "active",
|
|
43
|
+
variants: [
|
|
44
|
+
{ id: "control", weight: 50 },
|
|
45
|
+
{ id: "v1", weight: 50 }
|
|
46
|
+
],
|
|
47
|
+
goals: [
|
|
48
|
+
{
|
|
49
|
+
id: "signup-intent",
|
|
50
|
+
events: ["cta_click", "form_start"]
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
variantRules: [
|
|
54
|
+
{
|
|
55
|
+
when: ({ events }) => events.includes("viewed_pricing") || context.url.pathname === "/pricing",
|
|
56
|
+
variant: "v1"
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
splitTester.trackEvent("cta_click", { source: "hero" });
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`installRuntime()` exposes a singleton on `window.splitTester` and accepts late `define(...)` calls, so other snippets can add tests after the base script loads.
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pattonwebz/split-tester-client",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Client-side JavaScript split testing engine with GA4 event support.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=18"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js",
|
|
12
|
+
"./ga4": "./src/analytics/ga4.js",
|
|
13
|
+
"./runtime": "./src/runtime/index.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"ab-testing",
|
|
23
|
+
"split-testing",
|
|
24
|
+
"ga4",
|
|
25
|
+
"experiments",
|
|
26
|
+
"analytics"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test",
|
|
30
|
+
"test:jest": "NODE_OPTIONS=--experimental-vm-modules jest --runInBand --config jest.config.js"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"jest": "^29.7.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const DEFAULT_EVENT_NAMES = Object.freeze({
|
|
2
|
+
impression: "split_test_impression",
|
|
3
|
+
conversion: "split_test_conversion"
|
|
4
|
+
});
|
|
5
|
+
|
|
6
|
+
function normalizePagePath(pagePath) {
|
|
7
|
+
return typeof pagePath === "string" && pagePath.trim() !== "" ? pagePath : "/";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function addDefined(target, key, value) {
|
|
11
|
+
if (value !== undefined && value !== null) {
|
|
12
|
+
target[key] = value;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildGa4ImpressionPayload({
|
|
17
|
+
experimentId,
|
|
18
|
+
variantId,
|
|
19
|
+
pagePath,
|
|
20
|
+
payload = {},
|
|
21
|
+
eventName = DEFAULT_EVENT_NAMES.impression
|
|
22
|
+
} = {}) {
|
|
23
|
+
const params = { ...payload };
|
|
24
|
+
addDefined(params, "experiment_id", experimentId);
|
|
25
|
+
addDefined(params, "variant_id", variantId);
|
|
26
|
+
addDefined(params, "page_path", normalizePagePath(pagePath));
|
|
27
|
+
|
|
28
|
+
return { eventName, params };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildGa4ConversionPayload({
|
|
32
|
+
experimentId,
|
|
33
|
+
variantId,
|
|
34
|
+
goalId,
|
|
35
|
+
pagePath,
|
|
36
|
+
payload = {},
|
|
37
|
+
eventName = DEFAULT_EVENT_NAMES.conversion
|
|
38
|
+
} = {}) {
|
|
39
|
+
const params = { ...payload };
|
|
40
|
+
addDefined(params, "experiment_id", experimentId);
|
|
41
|
+
addDefined(params, "variant_id", variantId);
|
|
42
|
+
addDefined(params, "goal_id", goalId);
|
|
43
|
+
addDefined(params, "page_path", normalizePagePath(pagePath));
|
|
44
|
+
|
|
45
|
+
return { eventName, params };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildGa4EventPayload({
|
|
49
|
+
eventName,
|
|
50
|
+
experimentId,
|
|
51
|
+
variantId,
|
|
52
|
+
goalId,
|
|
53
|
+
pagePath,
|
|
54
|
+
payload = {}
|
|
55
|
+
} = {}) {
|
|
56
|
+
const params = { ...payload };
|
|
57
|
+
addDefined(params, "event_name", eventName);
|
|
58
|
+
addDefined(params, "experiment_id", experimentId);
|
|
59
|
+
addDefined(params, "variant_id", variantId);
|
|
60
|
+
addDefined(params, "goal_id", goalId);
|
|
61
|
+
addDefined(params, "page_path", normalizePagePath(pagePath));
|
|
62
|
+
|
|
63
|
+
return { eventName, params };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function emit(gtag, eventName, params) {
|
|
67
|
+
if (typeof gtag === "function") {
|
|
68
|
+
gtag("event", eventName, params);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Creates a dependency-free GA4 adapter for engine analytics hooks.
|
|
74
|
+
*
|
|
75
|
+
* @param {{
|
|
76
|
+
* gtag: (...args: unknown[]) => void,
|
|
77
|
+
* eventNames?: { impression?: string, conversion?: string },
|
|
78
|
+
* baseParams?: Record<string, unknown>
|
|
79
|
+
* }} options
|
|
80
|
+
*/
|
|
81
|
+
export function createGa4Adapter({ gtag, eventNames = {}, baseParams = {} } = {}) {
|
|
82
|
+
if (typeof gtag !== "function") {
|
|
83
|
+
throw new Error("createGa4Adapter requires a gtag-like function.");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const names = { ...DEFAULT_EVENT_NAMES, ...eventNames };
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
trackImpression(input = {}) {
|
|
90
|
+
const { eventName, params } = buildGa4ImpressionPayload({
|
|
91
|
+
...input,
|
|
92
|
+
eventName: names.impression
|
|
93
|
+
});
|
|
94
|
+
emit(gtag, eventName, { ...baseParams, ...params });
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
trackConversion(input = {}) {
|
|
98
|
+
const { eventName, params } = buildGa4ConversionPayload({
|
|
99
|
+
...input,
|
|
100
|
+
eventName: names.conversion
|
|
101
|
+
});
|
|
102
|
+
emit(gtag, eventName, { ...baseParams, ...params });
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
trackEvent(input = {}) {
|
|
106
|
+
const { eventName, params } = buildGa4EventPayload(input);
|
|
107
|
+
emit(gtag, eventName, { ...baseParams, ...params });
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { validateExperiments } from "../experiments/schema.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} VariantConfig
|
|
5
|
+
* @property {string} id
|
|
6
|
+
* @property {number} weight
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} GoalConfig
|
|
11
|
+
* @property {string} id
|
|
12
|
+
* @property {string} [gaEventName]
|
|
13
|
+
* @property {string[]} [events]
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} AssignmentConfig
|
|
18
|
+
* @property {"sticky-hash"} [strategy]
|
|
19
|
+
* @property {(input: AssignmentInput) => string|null|undefined} [forceVariant]
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} VariantRuleConfig
|
|
24
|
+
* @property {(input: AssignmentInput) => boolean} when
|
|
25
|
+
* @property {string} variant
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} ExperimentConfig
|
|
30
|
+
* @property {string} id
|
|
31
|
+
* @property {"draft"|"active"|"paused"} status
|
|
32
|
+
* @property {VariantConfig[]} variants
|
|
33
|
+
* @property {(context: UserContext) => boolean} [targeting]
|
|
34
|
+
* @property {string|null} [mutualExclusionGroup]
|
|
35
|
+
* @property {number} [priority]
|
|
36
|
+
* @property {AssignmentConfig} [assignment]
|
|
37
|
+
* @property {VariantRuleConfig[]} [variantRules]
|
|
38
|
+
* @property {GoalConfig[]} [goals]
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {Object} UserContext
|
|
43
|
+
* @property {{ pathname: string }} url
|
|
44
|
+
* @property {Record<string, unknown>} [user]
|
|
45
|
+
* @property {string} [sessionId]
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {Object} AssignmentInput
|
|
50
|
+
* @property {UserContext} context
|
|
51
|
+
* @property {string[]} events
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {Object} AnalyticsAdapter
|
|
56
|
+
* @property {(payload: Record<string, unknown>) => void} [trackImpression]
|
|
57
|
+
* @property {(payload: { eventName: string, payload: Record<string, unknown>, pagePath?: string }) => void} [trackEvent]
|
|
58
|
+
* @property {(payload: Record<string, unknown>) => void} [trackConversion]
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {Object} StorageAdapter
|
|
63
|
+
* @property {(key: string) => string|null} [getItem]
|
|
64
|
+
* @property {(key: string, value: string) => void} [setItem]
|
|
65
|
+
* @property {(key: string) => void} [removeItem]
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @typedef {Object} EngineInitOptions
|
|
70
|
+
* @property {ExperimentConfig[]} experiments
|
|
71
|
+
* @property {() => UserContext} [getUserContext]
|
|
72
|
+
* @property {AnalyticsAdapter} [analytics]
|
|
73
|
+
* @property {StorageAdapter} [storage]
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Validates and returns experiment configs for runtime registration.
|
|
78
|
+
*
|
|
79
|
+
* @param {ExperimentConfig[]} experiments
|
|
80
|
+
* @returns {ExperimentConfig[]}
|
|
81
|
+
*/
|
|
82
|
+
export function defineExperiments(experiments) {
|
|
83
|
+
return validateExperiments(experiments);
|
|
84
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { validateExperiments } from "../experiments/schema.js";
|
|
2
|
+
import { createAssignmentStore } from "../storage/assignment-store.js";
|
|
3
|
+
|
|
4
|
+
function defaultGetUserContext() {
|
|
5
|
+
return { url: { pathname: "/" }, user: {} };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function hashString(value) {
|
|
9
|
+
let hash = 0;
|
|
10
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
11
|
+
hash = (hash * 31 + value.charCodeAt(index)) | 0;
|
|
12
|
+
}
|
|
13
|
+
return Math.abs(hash);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolveUserKey(context) {
|
|
17
|
+
if (context?.user && typeof context.user.id === "string" && context.user.id.trim() !== "") {
|
|
18
|
+
return `user:${context.user.id}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (typeof context?.sessionId === "string" && context.sessionId.trim() !== "") {
|
|
22
|
+
return `session:${context.sessionId}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return "anonymous";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getAssignmentKey(experimentId, context) {
|
|
29
|
+
return `split-tester:${experimentId}:${resolveUserKey(context)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function selectWeightedVariant(experiment, userKey) {
|
|
33
|
+
const totalWeight = experiment.variants.reduce((sum, variant) => sum + variant.weight, 0);
|
|
34
|
+
const bucket = hashString(`${userKey}:${experiment.id}`) % totalWeight;
|
|
35
|
+
|
|
36
|
+
let running = 0;
|
|
37
|
+
for (const variant of experiment.variants) {
|
|
38
|
+
running += variant.weight;
|
|
39
|
+
if (bucket < running) {
|
|
40
|
+
return variant.id;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return experiment.variants[experiment.variants.length - 1].id;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getPriority(experiment) {
|
|
48
|
+
return experiment.priority ?? 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assertKnownVariant(experiment, variantId) {
|
|
52
|
+
if (!experiment.variants.some((variant) => variant.id === variantId)) {
|
|
53
|
+
throw new Error(`Experiment "${experiment.id}" resolved unknown variant "${variantId}".`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getGoalMatches(experiment, eventName) {
|
|
58
|
+
if (!Array.isArray(experiment.goals)) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return experiment.goals.filter((goal) => Array.isArray(goal.events) && goal.events.includes(eventName));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {import("./contract.js").EngineInitOptions} [options]
|
|
67
|
+
*/
|
|
68
|
+
export function createEngine(options = {}) {
|
|
69
|
+
const analytics = options.analytics ?? {};
|
|
70
|
+
const getUserContext = options.getUserContext ?? defaultGetUserContext;
|
|
71
|
+
const assignmentStore = options.assignmentStore ?? createAssignmentStore(options.storage);
|
|
72
|
+
|
|
73
|
+
/** @type {import("./contract.js").ExperimentConfig[]} */
|
|
74
|
+
let experiments = [];
|
|
75
|
+
const assignments = new Map();
|
|
76
|
+
const emittedImpressions = new Set();
|
|
77
|
+
const seenEvents = new Set();
|
|
78
|
+
|
|
79
|
+
function registerExperiments(nextExperiments) {
|
|
80
|
+
const merged = [...experiments, ...nextExperiments];
|
|
81
|
+
experiments = validateExperiments(merged);
|
|
82
|
+
return experiments;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function listExperiments() {
|
|
86
|
+
return [...experiments];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (options.experiments?.length) {
|
|
90
|
+
registerExperiments(options.experiments);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveExplicitVariant(experiment, context, eventNames) {
|
|
94
|
+
const input = { context, events: eventNames };
|
|
95
|
+
|
|
96
|
+
const forcedVariant = experiment.assignment?.forceVariant?.(input);
|
|
97
|
+
if (forcedVariant) {
|
|
98
|
+
assertKnownVariant(experiment, forcedVariant);
|
|
99
|
+
return forcedVariant;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (experiment.variantRules) {
|
|
103
|
+
for (const rule of experiment.variantRules) {
|
|
104
|
+
if (rule.when(input)) {
|
|
105
|
+
assertKnownVariant(experiment, rule.variant);
|
|
106
|
+
return rule.variant;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readStickyVariant(experiment, context) {
|
|
115
|
+
const stored = assignmentStore.get(getAssignmentKey(experiment.id, context));
|
|
116
|
+
if (!stored) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!experiment.variants.some((variant) => variant.id === stored)) {
|
|
121
|
+
assignmentStore.delete(getAssignmentKey(experiment.id, context));
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return stored;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function persistVariant(experiment, context, variantId) {
|
|
129
|
+
const key = getAssignmentKey(experiment.id, context);
|
|
130
|
+
assignmentStore.set(key, variantId);
|
|
131
|
+
assignments.set(key, variantId);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function assignExperiment(experiment, context, eventNames) {
|
|
135
|
+
const explicitVariant = resolveExplicitVariant(experiment, context, eventNames);
|
|
136
|
+
if (explicitVariant) {
|
|
137
|
+
persistVariant(experiment, context, explicitVariant);
|
|
138
|
+
return explicitVariant;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const stickyVariant = readStickyVariant(experiment, context);
|
|
142
|
+
if (stickyVariant) {
|
|
143
|
+
assignments.set(getAssignmentKey(experiment.id, context), stickyVariant);
|
|
144
|
+
return stickyVariant;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const existing = assignments.get(getAssignmentKey(experiment.id, context));
|
|
148
|
+
if (existing) {
|
|
149
|
+
return existing;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const userKey = resolveUserKey(context);
|
|
153
|
+
const variantId = selectWeightedVariant(experiment, userKey);
|
|
154
|
+
persistVariant(experiment, context, variantId);
|
|
155
|
+
return variantId;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function shouldRunExperiment(experiment, context) {
|
|
159
|
+
if (experiment.status !== "active") {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!experiment.targeting) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return Boolean(experiment.targeting(context));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function getActiveExperimentsForPage(context = getUserContext()) {
|
|
171
|
+
const eventNames = [...seenEvents];
|
|
172
|
+
const active = experiments
|
|
173
|
+
.filter((experiment) => shouldRunExperiment(experiment, context))
|
|
174
|
+
.sort((left, right) => getPriority(right) - getPriority(left));
|
|
175
|
+
|
|
176
|
+
const selectedGroups = new Set();
|
|
177
|
+
const result = {};
|
|
178
|
+
|
|
179
|
+
for (const experiment of active) {
|
|
180
|
+
const group = experiment.mutualExclusionGroup;
|
|
181
|
+
if (group && selectedGroups.has(group)) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const variantId = assignExperiment(experiment, context, eventNames);
|
|
186
|
+
result[experiment.id] = variantId;
|
|
187
|
+
|
|
188
|
+
if (group) {
|
|
189
|
+
selectedGroups.add(group);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const impressionKey = `${resolveUserKey(context)}:${experiment.id}:${variantId}`;
|
|
193
|
+
if (!emittedImpressions.has(impressionKey) && analytics.trackImpression) {
|
|
194
|
+
analytics.trackImpression({
|
|
195
|
+
experimentId: experiment.id,
|
|
196
|
+
variantId,
|
|
197
|
+
pagePath: context?.url?.pathname ?? "/"
|
|
198
|
+
});
|
|
199
|
+
emittedImpressions.add(impressionKey);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getVariant(experimentId, context = getUserContext()) {
|
|
207
|
+
const assignmentsForPage = getActiveExperimentsForPage(context);
|
|
208
|
+
return assignmentsForPage[experimentId] ?? null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function trackEvent(eventName, payload = {}, context = getUserContext()) {
|
|
212
|
+
seenEvents.add(eventName);
|
|
213
|
+
|
|
214
|
+
if (analytics.trackEvent) {
|
|
215
|
+
analytics.trackEvent({
|
|
216
|
+
eventName,
|
|
217
|
+
payload,
|
|
218
|
+
pagePath: context?.url?.pathname ?? "/"
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const assignmentsForPage = getActiveExperimentsForPage(context);
|
|
223
|
+
|
|
224
|
+
for (const experiment of experiments) {
|
|
225
|
+
const variantId = assignmentsForPage[experiment.id];
|
|
226
|
+
if (!variantId) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const goal of getGoalMatches(experiment, eventName)) {
|
|
231
|
+
if (analytics.trackConversion) {
|
|
232
|
+
analytics.trackConversion({
|
|
233
|
+
experimentId: experiment.id,
|
|
234
|
+
variantId,
|
|
235
|
+
goalId: goal.id,
|
|
236
|
+
eventName,
|
|
237
|
+
payload,
|
|
238
|
+
pagePath: context?.url?.pathname ?? "/"
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return assignmentsForPage;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function trackConversion(experimentId, goalId, payload = {}, context = getUserContext()) {
|
|
248
|
+
const variantId = getVariant(experimentId, context);
|
|
249
|
+
if (!variantId) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (analytics.trackConversion) {
|
|
254
|
+
analytics.trackConversion({
|
|
255
|
+
experimentId,
|
|
256
|
+
variantId,
|
|
257
|
+
goalId,
|
|
258
|
+
payload,
|
|
259
|
+
pagePath: context?.url?.pathname ?? "/"
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { experimentId, variantId, goalId };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
registerExperiments,
|
|
268
|
+
listExperiments,
|
|
269
|
+
getActiveExperimentsForPage,
|
|
270
|
+
getVariant,
|
|
271
|
+
trackEvent,
|
|
272
|
+
trackConversion
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
const ALLOWED_STATUSES = new Set(["draft", "active", "paused"]);
|
|
2
|
+
|
|
3
|
+
function assertObject(value, label) {
|
|
4
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
5
|
+
throw new Error(`${label} must be an object.`);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function assertNonEmptyString(value, label) {
|
|
10
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
11
|
+
throw new Error(`${label} must be a non-empty string.`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function validateVariant(variant, experimentId) {
|
|
16
|
+
assertObject(variant, `Variant in experiment "${experimentId}"`);
|
|
17
|
+
assertNonEmptyString(variant.id, `Variant id in experiment "${experimentId}"`);
|
|
18
|
+
|
|
19
|
+
if (typeof variant.weight !== "number" || !Number.isFinite(variant.weight) || variant.weight <= 0) {
|
|
20
|
+
throw new Error(`Variant "${variant.id}" in experiment "${experimentId}" must have a positive numeric weight.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function validateVariantRules(experiment) {
|
|
25
|
+
if (experiment.variantRules === undefined) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!Array.isArray(experiment.variantRules)) {
|
|
30
|
+
throw new Error(`Experiment "${experiment.id}" variantRules must be an array when provided.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const rule of experiment.variantRules) {
|
|
34
|
+
assertObject(rule, `Variant rule in experiment "${experiment.id}"`);
|
|
35
|
+
assertNonEmptyString(rule.variant, `Variant rule target in experiment "${experiment.id}"`);
|
|
36
|
+
if (typeof rule.when !== "function") {
|
|
37
|
+
throw new Error(`Variant rule "${rule.variant}" in experiment "${experiment.id}" must define a when function.`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateAssignment(experiment) {
|
|
43
|
+
if (experiment.assignment === undefined) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
assertObject(experiment.assignment, `Experiment "${experiment.id}" assignment`);
|
|
48
|
+
|
|
49
|
+
if (experiment.assignment.strategy !== undefined && experiment.assignment.strategy !== "sticky-hash") {
|
|
50
|
+
throw new Error(`Experiment "${experiment.id}" assignment.strategy must be "sticky-hash" when provided.`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (experiment.assignment.forceVariant !== undefined && typeof experiment.assignment.forceVariant !== "function") {
|
|
54
|
+
throw new Error(`Experiment "${experiment.id}" assignment.forceVariant must be a function when provided.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validateGoal(goal, experimentId) {
|
|
59
|
+
assertObject(goal, `Goal in experiment "${experimentId}"`);
|
|
60
|
+
assertNonEmptyString(goal.id, `Goal id in experiment "${experimentId}"`);
|
|
61
|
+
|
|
62
|
+
if (goal.gaEventName !== undefined) {
|
|
63
|
+
assertNonEmptyString(goal.gaEventName, `Goal "${goal.id}" gaEventName in experiment "${experimentId}"`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (goal.events !== undefined) {
|
|
67
|
+
if (!Array.isArray(goal.events) || goal.events.length === 0) {
|
|
68
|
+
throw new Error(`Goal "${goal.id}" events in experiment "${experimentId}" must be a non-empty array.`);
|
|
69
|
+
}
|
|
70
|
+
for (const eventName of goal.events) {
|
|
71
|
+
assertNonEmptyString(eventName, `Goal "${goal.id}" event name in experiment "${experimentId}"`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (goal.gaEventName === undefined && goal.events === undefined) {
|
|
76
|
+
throw new Error(`Goal "${goal.id}" in experiment "${experimentId}" must define gaEventName and/or events.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function validateExperimentConfig(experiment) {
|
|
81
|
+
assertObject(experiment, "Experiment");
|
|
82
|
+
assertNonEmptyString(experiment.id, "Experiment id");
|
|
83
|
+
|
|
84
|
+
if (!ALLOWED_STATUSES.has(experiment.status)) {
|
|
85
|
+
throw new Error(`Experiment "${experiment.id}" status must be one of: draft, active, paused.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!Array.isArray(experiment.variants) || experiment.variants.length < 2) {
|
|
89
|
+
throw new Error(`Experiment "${experiment.id}" must define at least two variants.`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let totalWeight = 0;
|
|
93
|
+
const variantIds = new Set();
|
|
94
|
+
for (const variant of experiment.variants) {
|
|
95
|
+
validateVariant(variant, experiment.id);
|
|
96
|
+
if (variantIds.has(variant.id)) {
|
|
97
|
+
throw new Error(`Experiment "${experiment.id}" has duplicate variant id "${variant.id}".`);
|
|
98
|
+
}
|
|
99
|
+
variantIds.add(variant.id);
|
|
100
|
+
totalWeight += variant.weight;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (totalWeight <= 0) {
|
|
104
|
+
throw new Error(`Experiment "${experiment.id}" must have a positive total variant weight.`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (experiment.targeting !== undefined && typeof experiment.targeting !== "function") {
|
|
108
|
+
throw new Error(`Experiment "${experiment.id}" targeting must be a function when provided.`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (
|
|
112
|
+
experiment.mutualExclusionGroup !== undefined &&
|
|
113
|
+
experiment.mutualExclusionGroup !== null &&
|
|
114
|
+
typeof experiment.mutualExclusionGroup !== "string"
|
|
115
|
+
) {
|
|
116
|
+
throw new Error(`Experiment "${experiment.id}" mutualExclusionGroup must be a string or null when provided.`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (experiment.priority !== undefined && typeof experiment.priority !== "number") {
|
|
120
|
+
throw new Error(`Experiment "${experiment.id}" priority must be a number when provided.`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
validateAssignment(experiment);
|
|
124
|
+
validateVariantRules(experiment);
|
|
125
|
+
|
|
126
|
+
if (experiment.goals !== undefined) {
|
|
127
|
+
if (!Array.isArray(experiment.goals)) {
|
|
128
|
+
throw new Error(`Experiment "${experiment.id}" goals must be an array when provided.`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const goal of experiment.goals) {
|
|
132
|
+
validateGoal(goal, experiment.id);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (experiment.variantRules) {
|
|
137
|
+
for (const rule of experiment.variantRules) {
|
|
138
|
+
if (!variantIds.has(rule.variant)) {
|
|
139
|
+
throw new Error(`Experiment "${experiment.id}" variant rule points to unknown variant "${rule.variant}".`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function validateExperiments(experiments) {
|
|
146
|
+
if (!Array.isArray(experiments)) {
|
|
147
|
+
throw new Error("Experiments must be provided as an array.");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ids = new Set();
|
|
151
|
+
for (const experiment of experiments) {
|
|
152
|
+
validateExperimentConfig(experiment);
|
|
153
|
+
|
|
154
|
+
if (ids.has(experiment.id)) {
|
|
155
|
+
throw new Error(`Duplicate experiment id "${experiment.id}" is not allowed.`);
|
|
156
|
+
}
|
|
157
|
+
ids.add(experiment.id);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return experiments;
|
|
161
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { defineExperiments } from "./engine/contract.js";
|
|
2
|
+
export { createEngine } from "./engine/runtime.js";
|
|
3
|
+
export { createRuntime, getRuntime, installRuntime } from "./runtime/index.js";
|
|
4
|
+
export {
|
|
5
|
+
buildGa4ConversionPayload,
|
|
6
|
+
buildGa4EventPayload,
|
|
7
|
+
buildGa4ImpressionPayload,
|
|
8
|
+
createGa4Adapter
|
|
9
|
+
} from "./analytics/ga4.js";
|
|
10
|
+
export { validateExperimentConfig, validateExperiments } from "./experiments/schema.js";
|
|
11
|
+
export { createAssignmentStore } from "./storage/assignment-store.js";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { defineExperiments } from "../engine/contract.js";
|
|
2
|
+
import { createEngine } from "../engine/runtime.js";
|
|
3
|
+
|
|
4
|
+
const RUNTIME_KEY = Symbol.for("split-tester-client.runtime");
|
|
5
|
+
|
|
6
|
+
function normalizeDefinitions(definition, runtime) {
|
|
7
|
+
const resolved =
|
|
8
|
+
typeof definition === "function"
|
|
9
|
+
? definition({
|
|
10
|
+
runtime,
|
|
11
|
+
context: runtime.getUserContext(),
|
|
12
|
+
experiments: runtime.listExperiments()
|
|
13
|
+
})
|
|
14
|
+
: definition;
|
|
15
|
+
|
|
16
|
+
if (resolved === undefined || resolved === null) {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return Array.isArray(resolved) ? resolved : [resolved];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createQueue(runtime) {
|
|
24
|
+
return {
|
|
25
|
+
push(definition) {
|
|
26
|
+
return runtime.define(definition);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createRuntime(options = {}) {
|
|
32
|
+
const engine = createEngine(options);
|
|
33
|
+
|
|
34
|
+
const runtime = {
|
|
35
|
+
getUserContext: options.getUserContext ?? (() => ({ url: { pathname: "/" }, user: {} })),
|
|
36
|
+
|
|
37
|
+
define(definition) {
|
|
38
|
+
const configs = defineExperiments(normalizeDefinitions(definition, runtime));
|
|
39
|
+
if (configs.length > 0) {
|
|
40
|
+
engine.registerExperiments(configs);
|
|
41
|
+
}
|
|
42
|
+
return configs;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
defineMany(definitions) {
|
|
46
|
+
return runtime.define(definitions);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
listExperiments() {
|
|
50
|
+
return engine.listExperiments();
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
getActiveExperimentsForPage(context) {
|
|
54
|
+
return engine.getActiveExperimentsForPage(context);
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
getVariant(experimentId, context) {
|
|
58
|
+
return engine.getVariant(experimentId, context);
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
trackEvent(eventName, payload, context) {
|
|
62
|
+
return engine.trackEvent(eventName, payload, context);
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
trackConversion(experimentId, goalId, payload, context) {
|
|
66
|
+
return engine.trackConversion(experimentId, goalId, payload, context);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return runtime;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function installRuntime(options = {}, globalScope = globalThis) {
|
|
74
|
+
const existing = globalScope[RUNTIME_KEY];
|
|
75
|
+
if (existing) {
|
|
76
|
+
return existing;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const runtime = createRuntime(options);
|
|
80
|
+
const queue = createQueue(runtime);
|
|
81
|
+
|
|
82
|
+
const preexistingQueue = globalScope.splitTesterQueue;
|
|
83
|
+
if (Array.isArray(preexistingQueue)) {
|
|
84
|
+
while (preexistingQueue.length > 0) {
|
|
85
|
+
runtime.define(preexistingQueue.shift());
|
|
86
|
+
}
|
|
87
|
+
} else if (typeof preexistingQueue?.drain === "function") {
|
|
88
|
+
preexistingQueue.drain();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
globalScope.splitTester = runtime;
|
|
92
|
+
globalScope.splitTesterQueue = queue;
|
|
93
|
+
globalScope[RUNTIME_KEY] = runtime;
|
|
94
|
+
|
|
95
|
+
return runtime;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getRuntime(globalScope = globalThis) {
|
|
99
|
+
return globalScope[RUNTIME_KEY] ?? globalScope.splitTester ?? null;
|
|
100
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const sharedMemoryEntries = new Map();
|
|
2
|
+
|
|
3
|
+
function createMemoryStorage() {
|
|
4
|
+
return {
|
|
5
|
+
getItem(key) {
|
|
6
|
+
return sharedMemoryEntries.has(key) ? sharedMemoryEntries.get(key) : null;
|
|
7
|
+
},
|
|
8
|
+
setItem(key, value) {
|
|
9
|
+
sharedMemoryEntries.set(key, String(value));
|
|
10
|
+
},
|
|
11
|
+
removeItem(key) {
|
|
12
|
+
sharedMemoryEntries.delete(key);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getBrowserStorage() {
|
|
18
|
+
if (typeof globalThis === "undefined") {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const storage = globalThis.localStorage;
|
|
24
|
+
if (!storage) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const testKey = "__split_tester_test__";
|
|
28
|
+
storage.setItem(testKey, "1");
|
|
29
|
+
storage.removeItem(testKey);
|
|
30
|
+
return storage;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeStorage(storage) {
|
|
37
|
+
if (!storage) {
|
|
38
|
+
return createMemoryStorage();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (
|
|
42
|
+
typeof storage.getItem === "function" &&
|
|
43
|
+
typeof storage.setItem === "function" &&
|
|
44
|
+
typeof storage.removeItem === "function"
|
|
45
|
+
) {
|
|
46
|
+
return storage;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return createMemoryStorage();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createAssignmentStore(storage = getBrowserStorage()) {
|
|
53
|
+
const backend = normalizeStorage(storage);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
get(key) {
|
|
57
|
+
try {
|
|
58
|
+
return backend.getItem(key);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
set(key, value) {
|
|
64
|
+
try {
|
|
65
|
+
backend.setItem(key, value);
|
|
66
|
+
} catch {
|
|
67
|
+
// Ignore storage failures and continue with in-memory assignments.
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
delete(key) {
|
|
71
|
+
try {
|
|
72
|
+
backend.removeItem(key);
|
|
73
|
+
} catch {
|
|
74
|
+
// Ignore storage failures.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|