@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.
@@ -0,0 +1,11 @@
1
+ import type { SplitTestConfigResponse, Assignment } from './types.js';
2
+ export { getOrCreateVisitorId } from '@sessionsight/sdk-shared';
3
+ interface CachedConfig {
4
+ data: SplitTestConfigResponse;
5
+ fetchedAt: number;
6
+ }
7
+ export declare function getCachedConfig(propertyId: string): CachedConfig | null;
8
+ export declare function setCachedConfig(propertyId: string, data: SplitTestConfigResponse): void;
9
+ export declare function getCachedAssignments(propertyId: string, visitorId: string): Record<string, Assignment> | null;
10
+ export declare function setCachedAssignments(propertyId: string, visitorId: string, assignments: Record<string, Assignment>): void;
11
+ export declare function clearCache(propertyId: string): void;
@@ -0,0 +1,40 @@
1
+ import type { SplitTestConfig, GetOptions } from './types.js';
2
+ export declare class SplitTestingClient {
3
+ private apiUrl;
4
+ private publicApiKey;
5
+ private propertyId;
6
+ private visitorId;
7
+ private attributes;
8
+ private bootstrap;
9
+ private antiFlicker;
10
+ private staleTTL;
11
+ private maxAge;
12
+ private onAssignment;
13
+ private config;
14
+ private assignments;
15
+ private initialized;
16
+ private antiFlickerStyle;
17
+ private flushTimer;
18
+ private pendingExposures;
19
+ constructor(config: SplitTestConfig);
20
+ init(): Promise<void>;
21
+ get(testKey: string, defaultValue: any, _options?: GetOptions): any;
22
+ trackConversion(goalId: string, options?: {
23
+ value?: number;
24
+ currency?: string;
25
+ }): void;
26
+ setAttributes(attrs: Record<string, string | number | boolean>): void;
27
+ getAssignments(): Record<string, number>;
28
+ refresh(): Promise<void>;
29
+ clearCache(): void;
30
+ destroy(): void;
31
+ private evaluateFromBootstrap;
32
+ private evaluateAssignments;
33
+ private fetchConfig;
34
+ private fetchConfigInBackground;
35
+ private trackExposure;
36
+ private flushExposures;
37
+ private sendBeacon;
38
+ private injectAntiFlicker;
39
+ private removeAntiFlicker;
40
+ }
package/dist/hash.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * djb2 hash producing 0-9999 for fine-grained traffic allocation.
3
+ * Same algorithm family as the flag-evaluation service.
4
+ */
5
+ export declare function splitTestHash(seed: string, visitorId: string): number;
6
+ /**
7
+ * Given a hash bucket, traffic allocation, and variation weights,
8
+ * determine which variation the visitor gets.
9
+ */
10
+ export declare function assignVariation(hashValue: number, trafficAllocation: number, variations: Array<{
11
+ key: string;
12
+ weight: number;
13
+ }>): {
14
+ variationIndex: number;
15
+ inTest: boolean;
16
+ };
@@ -0,0 +1 @@
1
+ export {};
package/dist/iife.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import { SplitTestingClient } from './client.js';
2
+ import type { SplitTestConfig, GetOptions } from './types.js';
3
+ export { SplitTestingClient };
4
+ export type { SplitTestConfig, GetOptions, AssignedVariation, Assignment, SplitTestConfigResponse } from './types.js';
5
+ declare const SplitTesting: {
6
+ init(config: SplitTestConfig): Promise<void>;
7
+ get(testKey: string, defaultValue: any, options?: GetOptions): any;
8
+ trackConversion(goalId: string, options?: {
9
+ value?: number;
10
+ currency?: string;
11
+ }): void;
12
+ setAttributes(attrs: Record<string, string | number | boolean>): void;
13
+ getAssignments(): Record<string, number>;
14
+ refresh(): Promise<void>;
15
+ clearCache(): void;
16
+ destroy(): void;
17
+ };
18
+ export default SplitTesting;
package/dist/index.js ADDED
@@ -0,0 +1,535 @@
1
+ // src/hash.ts
2
+ function splitTestHash(seed, visitorId) {
3
+ const str = `${seed}:${visitorId}`;
4
+ let hash = 5381;
5
+ for (let i = 0;i < str.length; i++) {
6
+ hash = (hash << 5) + hash + str.charCodeAt(i) >>> 0;
7
+ }
8
+ return hash % 1e4;
9
+ }
10
+ function assignVariation(hashValue, trafficAllocation, variations) {
11
+ const trafficThreshold = trafficAllocation * 100;
12
+ if (hashValue >= trafficThreshold) {
13
+ return { variationIndex: 0, inTest: false };
14
+ }
15
+ const totalWeight = variations.reduce((s, v) => s + v.weight, 0);
16
+ if (totalWeight === 0)
17
+ return { variationIndex: 0, inTest: true };
18
+ let cumulative = 0;
19
+ for (let i = 0;i < variations.length; i++) {
20
+ cumulative += variations[i].weight / totalWeight * trafficThreshold;
21
+ if (hashValue < cumulative) {
22
+ return { variationIndex: i, inTest: true };
23
+ }
24
+ }
25
+ return { variationIndex: variations.length - 1, inTest: true };
26
+ }
27
+
28
+ // ../sdk-shared/src/index.ts
29
+ var DEFAULT_API_URL = "https://api.sessionsight.com";
30
+ function normalizeApiUrl(url) {
31
+ const normalized = (url || DEFAULT_API_URL).replace(/\/$/, "");
32
+ if (normalized.startsWith("http://") && !normalized.startsWith("http://localhost")) {
33
+ console.warn("SessionSight: API URL uses http:// instead of https://. Data will be transmitted unencrypted.");
34
+ }
35
+ return normalized;
36
+ }
37
+ var VISITOR_STORAGE_KEY = "sessionsight_visitor_id";
38
+ var VISITOR_COOKIE_NAME = "ss_vid";
39
+ var VISITOR_COOKIE_MAX_AGE = 365 * 24 * 60 * 60;
40
+ function readCookie(name) {
41
+ if (typeof document === "undefined")
42
+ return null;
43
+ try {
44
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
45
+ const match = document.cookie.match(new RegExp("(?:^|; )" + escaped + "=([^;]*)"));
46
+ return match ? decodeURIComponent(match[1]) : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+ function setCookie(name, value, maxAge) {
52
+ if (typeof document === "undefined")
53
+ return;
54
+ try {
55
+ const secure = location.protocol === "https:" ? "; Secure" : "";
56
+ document.cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax${secure}`;
57
+ } catch {}
58
+ }
59
+ function generateUUID() {
60
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
61
+ return crypto.randomUUID();
62
+ }
63
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
64
+ const r = Math.random() * 16 | 0;
65
+ return (c === "x" ? r : r & 3 | 8).toString(16);
66
+ });
67
+ }
68
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
69
+ function isValidUUID(id) {
70
+ return UUID_RE.test(id);
71
+ }
72
+ function hasLocalStorage() {
73
+ try {
74
+ const key = "__ss_test__";
75
+ localStorage.setItem(key, "1");
76
+ localStorage.removeItem(key);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+ function shouldSuppressPersistentId() {
83
+ if (typeof navigator !== "undefined") {
84
+ if (navigator.globalPrivacyControl === true)
85
+ return true;
86
+ if (navigator.doNotTrack === "1")
87
+ return true;
88
+ }
89
+ return false;
90
+ }
91
+ function getOrCreateVisitorId(providedId) {
92
+ if (providedId)
93
+ return providedId;
94
+ if (shouldSuppressPersistentId()) {
95
+ return generateUUID();
96
+ }
97
+ const canStore = typeof window !== "undefined" && hasLocalStorage();
98
+ const cookieId = readCookie(VISITOR_COOKIE_NAME);
99
+ if (cookieId && isValidUUID(cookieId)) {
100
+ if (canStore)
101
+ localStorage.setItem(VISITOR_STORAGE_KEY, cookieId);
102
+ return cookieId;
103
+ }
104
+ if (canStore) {
105
+ const stored = localStorage.getItem(VISITOR_STORAGE_KEY);
106
+ if (stored && isValidUUID(stored)) {
107
+ setCookie(VISITOR_COOKIE_NAME, stored, VISITOR_COOKIE_MAX_AGE);
108
+ return stored;
109
+ }
110
+ }
111
+ const id = generateUUID();
112
+ if (canStore)
113
+ localStorage.setItem(VISITOR_STORAGE_KEY, id);
114
+ setCookie(VISITOR_COOKIE_NAME, id, VISITOR_COOKIE_MAX_AGE);
115
+ return id;
116
+ }
117
+ var registry = new Map;
118
+
119
+ // src/cache.ts
120
+ var CONFIG_PREFIX = "ss-split-config:";
121
+ var ASSIGNMENTS_PREFIX = "ss-split-assignments:";
122
+ function hasLocalStorage2() {
123
+ try {
124
+ const key = "__ss_test__";
125
+ localStorage.setItem(key, "1");
126
+ localStorage.removeItem(key);
127
+ return true;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+ var canUseStorage = typeof window !== "undefined" && hasLocalStorage2();
133
+ function getCachedConfig(propertyId) {
134
+ if (!canUseStorage)
135
+ return null;
136
+ try {
137
+ const raw = localStorage.getItem(CONFIG_PREFIX + propertyId);
138
+ if (!raw)
139
+ return null;
140
+ return JSON.parse(raw);
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+ function setCachedConfig(propertyId, data) {
146
+ if (!canUseStorage)
147
+ return;
148
+ try {
149
+ const cached = { data, fetchedAt: Date.now() };
150
+ localStorage.setItem(CONFIG_PREFIX + propertyId, JSON.stringify(cached));
151
+ } catch {}
152
+ }
153
+ function getCachedAssignments(propertyId, visitorId) {
154
+ if (!canUseStorage)
155
+ return null;
156
+ try {
157
+ const raw = localStorage.getItem(ASSIGNMENTS_PREFIX + propertyId + ":" + visitorId);
158
+ if (!raw)
159
+ return null;
160
+ return JSON.parse(raw);
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+ function setCachedAssignments(propertyId, visitorId, assignments) {
166
+ if (!canUseStorage)
167
+ return;
168
+ try {
169
+ localStorage.setItem(ASSIGNMENTS_PREFIX + propertyId + ":" + visitorId, JSON.stringify(assignments));
170
+ } catch {}
171
+ }
172
+ function clearCache(propertyId) {
173
+ if (!canUseStorage)
174
+ return;
175
+ try {
176
+ const keysToRemove = [];
177
+ for (let i = 0;i < localStorage.length; i++) {
178
+ const key = localStorage.key(i);
179
+ if (key && (key.startsWith(CONFIG_PREFIX + propertyId) || key.startsWith(ASSIGNMENTS_PREFIX + propertyId))) {
180
+ keysToRemove.push(key);
181
+ }
182
+ }
183
+ keysToRemove.forEach((k) => localStorage.removeItem(k));
184
+ } catch {}
185
+ }
186
+
187
+ // src/client.ts
188
+ var FETCH_TIMEOUT_MS = 1e4;
189
+ function fetchWithTimeout(url, options) {
190
+ const controller = new AbortController;
191
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
192
+ return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timer));
193
+ }
194
+ var DEFAULT_STALE_TTL = 0;
195
+ var DEFAULT_MAX_AGE = 86400000;
196
+
197
+ class SplitTestingClient {
198
+ apiUrl;
199
+ publicApiKey;
200
+ propertyId;
201
+ visitorId;
202
+ attributes;
203
+ bootstrap;
204
+ antiFlicker;
205
+ staleTTL;
206
+ maxAge;
207
+ onAssignment;
208
+ config = null;
209
+ assignments = {};
210
+ initialized = false;
211
+ antiFlickerStyle = null;
212
+ flushTimer = null;
213
+ pendingExposures = [];
214
+ constructor(config) {
215
+ this.publicApiKey = config.publicApiKey;
216
+ this.propertyId = config.propertyId;
217
+ this.apiUrl = normalizeApiUrl(config.apiUrl || "");
218
+ this.visitorId = getOrCreateVisitorId(config.visitorId);
219
+ this.attributes = config.attributes || {};
220
+ this.bootstrap = config.bootstrap || null;
221
+ this.antiFlicker = config.antiFlicker || false;
222
+ this.staleTTL = config.staleTTL ?? DEFAULT_STALE_TTL;
223
+ this.maxAge = config.maxAge ?? DEFAULT_MAX_AGE;
224
+ this.onAssignment = config.onAssignment || null;
225
+ }
226
+ async init() {
227
+ if (this.antiFlicker && typeof document !== "undefined") {
228
+ this.injectAntiFlicker();
229
+ }
230
+ try {
231
+ if (this.bootstrap) {
232
+ const cached = getCachedConfig(this.propertyId);
233
+ if (cached) {
234
+ this.config = cached.data;
235
+ this.evaluateFromBootstrap();
236
+ this.initialized = true;
237
+ this.fetchConfigInBackground();
238
+ return;
239
+ }
240
+ await this.fetchConfig();
241
+ this.evaluateFromBootstrap();
242
+ this.initialized = true;
243
+ return;
244
+ }
245
+ const cachedAssignments = getCachedAssignments(this.propertyId, this.visitorId);
246
+ if (cachedAssignments) {
247
+ this.assignments = cachedAssignments;
248
+ }
249
+ const cachedConfig = getCachedConfig(this.propertyId);
250
+ const now = Date.now();
251
+ if (cachedConfig) {
252
+ const age = now - cachedConfig.fetchedAt;
253
+ if (age < this.maxAge) {
254
+ this.config = cachedConfig.data;
255
+ this.evaluateAssignments();
256
+ this.initialized = true;
257
+ if (age >= this.staleTTL) {
258
+ this.fetchConfigInBackground();
259
+ }
260
+ return;
261
+ }
262
+ }
263
+ await this.fetchConfig();
264
+ this.evaluateAssignments();
265
+ this.initialized = true;
266
+ } finally {
267
+ this.removeAntiFlicker();
268
+ }
269
+ }
270
+ get(testKey, defaultValue, _options) {
271
+ if (!this.initialized) {
272
+ console.warn("[SessionSight SplitTesting] Not initialized. Call init() first.");
273
+ return defaultValue;
274
+ }
275
+ const assignment = this.assignments[testKey];
276
+ if (!assignment)
277
+ return defaultValue;
278
+ if (assignment.inTest) {
279
+ this.trackExposure(testKey, assignment.variationKey);
280
+ }
281
+ switch (assignment.type) {
282
+ case "id":
283
+ return assignment.variationKey;
284
+ case "text":
285
+ return assignment.value || defaultValue;
286
+ case "json":
287
+ try {
288
+ return JSON.parse(assignment.value);
289
+ } catch {
290
+ return defaultValue;
291
+ }
292
+ default:
293
+ return defaultValue;
294
+ }
295
+ }
296
+ trackConversion(goalId, options) {
297
+ if (!this.initialized)
298
+ return;
299
+ const body = {
300
+ propertyId: this.propertyId,
301
+ visitorId: this.visitorId,
302
+ goalId,
303
+ ...options?.value != null ? { value: options.value } : {},
304
+ ...options?.currency ? { currency: options.currency } : {}
305
+ };
306
+ this.sendBeacon(`${this.apiUrl}/v1/split-testing/convert`, body);
307
+ }
308
+ setAttributes(attrs) {
309
+ Object.assign(this.attributes, attrs);
310
+ }
311
+ getAssignments() {
312
+ const result = {};
313
+ for (const [key, assignment] of Object.entries(this.assignments)) {
314
+ result[key] = assignment.variationIndex;
315
+ }
316
+ return result;
317
+ }
318
+ async refresh() {
319
+ await this.fetchConfig();
320
+ this.evaluateAssignments();
321
+ }
322
+ clearCache() {
323
+ clearCache(this.propertyId);
324
+ }
325
+ destroy() {
326
+ if (this.flushTimer) {
327
+ clearTimeout(this.flushTimer);
328
+ this.flushTimer = null;
329
+ }
330
+ this.flushExposures();
331
+ this.removeAntiFlicker();
332
+ this.config = null;
333
+ this.assignments = {};
334
+ this.initialized = false;
335
+ this.pendingExposures = [];
336
+ }
337
+ evaluateFromBootstrap() {
338
+ if (!this.config || !this.bootstrap)
339
+ return;
340
+ for (const test of this.config.tests) {
341
+ const variationIndex = this.bootstrap[test.key];
342
+ if (variationIndex === undefined)
343
+ continue;
344
+ const variation = test.variations[variationIndex];
345
+ if (!variation)
346
+ continue;
347
+ this.assignments[test.key] = {
348
+ testKey: test.key,
349
+ variationIndex,
350
+ variationKey: variation.key,
351
+ value: variation.value,
352
+ type: test.type,
353
+ inTest: true
354
+ };
355
+ if (this.onAssignment) {
356
+ this.onAssignment(test.key, { key: variation.key, value: variation.value });
357
+ }
358
+ }
359
+ setCachedAssignments(this.propertyId, this.visitorId, this.assignments);
360
+ }
361
+ evaluateAssignments() {
362
+ if (!this.config)
363
+ return;
364
+ for (const test of this.config.tests) {
365
+ const hash = splitTestHash(test.hashSeed, this.visitorId);
366
+ const result = assignVariation(hash, test.trafficAllocation, test.variations);
367
+ const variation = test.variations[result.variationIndex];
368
+ if (!variation)
369
+ continue;
370
+ this.assignments[test.key] = {
371
+ testKey: test.key,
372
+ variationIndex: result.variationIndex,
373
+ variationKey: variation.key,
374
+ value: variation.value,
375
+ type: test.type,
376
+ inTest: result.inTest
377
+ };
378
+ if (this.onAssignment) {
379
+ this.onAssignment(test.key, { key: variation.key, value: variation.value });
380
+ }
381
+ }
382
+ setCachedAssignments(this.propertyId, this.visitorId, this.assignments);
383
+ }
384
+ async fetchConfig() {
385
+ try {
386
+ const url = `${this.apiUrl}/v1/split-testing/config?propertyId=${encodeURIComponent(this.propertyId)}`;
387
+ const res = await fetchWithTimeout(url, {
388
+ headers: { "x-api-key": this.publicApiKey }
389
+ });
390
+ if (!res.ok) {
391
+ console.warn(`[SessionSight SplitTesting] Failed to fetch config: ${res.status}`);
392
+ return;
393
+ }
394
+ const data = await res.json();
395
+ if (!data || !Array.isArray(data.tests)) {
396
+ console.warn("[SessionSight SplitTesting] Invalid config response");
397
+ return;
398
+ }
399
+ this.config = data;
400
+ setCachedConfig(this.propertyId, data);
401
+ } catch (err) {
402
+ console.warn("[SessionSight SplitTesting] Failed to fetch config:", err);
403
+ }
404
+ }
405
+ fetchConfigInBackground() {
406
+ this.fetchConfig().then(() => {
407
+ if (this.config) {
408
+ this.evaluateAssignments();
409
+ }
410
+ });
411
+ }
412
+ trackExposure(testKey, variationKey) {
413
+ if (this.pendingExposures.some((e) => e.splitTestKey === testKey))
414
+ return;
415
+ this.pendingExposures.push({
416
+ splitTestKey: testKey,
417
+ variationKey,
418
+ visitorId: this.visitorId,
419
+ timestamp: Date.now(),
420
+ attributes: this.attributes
421
+ });
422
+ if (this.pendingExposures.length === 1) {
423
+ this.flushTimer = setTimeout(() => this.flushExposures(), 1000);
424
+ }
425
+ }
426
+ flushExposures() {
427
+ if (this.pendingExposures.length === 0)
428
+ return;
429
+ const exposures = [...this.pendingExposures];
430
+ this.pendingExposures = [];
431
+ const body = {
432
+ propertyId: this.propertyId,
433
+ exposures
434
+ };
435
+ this.sendBeacon(`${this.apiUrl}/v1/split-testing/expose`, body);
436
+ }
437
+ sendBeacon(url, body) {
438
+ const json = JSON.stringify(body);
439
+ if (typeof navigator !== "undefined" && navigator.sendBeacon) {
440
+ const blob = new Blob([json], { type: "application/json" });
441
+ if (navigator.sendBeacon(url, blob))
442
+ return;
443
+ }
444
+ fetch(url, {
445
+ method: "POST",
446
+ headers: {
447
+ "Content-Type": "application/json",
448
+ "x-api-key": this.publicApiKey
449
+ },
450
+ body: json,
451
+ keepalive: true
452
+ }).catch(() => {});
453
+ }
454
+ injectAntiFlicker() {
455
+ if (typeof document === "undefined")
456
+ return;
457
+ const style = document.createElement("style");
458
+ style.id = "ss-split-anti-flicker";
459
+ style.textContent = "[data-ss-split]{visibility:hidden!important}";
460
+ document.head.appendChild(style);
461
+ this.antiFlickerStyle = style;
462
+ }
463
+ removeAntiFlicker() {
464
+ if (this.antiFlickerStyle) {
465
+ this.antiFlickerStyle.remove();
466
+ this.antiFlickerStyle = null;
467
+ }
468
+ }
469
+ }
470
+
471
+ // src/index.ts
472
+ var instance = null;
473
+ var SplitTesting = {
474
+ async init(config) {
475
+ if (instance) {
476
+ console.warn("[SessionSight SplitTesting] Already initialized. Call destroy() first.");
477
+ return;
478
+ }
479
+ instance = new SplitTestingClient(config);
480
+ await instance.init();
481
+ },
482
+ get(testKey, defaultValue, options) {
483
+ if (!instance) {
484
+ console.warn("[SessionSight SplitTesting] Not initialized. Call init() first.");
485
+ return defaultValue;
486
+ }
487
+ return instance.get(testKey, defaultValue, options);
488
+ },
489
+ trackConversion(goalId, options) {
490
+ if (!instance) {
491
+ console.warn("[SessionSight SplitTesting] Not initialized. Call init() first.");
492
+ return;
493
+ }
494
+ instance.trackConversion(goalId, options);
495
+ },
496
+ setAttributes(attrs) {
497
+ if (!instance) {
498
+ console.warn("[SessionSight SplitTesting] Not initialized. Call init() first.");
499
+ return;
500
+ }
501
+ instance.setAttributes(attrs);
502
+ },
503
+ getAssignments() {
504
+ if (!instance) {
505
+ console.warn("[SessionSight SplitTesting] Not initialized. Call init() first.");
506
+ return {};
507
+ }
508
+ return instance.getAssignments();
509
+ },
510
+ async refresh() {
511
+ if (!instance) {
512
+ console.warn("[SessionSight SplitTesting] Not initialized. Call init() first.");
513
+ return;
514
+ }
515
+ await instance.refresh();
516
+ },
517
+ clearCache() {
518
+ if (!instance) {
519
+ console.warn("[SessionSight SplitTesting] Not initialized. Call init() first.");
520
+ return;
521
+ }
522
+ instance.clearCache();
523
+ },
524
+ destroy() {
525
+ if (instance) {
526
+ instance.destroy();
527
+ instance = null;
528
+ }
529
+ }
530
+ };
531
+ var src_default = SplitTesting;
532
+ export {
533
+ src_default as default,
534
+ SplitTestingClient
535
+ };
@@ -0,0 +1 @@
1
+ (()=>{function D(z,w){let q=`${z}:${w}`,J=5381;for(let Z=0;Z<q.length;Z++)J=(J<<5)+J+q.charCodeAt(Z)>>>0;return J%1e4}function N(z,w,q){let J=w*100;if(z>=J)return{variationIndex:0,inTest:!1};let Z=q.reduce(($,U)=>$+U.weight,0);if(Z===0)return{variationIndex:0,inTest:!0};let L=0;for(let $=0;$<q.length;$++)if(L+=q[$].weight/Z*J,z<L)return{variationIndex:$,inTest:!0};return{variationIndex:q.length-1,inTest:!0}}function T(z){let w=(z||"https://api.sessionsight.com").replace(/\/$/,"");if(w.startsWith("http://")&&!w.startsWith("http://localhost"))console.warn("SessionSight: API URL uses http:// instead of https://. Data will be transmitted unencrypted.");return w}var S="sessionsight_visitor_id",j="ss_vid",C=31536000;function m(z){if(typeof document>"u")return null;try{let w=z.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),q=document.cookie.match(new RegExp("(?:^|; )"+w+"=([^;]*)"));return q?decodeURIComponent(q[1]):null}catch{return null}}function G(z,w,q){if(typeof document>"u")return;try{let J=location.protocol==="https:"?"; Secure":"";document.cookie=`${z}=${w}; path=/; max-age=${q}; SameSite=Lax${J}`}catch{}}function X(){if(typeof crypto<"u"&&crypto.randomUUID)return crypto.randomUUID();return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(z)=>{let w=Math.random()*16|0;return(z==="x"?w:w&3|8).toString(16)})}var A=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function M(z){return A.test(z)}function O(){try{return localStorage.setItem("__ss_test__","1"),localStorage.removeItem("__ss_test__"),!0}catch{return!1}}function k(){if(typeof navigator<"u"){if(navigator.globalPrivacyControl===!0)return!0;if(navigator.doNotTrack==="1")return!0}return!1}function H(z){if(z)return z;if(k())return X();let w=typeof window<"u"&&O(),q=m(j);if(q&&M(q)){if(w)localStorage.setItem(S,q);return q}if(w){let Z=localStorage.getItem(S);if(Z&&M(Z))return G(j,Z,C),Z}let J=X();if(w)localStorage.setItem(S,J);return G(j,J,C),J}var W="ss-split-config:",Y="ss-split-assignments:";function n(){try{return localStorage.setItem("__ss_test__","1"),localStorage.removeItem("__ss_test__"),!0}catch{return!1}}var B=typeof window<"u"&&n();function b(z){if(!B)return null;try{let w=localStorage.getItem(W+z);if(!w)return null;return JSON.parse(w)}catch{return null}}function F(z,w){if(!B)return;try{let q={data:w,fetchedAt:Date.now()};localStorage.setItem(W+z,JSON.stringify(q))}catch{}}function p(z,w){if(!B)return null;try{let q=localStorage.getItem(Y+z+":"+w);if(!q)return null;return JSON.parse(q)}catch{return null}}function x(z,w,q){if(!B)return;try{localStorage.setItem(Y+z+":"+w,JSON.stringify(q))}catch{}}function K(z){if(!B)return;try{let w=[];for(let q=0;q<localStorage.length;q++){let J=localStorage.key(q);if(J&&(J.startsWith(W+z)||J.startsWith(Y+z)))w.push(J)}w.forEach((q)=>localStorage.removeItem(q))}catch{}}var V=1e4;function f(z,w){let q=new AbortController,J=setTimeout(()=>q.abort(),V);return fetch(z,{...w,signal:q.signal}).finally(()=>clearTimeout(J))}var l=0,g=86400000;class P{apiUrl;publicApiKey;propertyId;visitorId;attributes;bootstrap;antiFlicker;staleTTL;maxAge;onAssignment;config=null;assignments={};initialized=!1;antiFlickerStyle=null;flushTimer=null;pendingExposures=[];constructor(z){this.publicApiKey=z.publicApiKey,this.propertyId=z.propertyId,this.apiUrl=T(z.apiUrl||""),this.visitorId=H(z.visitorId),this.attributes=z.attributes||{},this.bootstrap=z.bootstrap||null,this.antiFlicker=z.antiFlicker||!1,this.staleTTL=z.staleTTL??l,this.maxAge=z.maxAge??g,this.onAssignment=z.onAssignment||null}async init(){if(this.antiFlicker&&typeof document<"u")this.injectAntiFlicker();try{if(this.bootstrap){let J=b(this.propertyId);if(J){this.config=J.data,this.evaluateFromBootstrap(),this.initialized=!0,this.fetchConfigInBackground();return}await this.fetchConfig(),this.evaluateFromBootstrap(),this.initialized=!0;return}let z=p(this.propertyId,this.visitorId);if(z)this.assignments=z;let w=b(this.propertyId),q=Date.now();if(w){let J=q-w.fetchedAt;if(J<this.maxAge){if(this.config=w.data,this.evaluateAssignments(),this.initialized=!0,J>=this.staleTTL)this.fetchConfigInBackground();return}}await this.fetchConfig(),this.evaluateAssignments(),this.initialized=!0}finally{this.removeAntiFlicker()}}get(z,w,q){if(!this.initialized)return console.warn("[SessionSight SplitTesting] Not initialized. Call init() first."),w;let J=this.assignments[z];if(!J)return w;if(J.inTest)this.trackExposure(z,J.variationKey);switch(J.type){case"id":return J.variationKey;case"text":return J.value||w;case"json":try{return JSON.parse(J.value)}catch{return w}default:return w}}trackConversion(z,w){if(!this.initialized)return;let q={propertyId:this.propertyId,visitorId:this.visitorId,goalId:z,...w?.value!=null?{value:w.value}:{},...w?.currency?{currency:w.currency}:{}};this.sendBeacon(`${this.apiUrl}/v1/split-testing/convert`,q)}setAttributes(z){Object.assign(this.attributes,z)}getAssignments(){let z={};for(let[w,q]of Object.entries(this.assignments))z[w]=q.variationIndex;return z}async refresh(){await this.fetchConfig(),this.evaluateAssignments()}clearCache(){K(this.propertyId)}destroy(){if(this.flushTimer)clearTimeout(this.flushTimer),this.flushTimer=null;this.flushExposures(),this.removeAntiFlicker(),this.config=null,this.assignments={},this.initialized=!1,this.pendingExposures=[]}evaluateFromBootstrap(){if(!this.config||!this.bootstrap)return;for(let z of this.config.tests){let w=this.bootstrap[z.key];if(w===void 0)continue;let q=z.variations[w];if(!q)continue;if(this.assignments[z.key]={testKey:z.key,variationIndex:w,variationKey:q.key,value:q.value,type:z.type,inTest:!0},this.onAssignment)this.onAssignment(z.key,{key:q.key,value:q.value})}x(this.propertyId,this.visitorId,this.assignments)}evaluateAssignments(){if(!this.config)return;for(let z of this.config.tests){let w=D(z.hashSeed,this.visitorId),q=N(w,z.trafficAllocation,z.variations),J=z.variations[q.variationIndex];if(!J)continue;if(this.assignments[z.key]={testKey:z.key,variationIndex:q.variationIndex,variationKey:J.key,value:J.value,type:z.type,inTest:q.inTest},this.onAssignment)this.onAssignment(z.key,{key:J.key,value:J.value})}x(this.propertyId,this.visitorId,this.assignments)}async fetchConfig(){try{let z=`${this.apiUrl}/v1/split-testing/config?propertyId=${encodeURIComponent(this.propertyId)}`,w=await f(z,{headers:{"x-api-key":this.publicApiKey}});if(!w.ok){console.warn(`[SessionSight SplitTesting] Failed to fetch config: ${w.status}`);return}let q=await w.json();if(!q||!Array.isArray(q.tests)){console.warn("[SessionSight SplitTesting] Invalid config response");return}this.config=q,F(this.propertyId,q)}catch(z){console.warn("[SessionSight SplitTesting] Failed to fetch config:",z)}}fetchConfigInBackground(){this.fetchConfig().then(()=>{if(this.config)this.evaluateAssignments()})}trackExposure(z,w){if(this.pendingExposures.some((q)=>q.splitTestKey===z))return;if(this.pendingExposures.push({splitTestKey:z,variationKey:w,visitorId:this.visitorId,timestamp:Date.now(),attributes:this.attributes}),this.pendingExposures.length===1)this.flushTimer=setTimeout(()=>this.flushExposures(),1000)}flushExposures(){if(this.pendingExposures.length===0)return;let z=[...this.pendingExposures];this.pendingExposures=[];let w={propertyId:this.propertyId,exposures:z};this.sendBeacon(`${this.apiUrl}/v1/split-testing/expose`,w)}sendBeacon(z,w){let q=JSON.stringify(w);if(typeof navigator<"u"&&navigator.sendBeacon){let J=new Blob([q],{type:"application/json"});if(navigator.sendBeacon(z,J))return}fetch(z,{method:"POST",headers:{"Content-Type":"application/json","x-api-key":this.publicApiKey},body:q,keepalive:!0}).catch(()=>{})}injectAntiFlicker(){if(typeof document>"u")return;let z=document.createElement("style");z.id="ss-split-anti-flicker",z.textContent="[data-ss-split]{visibility:hidden!important}",document.head.appendChild(z),this.antiFlickerStyle=z}removeAntiFlicker(){if(this.antiFlickerStyle)this.antiFlickerStyle.remove(),this.antiFlickerStyle=null}}var Q=null,y={async init(z){if(Q){console.warn("[SessionSight SplitTesting] Already initialized. Call destroy() first.");return}Q=new P(z),await Q.init()},get(z,w,q){if(!Q)return console.warn("[SessionSight SplitTesting] Not initialized. Call init() first."),w;return Q.get(z,w,q)},trackConversion(z,w){if(!Q){console.warn("[SessionSight SplitTesting] Not initialized. Call init() first.");return}Q.trackConversion(z,w)},setAttributes(z){if(!Q){console.warn("[SessionSight SplitTesting] Not initialized. Call init() first.");return}Q.setAttributes(z)},getAssignments(){if(!Q)return console.warn("[SessionSight SplitTesting] Not initialized. Call init() first."),{};return Q.getAssignments()},async refresh(){if(!Q){console.warn("[SessionSight SplitTesting] Not initialized. Call init() first.");return}await Q.refresh()},clearCache(){if(!Q){console.warn("[SessionSight SplitTesting] Not initialized. Call init() first.");return}Q.clearCache()},destroy(){if(Q)Q.destroy(),Q=null}},R=y;window.SessionSightSplitTesting=R;})();
@@ -0,0 +1,44 @@
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
+ export interface AssignedVariation {
14
+ key: string;
15
+ value: string;
16
+ }
17
+ export interface SplitTestConfigEntry {
18
+ key: string;
19
+ id: string;
20
+ type: 'id' | 'text' | 'json';
21
+ status: string;
22
+ hashSeed: string;
23
+ trafficAllocation: number;
24
+ variations: Array<{
25
+ key: string;
26
+ weight: number;
27
+ value: string;
28
+ }>;
29
+ }
30
+ export interface SplitTestConfigResponse {
31
+ tests: SplitTestConfigEntry[];
32
+ ttl: number;
33
+ }
34
+ export interface Assignment {
35
+ testKey: string;
36
+ variationIndex: number;
37
+ variationKey: string;
38
+ value: string;
39
+ type: 'id' | 'text' | 'json';
40
+ inTest: boolean;
41
+ }
42
+ export interface GetOptions {
43
+ [key: string]: any;
44
+ }