@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sessionsight/split-testing",
3
- "version": "1.0.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
@@ -1,2 +0,0 @@
1
- [test]
2
- preload = ["./test/setup.ts"]
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
@@ -1,2 +0,0 @@
1
- import SplitTesting from './index.js';
2
- (window as any).SessionSightSplitTesting = SplitTesting;
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
- }