@sessionsight/split-testing 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cache.d.ts +11 -0
- package/dist/client.d.ts +40 -0
- package/dist/hash.d.ts +16 -0
- package/dist/hash.test.d.ts +1 -0
- package/dist/iife.d.ts +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +535 -0
- package/dist/sessionsight-split-testing.js +1 -0
- package/dist/types.d.ts +44 -0
- package/package.json +4 -1
- package/bunfig.toml +0 -2
- package/src/cache.ts +0 -95
- package/src/client.ts +0 -370
- package/src/hash.ts +0 -44
- package/src/iife.ts +0 -2
- package/src/index.ts +0 -75
- package/src/types.ts +0 -50
- package/test/client.test.ts +0 -224
- package/test/hash.test.ts +0 -85
- package/test/setup.ts +0 -21
- package/tsconfig.json +0 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sessionsight/split-testing",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "A/B and split testing SDK for SessionSight.",
|
|
5
5
|
"author": "SessionSight",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
"experiments",
|
|
20
20
|
"sessionsight"
|
|
21
21
|
],
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
22
25
|
"type": "module",
|
|
23
26
|
"main": "./dist/index.js",
|
|
24
27
|
"types": "./dist/index.d.ts",
|
package/bunfig.toml
DELETED
package/src/cache.ts
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import type { SplitTestConfigResponse, Assignment } from './types.js';
|
|
2
|
-
|
|
3
|
-
export { getOrCreateVisitorId } from '@sessionsight/sdk-shared';
|
|
4
|
-
|
|
5
|
-
const CONFIG_PREFIX = 'ss-split-config:';
|
|
6
|
-
const ASSIGNMENTS_PREFIX = 'ss-split-assignments:';
|
|
7
|
-
|
|
8
|
-
interface CachedConfig {
|
|
9
|
-
data: SplitTestConfigResponse;
|
|
10
|
-
fetchedAt: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function hasLocalStorage(): boolean {
|
|
14
|
-
try {
|
|
15
|
-
const key = '__ss_test__';
|
|
16
|
-
localStorage.setItem(key, '1');
|
|
17
|
-
localStorage.removeItem(key);
|
|
18
|
-
return true;
|
|
19
|
-
} catch {
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const canUseStorage = typeof window !== 'undefined' && hasLocalStorage();
|
|
25
|
-
|
|
26
|
-
// ── Config Cache ────────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
export function getCachedConfig(propertyId: string): CachedConfig | null {
|
|
29
|
-
if (!canUseStorage) return null;
|
|
30
|
-
try {
|
|
31
|
-
const raw = localStorage.getItem(CONFIG_PREFIX + propertyId);
|
|
32
|
-
if (!raw) return null;
|
|
33
|
-
return JSON.parse(raw);
|
|
34
|
-
} catch {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function setCachedConfig(propertyId: string, data: SplitTestConfigResponse): void {
|
|
40
|
-
if (!canUseStorage) return;
|
|
41
|
-
try {
|
|
42
|
-
const cached: CachedConfig = { data, fetchedAt: Date.now() };
|
|
43
|
-
localStorage.setItem(CONFIG_PREFIX + propertyId, JSON.stringify(cached));
|
|
44
|
-
} catch {
|
|
45
|
-
// Storage full or unavailable
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ── Assignment Cache ────────────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
export function getCachedAssignments(propertyId: string, visitorId: string): Record<string, Assignment> | null {
|
|
52
|
-
if (!canUseStorage) return null;
|
|
53
|
-
try {
|
|
54
|
-
const raw = localStorage.getItem(ASSIGNMENTS_PREFIX + propertyId + ':' + visitorId);
|
|
55
|
-
if (!raw) return null;
|
|
56
|
-
return JSON.parse(raw);
|
|
57
|
-
} catch {
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function setCachedAssignments(
|
|
63
|
-
propertyId: string,
|
|
64
|
-
visitorId: string,
|
|
65
|
-
assignments: Record<string, Assignment>,
|
|
66
|
-
): void {
|
|
67
|
-
if (!canUseStorage) return;
|
|
68
|
-
try {
|
|
69
|
-
localStorage.setItem(
|
|
70
|
-
ASSIGNMENTS_PREFIX + propertyId + ':' + visitorId,
|
|
71
|
-
JSON.stringify(assignments),
|
|
72
|
-
);
|
|
73
|
-
} catch {
|
|
74
|
-
// Storage full or unavailable
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ── Cleanup ─────────────────────────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
export function clearCache(propertyId: string): void {
|
|
81
|
-
if (!canUseStorage) return;
|
|
82
|
-
try {
|
|
83
|
-
// Remove all keys with our prefix for this property
|
|
84
|
-
const keysToRemove: string[] = [];
|
|
85
|
-
for (let i = 0; i < localStorage.length; i++) {
|
|
86
|
-
const key = localStorage.key(i);
|
|
87
|
-
if (key && (key.startsWith(CONFIG_PREFIX + propertyId) || key.startsWith(ASSIGNMENTS_PREFIX + propertyId))) {
|
|
88
|
-
keysToRemove.push(key);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
keysToRemove.forEach((k) => localStorage.removeItem(k));
|
|
92
|
-
} catch {
|
|
93
|
-
// Ignore
|
|
94
|
-
}
|
|
95
|
-
}
|
package/src/client.ts
DELETED
|
@@ -1,370 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
SplitTestConfig,
|
|
3
|
-
SplitTestConfigResponse,
|
|
4
|
-
SplitTestConfigEntry,
|
|
5
|
-
Assignment,
|
|
6
|
-
AssignedVariation,
|
|
7
|
-
GetOptions,
|
|
8
|
-
} from './types.js';
|
|
9
|
-
import { splitTestHash, assignVariation } from './hash.js';
|
|
10
|
-
import {
|
|
11
|
-
getOrCreateVisitorId,
|
|
12
|
-
getCachedConfig,
|
|
13
|
-
setCachedConfig,
|
|
14
|
-
getCachedAssignments,
|
|
15
|
-
setCachedAssignments,
|
|
16
|
-
clearCache,
|
|
17
|
-
} from './cache.js';
|
|
18
|
-
|
|
19
|
-
const FETCH_TIMEOUT_MS = 10_000;
|
|
20
|
-
|
|
21
|
-
function fetchWithTimeout(url: string, options: RequestInit): Promise<Response> {
|
|
22
|
-
const controller = new AbortController();
|
|
23
|
-
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
24
|
-
return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timer));
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
import { normalizeApiUrl } from '@sessionsight/sdk-shared';
|
|
28
|
-
|
|
29
|
-
const DEFAULT_STALE_TTL = 0;
|
|
30
|
-
const DEFAULT_MAX_AGE = 86_400_000; // 24 hours
|
|
31
|
-
|
|
32
|
-
export class SplitTestingClient {
|
|
33
|
-
private apiUrl: string;
|
|
34
|
-
private publicApiKey: string;
|
|
35
|
-
private propertyId: string;
|
|
36
|
-
private visitorId: string;
|
|
37
|
-
private attributes: Record<string, string | number | boolean>;
|
|
38
|
-
private bootstrap: Record<string, number> | null;
|
|
39
|
-
private antiFlicker: boolean;
|
|
40
|
-
private staleTTL: number;
|
|
41
|
-
private maxAge: number;
|
|
42
|
-
private onAssignment: ((testKey: string, variation: AssignedVariation) => void) | null;
|
|
43
|
-
|
|
44
|
-
private config: SplitTestConfigResponse | null = null;
|
|
45
|
-
private assignments: Record<string, Assignment> = {};
|
|
46
|
-
private initialized = false;
|
|
47
|
-
private antiFlickerStyle: HTMLStyleElement | null = null;
|
|
48
|
-
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
49
|
-
private pendingExposures: Array<{
|
|
50
|
-
splitTestKey: string;
|
|
51
|
-
variationKey: string;
|
|
52
|
-
visitorId: string;
|
|
53
|
-
timestamp: number;
|
|
54
|
-
attributes: Record<string, string | number | boolean>;
|
|
55
|
-
}> = [];
|
|
56
|
-
|
|
57
|
-
constructor(config: SplitTestConfig) {
|
|
58
|
-
this.publicApiKey = config.publicApiKey;
|
|
59
|
-
this.propertyId = config.propertyId;
|
|
60
|
-
this.apiUrl = normalizeApiUrl(config.apiUrl || '');
|
|
61
|
-
this.visitorId = getOrCreateVisitorId(config.visitorId);
|
|
62
|
-
this.attributes = config.attributes || {};
|
|
63
|
-
this.bootstrap = config.bootstrap || null;
|
|
64
|
-
this.antiFlicker = config.antiFlicker || false;
|
|
65
|
-
this.staleTTL = config.staleTTL ?? DEFAULT_STALE_TTL;
|
|
66
|
-
this.maxAge = config.maxAge ?? DEFAULT_MAX_AGE;
|
|
67
|
-
this.onAssignment = config.onAssignment || null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async init(): Promise<void> {
|
|
71
|
-
// Step 1: Anti-flicker
|
|
72
|
-
if (this.antiFlicker && typeof document !== 'undefined') {
|
|
73
|
-
this.injectAntiFlicker();
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
// Step 2: Bootstrap (highest priority, zero-flicker)
|
|
78
|
-
if (this.bootstrap) {
|
|
79
|
-
// We still need config to know the test metadata (type, variations)
|
|
80
|
-
// Try cache first, then fetch
|
|
81
|
-
const cached = getCachedConfig(this.propertyId);
|
|
82
|
-
if (cached) {
|
|
83
|
-
this.config = cached.data;
|
|
84
|
-
this.evaluateFromBootstrap();
|
|
85
|
-
this.initialized = true;
|
|
86
|
-
// Refresh in background
|
|
87
|
-
this.fetchConfigInBackground();
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
// Must fetch to know test structure
|
|
91
|
-
await this.fetchConfig();
|
|
92
|
-
this.evaluateFromBootstrap();
|
|
93
|
-
this.initialized = true;
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Step 3: Try cached assignments
|
|
98
|
-
const cachedAssignments = getCachedAssignments(this.propertyId, this.visitorId);
|
|
99
|
-
if (cachedAssignments) {
|
|
100
|
-
this.assignments = cachedAssignments;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Step 4: Check cached config
|
|
104
|
-
const cachedConfig = getCachedConfig(this.propertyId);
|
|
105
|
-
const now = Date.now();
|
|
106
|
-
|
|
107
|
-
if (cachedConfig) {
|
|
108
|
-
const age = now - cachedConfig.fetchedAt;
|
|
109
|
-
|
|
110
|
-
if (age < this.maxAge) {
|
|
111
|
-
this.config = cachedConfig.data;
|
|
112
|
-
|
|
113
|
-
// Re-evaluate assignments from cached config (to ensure consistency)
|
|
114
|
-
this.evaluateAssignments();
|
|
115
|
-
this.initialized = true;
|
|
116
|
-
|
|
117
|
-
// If stale, fetch in background
|
|
118
|
-
if (age >= this.staleTTL) {
|
|
119
|
-
this.fetchConfigInBackground();
|
|
120
|
-
}
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Step 5: No usable cache. Fetch fresh.
|
|
126
|
-
await this.fetchConfig();
|
|
127
|
-
this.evaluateAssignments();
|
|
128
|
-
this.initialized = true;
|
|
129
|
-
} finally {
|
|
130
|
-
this.removeAntiFlicker();
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
get(testKey: string, defaultValue: any, _options?: GetOptions): any {
|
|
135
|
-
if (!this.initialized) {
|
|
136
|
-
console.warn('[SessionSight SplitTesting] Not initialized. Call init() first.');
|
|
137
|
-
return defaultValue;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const assignment = this.assignments[testKey];
|
|
141
|
-
if (!assignment) return defaultValue;
|
|
142
|
-
|
|
143
|
-
// Track exposure (fire-and-forget)
|
|
144
|
-
if (assignment.inTest) {
|
|
145
|
-
this.trackExposure(testKey, assignment.variationKey);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
switch (assignment.type) {
|
|
149
|
-
case 'id':
|
|
150
|
-
return assignment.variationKey;
|
|
151
|
-
case 'text':
|
|
152
|
-
return assignment.value || defaultValue;
|
|
153
|
-
case 'json':
|
|
154
|
-
try {
|
|
155
|
-
return JSON.parse(assignment.value);
|
|
156
|
-
} catch {
|
|
157
|
-
return defaultValue;
|
|
158
|
-
}
|
|
159
|
-
default:
|
|
160
|
-
return defaultValue;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
trackConversion(goalId: string, options?: { value?: number; currency?: string }): void {
|
|
165
|
-
if (!this.initialized) return;
|
|
166
|
-
|
|
167
|
-
const body = {
|
|
168
|
-
propertyId: this.propertyId,
|
|
169
|
-
visitorId: this.visitorId,
|
|
170
|
-
goalId,
|
|
171
|
-
...(options?.value != null ? { value: options.value } : {}),
|
|
172
|
-
...(options?.currency ? { currency: options.currency } : {}),
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
this.sendBeacon(`${this.apiUrl}/v1/split-testing/convert`, body);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
setAttributes(attrs: Record<string, string | number | boolean>): void {
|
|
179
|
-
Object.assign(this.attributes, attrs);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
getAssignments(): Record<string, number> {
|
|
183
|
-
const result: Record<string, number> = {};
|
|
184
|
-
for (const [key, assignment] of Object.entries(this.assignments)) {
|
|
185
|
-
result[key] = assignment.variationIndex;
|
|
186
|
-
}
|
|
187
|
-
return result;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
async refresh(): Promise<void> {
|
|
191
|
-
await this.fetchConfig();
|
|
192
|
-
this.evaluateAssignments();
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
clearCache(): void {
|
|
196
|
-
clearCache(this.propertyId);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
destroy(): void {
|
|
200
|
-
if (this.flushTimer) {
|
|
201
|
-
clearTimeout(this.flushTimer);
|
|
202
|
-
this.flushTimer = null;
|
|
203
|
-
}
|
|
204
|
-
this.flushExposures();
|
|
205
|
-
this.removeAntiFlicker();
|
|
206
|
-
this.config = null;
|
|
207
|
-
this.assignments = {};
|
|
208
|
-
this.initialized = false;
|
|
209
|
-
this.pendingExposures = [];
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ── Private ─────────────────────────────────────────────────────
|
|
213
|
-
|
|
214
|
-
private evaluateFromBootstrap(): void {
|
|
215
|
-
if (!this.config || !this.bootstrap) return;
|
|
216
|
-
|
|
217
|
-
for (const test of this.config.tests) {
|
|
218
|
-
const variationIndex = this.bootstrap[test.key];
|
|
219
|
-
if (variationIndex === undefined) continue;
|
|
220
|
-
|
|
221
|
-
const variation = test.variations[variationIndex];
|
|
222
|
-
if (!variation) continue;
|
|
223
|
-
|
|
224
|
-
this.assignments[test.key] = {
|
|
225
|
-
testKey: test.key,
|
|
226
|
-
variationIndex,
|
|
227
|
-
variationKey: variation.key,
|
|
228
|
-
value: variation.value,
|
|
229
|
-
type: test.type,
|
|
230
|
-
inTest: true,
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
if (this.onAssignment) {
|
|
234
|
-
this.onAssignment(test.key, { key: variation.key, value: variation.value });
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
setCachedAssignments(this.propertyId, this.visitorId, this.assignments);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
private evaluateAssignments(): void {
|
|
242
|
-
if (!this.config) return;
|
|
243
|
-
|
|
244
|
-
for (const test of this.config.tests) {
|
|
245
|
-
const hash = splitTestHash(test.hashSeed, this.visitorId);
|
|
246
|
-
const result = assignVariation(hash, test.trafficAllocation, test.variations);
|
|
247
|
-
const variation = test.variations[result.variationIndex];
|
|
248
|
-
|
|
249
|
-
if (!variation) continue;
|
|
250
|
-
|
|
251
|
-
this.assignments[test.key] = {
|
|
252
|
-
testKey: test.key,
|
|
253
|
-
variationIndex: result.variationIndex,
|
|
254
|
-
variationKey: variation.key,
|
|
255
|
-
value: variation.value,
|
|
256
|
-
type: test.type,
|
|
257
|
-
inTest: result.inTest,
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
if (this.onAssignment) {
|
|
261
|
-
this.onAssignment(test.key, { key: variation.key, value: variation.value });
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
setCachedAssignments(this.propertyId, this.visitorId, this.assignments);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
private async fetchConfig(): Promise<void> {
|
|
269
|
-
try {
|
|
270
|
-
const url = `${this.apiUrl}/v1/split-testing/config?propertyId=${encodeURIComponent(this.propertyId)}`;
|
|
271
|
-
const res = await fetchWithTimeout(url, {
|
|
272
|
-
headers: { 'x-api-key': this.publicApiKey },
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
if (!res.ok) {
|
|
276
|
-
console.warn(`[SessionSight SplitTesting] Failed to fetch config: ${res.status}`);
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const data = await res.json();
|
|
281
|
-
if (!data || !Array.isArray(data.tests)) {
|
|
282
|
-
console.warn('[SessionSight SplitTesting] Invalid config response');
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
this.config = data as SplitTestConfigResponse;
|
|
286
|
-
setCachedConfig(this.propertyId, data);
|
|
287
|
-
} catch (err) {
|
|
288
|
-
console.warn('[SessionSight SplitTesting] Failed to fetch config:', err);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
private fetchConfigInBackground(): void {
|
|
293
|
-
this.fetchConfig().then(() => {
|
|
294
|
-
if (this.config) {
|
|
295
|
-
this.evaluateAssignments();
|
|
296
|
-
}
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
private trackExposure(testKey: string, variationKey: string): void {
|
|
301
|
-
// Deduplicate: only track once per test per session
|
|
302
|
-
if (this.pendingExposures.some((e) => e.splitTestKey === testKey)) return;
|
|
303
|
-
|
|
304
|
-
this.pendingExposures.push({
|
|
305
|
-
splitTestKey: testKey,
|
|
306
|
-
variationKey,
|
|
307
|
-
visitorId: this.visitorId,
|
|
308
|
-
timestamp: Date.now(),
|
|
309
|
-
attributes: this.attributes,
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
// Flush after a short delay to batch exposures
|
|
313
|
-
if (this.pendingExposures.length === 1) {
|
|
314
|
-
this.flushTimer = setTimeout(() => this.flushExposures(), 1000);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
private flushExposures(): void {
|
|
319
|
-
if (this.pendingExposures.length === 0) return;
|
|
320
|
-
|
|
321
|
-
const exposures = [...this.pendingExposures];
|
|
322
|
-
this.pendingExposures = [];
|
|
323
|
-
|
|
324
|
-
const body = {
|
|
325
|
-
propertyId: this.propertyId,
|
|
326
|
-
exposures,
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
this.sendBeacon(`${this.apiUrl}/v1/split-testing/expose`, body);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
private sendBeacon(url: string, body: any): void {
|
|
333
|
-
const json = JSON.stringify(body);
|
|
334
|
-
|
|
335
|
-
// Try sendBeacon first (works during page unload)
|
|
336
|
-
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
|
337
|
-
const blob = new Blob([json], { type: 'application/json' });
|
|
338
|
-
if (navigator.sendBeacon(url, blob)) return;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// Fallback to fetch with keepalive
|
|
342
|
-
fetch(url, {
|
|
343
|
-
method: 'POST',
|
|
344
|
-
headers: {
|
|
345
|
-
'Content-Type': 'application/json',
|
|
346
|
-
'x-api-key': this.publicApiKey,
|
|
347
|
-
},
|
|
348
|
-
body: json,
|
|
349
|
-
keepalive: true,
|
|
350
|
-
}).catch(() => {
|
|
351
|
-
// Fire-and-forget
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
private injectAntiFlicker(): void {
|
|
356
|
-
if (typeof document === 'undefined') return;
|
|
357
|
-
const style = document.createElement('style');
|
|
358
|
-
style.id = 'ss-split-anti-flicker';
|
|
359
|
-
style.textContent = '[data-ss-split]{visibility:hidden!important}';
|
|
360
|
-
document.head.appendChild(style);
|
|
361
|
-
this.antiFlickerStyle = style;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
private removeAntiFlicker(): void {
|
|
365
|
-
if (this.antiFlickerStyle) {
|
|
366
|
-
this.antiFlickerStyle.remove();
|
|
367
|
-
this.antiFlickerStyle = null;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
package/src/hash.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* djb2 hash producing 0-9999 for fine-grained traffic allocation.
|
|
3
|
-
* Same algorithm family as the flag-evaluation service.
|
|
4
|
-
*/
|
|
5
|
-
export function splitTestHash(seed: string, visitorId: string): number {
|
|
6
|
-
const str = `${seed}:${visitorId}`;
|
|
7
|
-
let hash = 5381;
|
|
8
|
-
for (let i = 0; i < str.length; i++) {
|
|
9
|
-
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
|
|
10
|
-
}
|
|
11
|
-
return hash % 10000;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Given a hash bucket, traffic allocation, and variation weights,
|
|
16
|
-
* determine which variation the visitor gets.
|
|
17
|
-
*/
|
|
18
|
-
export function assignVariation(
|
|
19
|
-
hashValue: number,
|
|
20
|
-
trafficAllocation: number,
|
|
21
|
-
variations: Array<{ key: string; weight: number }>,
|
|
22
|
-
): { variationIndex: number; inTest: boolean } {
|
|
23
|
-
// trafficAllocation is 0-100, scale to 0-10000
|
|
24
|
-
const trafficThreshold = trafficAllocation * 100;
|
|
25
|
-
|
|
26
|
-
if (hashValue >= trafficThreshold) {
|
|
27
|
-
// Outside traffic allocation: gets control (index 0), not tracked
|
|
28
|
-
return { variationIndex: 0, inTest: false };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Bucket within traffic allocation
|
|
32
|
-
const totalWeight = variations.reduce((s, v) => s + v.weight, 0);
|
|
33
|
-
if (totalWeight === 0) return { variationIndex: 0, inTest: true };
|
|
34
|
-
|
|
35
|
-
let cumulative = 0;
|
|
36
|
-
for (let i = 0; i < variations.length; i++) {
|
|
37
|
-
cumulative += (variations[i]!.weight / totalWeight) * trafficThreshold;
|
|
38
|
-
if (hashValue < cumulative) {
|
|
39
|
-
return { variationIndex: i, inTest: true };
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return { variationIndex: variations.length - 1, inTest: true };
|
|
44
|
-
}
|
package/src/iife.ts
DELETED
package/src/index.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { SplitTestingClient } from './client.js';
|
|
2
|
-
import type { SplitTestConfig, GetOptions, AssignedVariation } from './types.js';
|
|
3
|
-
|
|
4
|
-
export { SplitTestingClient };
|
|
5
|
-
export type { SplitTestConfig, GetOptions, AssignedVariation, Assignment, SplitTestConfigResponse } from './types.js';
|
|
6
|
-
|
|
7
|
-
let instance: SplitTestingClient | null = null;
|
|
8
|
-
|
|
9
|
-
const SplitTesting = {
|
|
10
|
-
async init(config: SplitTestConfig): Promise<void> {
|
|
11
|
-
if (instance) {
|
|
12
|
-
console.warn('[SessionSight SplitTesting] Already initialized. Call destroy() first.');
|
|
13
|
-
return;
|
|
14
|
-
}
|
|
15
|
-
instance = new SplitTestingClient(config);
|
|
16
|
-
await instance.init();
|
|
17
|
-
},
|
|
18
|
-
|
|
19
|
-
get(testKey: string, defaultValue: any, options?: GetOptions): any {
|
|
20
|
-
if (!instance) {
|
|
21
|
-
console.warn('[SessionSight SplitTesting] Not initialized. Call init() first.');
|
|
22
|
-
return defaultValue;
|
|
23
|
-
}
|
|
24
|
-
return instance.get(testKey, defaultValue, options);
|
|
25
|
-
},
|
|
26
|
-
|
|
27
|
-
trackConversion(goalId: string, options?: { value?: number; currency?: string }): void {
|
|
28
|
-
if (!instance) {
|
|
29
|
-
console.warn('[SessionSight SplitTesting] Not initialized. Call init() first.');
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
instance.trackConversion(goalId, options);
|
|
33
|
-
},
|
|
34
|
-
|
|
35
|
-
setAttributes(attrs: Record<string, string | number | boolean>): void {
|
|
36
|
-
if (!instance) {
|
|
37
|
-
console.warn('[SessionSight SplitTesting] Not initialized. Call init() first.');
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
instance.setAttributes(attrs);
|
|
41
|
-
},
|
|
42
|
-
|
|
43
|
-
getAssignments(): Record<string, number> {
|
|
44
|
-
if (!instance) {
|
|
45
|
-
console.warn('[SessionSight SplitTesting] Not initialized. Call init() first.');
|
|
46
|
-
return {};
|
|
47
|
-
}
|
|
48
|
-
return instance.getAssignments();
|
|
49
|
-
},
|
|
50
|
-
|
|
51
|
-
async refresh(): Promise<void> {
|
|
52
|
-
if (!instance) {
|
|
53
|
-
console.warn('[SessionSight SplitTesting] Not initialized. Call init() first.');
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
await instance.refresh();
|
|
57
|
-
},
|
|
58
|
-
|
|
59
|
-
clearCache(): void {
|
|
60
|
-
if (!instance) {
|
|
61
|
-
console.warn('[SessionSight SplitTesting] Not initialized. Call init() first.');
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
instance.clearCache();
|
|
65
|
-
},
|
|
66
|
-
|
|
67
|
-
destroy(): void {
|
|
68
|
-
if (instance) {
|
|
69
|
-
instance.destroy();
|
|
70
|
-
instance = null;
|
|
71
|
-
}
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
export default SplitTesting;
|
package/src/types.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
export interface SplitTestConfig {
|
|
2
|
-
publicApiKey: string;
|
|
3
|
-
propertyId: string;
|
|
4
|
-
apiUrl?: string;
|
|
5
|
-
visitorId?: string;
|
|
6
|
-
attributes?: Record<string, string | number | boolean>;
|
|
7
|
-
bootstrap?: Record<string, number>;
|
|
8
|
-
antiFlicker?: boolean;
|
|
9
|
-
staleTTL?: number;
|
|
10
|
-
maxAge?: number;
|
|
11
|
-
onAssignment?: (testKey: string, variation: AssignedVariation) => void;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface AssignedVariation {
|
|
15
|
-
key: string;
|
|
16
|
-
value: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface SplitTestConfigEntry {
|
|
20
|
-
key: string;
|
|
21
|
-
id: string;
|
|
22
|
-
type: 'id' | 'text' | 'json';
|
|
23
|
-
status: string;
|
|
24
|
-
hashSeed: string;
|
|
25
|
-
trafficAllocation: number;
|
|
26
|
-
variations: Array<{
|
|
27
|
-
key: string;
|
|
28
|
-
weight: number;
|
|
29
|
-
value: string;
|
|
30
|
-
}>;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface SplitTestConfigResponse {
|
|
34
|
-
tests: SplitTestConfigEntry[];
|
|
35
|
-
ttl: number;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface Assignment {
|
|
39
|
-
testKey: string;
|
|
40
|
-
variationIndex: number;
|
|
41
|
-
variationKey: string;
|
|
42
|
-
value: string;
|
|
43
|
-
type: 'id' | 'text' | 'json';
|
|
44
|
-
inTest: boolean;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface GetOptions {
|
|
48
|
-
// Reserved for future use
|
|
49
|
-
[key: string]: any;
|
|
50
|
-
}
|