@sleekio/sdk
Version:
Modern A/B Testing SDK for JavaScript Applications - Production-ready with graceful fallbacks
620 lines (527 loc) • 17.2 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
/**
* Sleek SDK - Modern A/B Testing for JavaScript Applications
* Built with functional programming patterns and modern JavaScript
*/
// Generate a cryptographically secure random ID
function generateSecureId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback for older browsers
return "sleek_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9);
}
// Generate a device fingerprint (basic version)
function generateDeviceFingerprint() {
if (typeof window === "undefined") return "server_" + generateSecureId();
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
ctx.textBaseline = "top";
ctx.font = "14px Arial";
ctx.fillText("Sleek fingerprint", 2, 2);
const fingerprint = [
navigator.userAgent,
navigator.language,
screen.width + "x" + screen.height,
new Date().getTimezoneOffset(),
canvas.toDataURL(),
].join("|");
// Simple hash function
let hash = 0;
for (let i = 0; i < fingerprint.length; i++) {
const char = fingerprint.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return "fp_" + Math.abs(hash).toString(36);
}
// User ID Manager class
class SleekUserManager {
constructor(options = {}) {
this.options = {
storageKey: "sleek_user_id",
useLocalStorage: true,
useSessionStorage: true,
cookieName: "sleek_uid",
cookieExpireDays: 365,
useDeviceFingerprint: false,
fallbackToFingerprint: true,
...options,
};
this.userId = null;
this.isAnonymous = true;
this.userProperties = {};
}
// Set authenticated user ID
setAuthenticatedUser(userId, userProperties = {}) {
this.userId = userId;
this.isAnonymous = false;
this.userProperties = userProperties;
// Store in persistent storage
this.storeUserId(userId);
return userId;
}
// Get or generate user ID
getUserId() {
if (this.userId) {
return this.userId;
}
// Try to get from storage
this.userId = this.getStoredUserId();
if (!this.userId) {
// Generate new anonymous ID
this.userId = this.generateAnonymousId();
this.storeUserId(this.userId);
}
return this.userId;
}
// Generate anonymous user ID
generateAnonymousId() {
if (this.options.useDeviceFingerprint) {
return generateDeviceFingerprint();
}
const anonymousId = "anon_" + generateSecureId();
this.isAnonymous = true;
return anonymousId;
}
// Store user ID in various storage mechanisms
storeUserId(userId) {
try {
// Local Storage (persistent across sessions)
if (this.options.useLocalStorage && typeof localStorage !== "undefined") {
localStorage.setItem(this.options.storageKey, userId);
}
// Session Storage (current session only)
if (
this.options.useSessionStorage &&
typeof sessionStorage !== "undefined"
) {
sessionStorage.setItem(this.options.storageKey + "_session", userId);
}
// Cookie (works across subdomains)
if (this.options.cookieName && typeof document !== "undefined") {
const expires = new Date();
expires.setDate(expires.getDate() + this.options.cookieExpireDays);
document.cookie = `${
this.options.cookieName
}=${userId}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`;
}
} catch (error) {
console.warn("Failed to store user ID:", error);
}
}
// Retrieve stored user ID
getStoredUserId() {
try {
// Priority order: localStorage > sessionStorage > cookie > fingerprint
// Local Storage first
if (this.options.useLocalStorage && typeof localStorage !== "undefined") {
const stored = localStorage.getItem(this.options.storageKey);
if (stored) return stored;
}
// Session Storage
if (
this.options.useSessionStorage &&
typeof sessionStorage !== "undefined"
) {
const stored = sessionStorage.getItem(
this.options.storageKey + "_session"
);
if (stored) return stored;
}
// Cookie
if (this.options.cookieName && typeof document !== "undefined") {
const cookies = document.cookie.split(";");
for (let cookie of cookies) {
const [name, value] = cookie.trim().split("=");
if (name === this.options.cookieName) {
return value;
}
}
}
// Device fingerprint as fallback (only if explicitly enabled)
if (
this.options.fallbackToFingerprint &&
this.options.useDeviceFingerprint
) {
return generateDeviceFingerprint();
}
} catch (error) {
console.warn("Failed to retrieve stored user ID:", error);
}
return null;
}
// Clear user data (logout)
clearUser() {
this.userId = null;
this.isAnonymous = true;
this.userProperties = {};
try {
if (typeof localStorage !== "undefined") {
localStorage.removeItem(this.options.storageKey);
}
if (typeof sessionStorage !== "undefined") {
sessionStorage.removeItem(this.options.storageKey + "_session");
}
if (typeof document !== "undefined") {
document.cookie = `${this.options.cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
}
} catch (error) {
console.warn("Failed to clear user data:", error);
}
}
// Get user context for analytics
getUserContext() {
return {
userId: this.getUserId(),
isAnonymous: this.isAnonymous,
userProperties: this.userProperties || {},
sessionId: this.getSessionId(),
deviceInfo: this.getDeviceInfo(),
};
}
// Get or generate session ID
getSessionId() {
if (typeof sessionStorage === "undefined") return null;
let sessionId = sessionStorage.getItem("sleek_session_id");
if (!sessionId) {
sessionId = "ses_" + generateSecureId();
sessionStorage.setItem("sleek_session_id", sessionId);
}
return sessionId;
}
// Get device information
getDeviceInfo() {
if (typeof navigator === "undefined") return {};
return {
userAgent: navigator.userAgent,
language: navigator.language,
platform: navigator.platform,
screenResolution:
typeof screen !== "undefined"
? `${screen.width}x${screen.height}`
: null,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
cookieEnabled: navigator.cookieEnabled,
};
}
}
// Enhanced Sleek SDK factory with user management
function createSleekSDK(apiUrl, userIdOrManager, options = {}) {
let userManager;
// Handle different user ID inputs
if (typeof userIdOrManager === "string") {
// Direct user ID provided
userManager = new SleekUserManager(options.userManager || {});
if (
userIdOrManager.startsWith("anon_") ||
userIdOrManager.startsWith("fp_")
) {
userManager.userId = userIdOrManager;
userManager.isAnonymous = true;
} else {
userManager.setAuthenticatedUser(userIdOrManager);
}
} else if (userIdOrManager instanceof SleekUserManager) {
// User manager instance provided
userManager = userIdOrManager;
} else if (userIdOrManager === null || userIdOrManager === undefined) {
// No user ID provided - create anonymous user
userManager = new SleekUserManager(options.userManager || {});
} else {
throw new Error("Invalid user ID or user manager provided");
}
const config = {
apiUrl: apiUrl.replace(/\/$/, ""),
timeout: 5000,
requestTimeout: 5000,
retryAttempts: 3,
batchEvents: true,
batchSize: 10,
flushInterval: 5000,
autoRefresh: true,
gracefulFallback: true, // Always return 'control' on errors
enableLogging: false, // Disable in production
...options,
};
// Get current user ID
const getCurrentUserId = () => userManager.getUserId();
const getUserContext = () => userManager.getUserContext();
// Internal state
const assignments = new Map();
const eventQueue = [];
let flushTimer = null;
// Utility functions
const createAbortController = (timeout) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
return { controller, timeoutId };
};
const makeRequest = async (endpoint, body, method = "POST") => {
const controller = createAbortController(config.requestTimeout);
try {
const response = await fetch(`${apiUrl}${endpoint}`, {
method,
headers: {
"Content-Type": "application/json",
...options.headers,
},
body: JSON.stringify(body),
signal: controller.signal,
});
// Enhanced error handling for production
if (!response.ok) {
console.warn(
`Sleek API ${method} ${endpoint} failed:`,
response.status,
response.statusText
);
// Return graceful fallbacks instead of throwing
if (endpoint.includes("/assign")) {
return { variant: "control", config: {} };
}
if (endpoint.includes("/track")) {
return { success: false };
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
// Network errors, timeouts, etc.
if (error.name === "AbortError") {
console.warn(`Sleek API request timeout for ${endpoint}`);
} else {
console.warn(
`Sleek API request failed for ${endpoint}:`,
error.message
);
}
// Always return safe fallbacks to prevent app crashes
if (endpoint.includes("/assign")) {
return { variant: "control", config: {} };
}
if (endpoint.includes("/track")) {
return { success: false };
}
throw error;
}
};
// Check if assignment is expired
const isAssignmentExpired = (assignment) => {
if (!assignment.expires_at) return false;
return new Date(assignment.expires_at) <= new Date();
};
// Core functions
const getVariant = async (experimentId, forceRefresh = false) => {
const existingAssignment = assignments.get(experimentId);
// Return cached assignment if valid and not expired
if (
!forceRefresh &&
existingAssignment &&
!isAssignmentExpired(existingAssignment)
) {
return existingAssignment.variant;
}
// Clear expired assignment
if (existingAssignment && isAssignmentExpired(existingAssignment)) {
assignments.delete(experimentId);
}
try {
const result = await makeRequest("/api/assign", {
experimentId,
userId: getCurrentUserId(),
sessionId: userManager.getSessionId(),
});
const assignment = {
variant: result.variant || "control",
config: result.config || {},
expires_at: result.expires_at,
assigned_at: new Date().toISOString(),
};
assignments.set(experimentId, assignment);
// Set up auto-refresh if assignment expires
if (assignment.expires_at && config.autoRefresh) {
const expiryTime = new Date(assignment.expires_at).getTime();
const refreshTime = expiryTime - Date.now() - 60000; // Refresh 1 minute before expiry
if (refreshTime > 0) {
setTimeout(() => {
getVariant(experimentId, true);
}, refreshTime);
}
}
return assignment.variant;
} catch (error) {
console.warn(
`Sleek assignment failed for experiment ${experimentId}:`,
error.message
);
return "control";
}
};
const getVariantConfig = async (experimentId, forceRefresh = false) => {
const existingAssignment = assignments.get(experimentId);
if (
!forceRefresh &&
existingAssignment &&
!isAssignmentExpired(existingAssignment)
) {
return existingAssignment.config;
}
await getVariant(experimentId, forceRefresh);
return assignments.get(experimentId)?.config || {};
};
// Check if assignment will expire soon
const isAssignmentExpiringSoon = (experimentId, minutesBeforeExpiry = 5) => {
const assignment = assignments.get(experimentId);
if (!assignment || !assignment.expires_at) return false;
const expiryTime = new Date(assignment.expires_at).getTime();
const warningTime = expiryTime - minutesBeforeExpiry * 60 * 1000;
return Date.now() >= warningTime;
};
// Get assignment metadata
const getAssignmentInfo = (experimentId) => {
const assignment = assignments.get(experimentId);
if (!assignment) return null;
return {
variant: assignment.variant,
assigned_at: assignment.assigned_at,
expires_at: assignment.expires_at,
is_expired: isAssignmentExpired(assignment),
expires_soon: isAssignmentExpiringSoon(experimentId),
session_id: userManager.getSessionId(),
};
};
const createEvent = (experimentId, eventName, properties = {}) => ({
userId: getCurrentUserId(),
experimentId,
eventName,
properties: {
...properties,
timestamp: Date.now(),
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "",
url: typeof window !== "undefined" ? window.location.href : "",
},
});
const sendSingleEvent = async (event) => {
try {
await makeRequest("/api/track", event);
} catch (error) {
console.warn("Event tracking failed:", error.message);
if (config.batchEvents && eventQueue.length < config.batchSize) {
eventQueue.push(event);
}
}
};
const flushEvents = async () => {
if (eventQueue.length === 0) return;
const events = [...eventQueue];
eventQueue.length = 0; // Clear queue
try {
await makeRequest("/api/track/batch", { events });
} catch (error) {
console.warn("Batch event tracking failed:", error.message);
if (eventQueue.length < config.batchSize) {
eventQueue.unshift(...events);
}
}
};
const startBatchFlushing = () => {
if (flushTimer) return;
flushTimer = setInterval(() => {
if (eventQueue.length > 0) {
flushEvents();
}
}, config.flushInterval);
// Flush on page unload (browser only)
if (typeof window !== "undefined") {
window.addEventListener("beforeunload", flushEvents);
}
};
const track = (experimentId, eventName, properties = {}) => {
const event = createEvent(experimentId, eventName, properties);
if (config.batchEvents) {
eventQueue.push(event);
if (!flushTimer) {
startBatchFlushing();
}
if (eventQueue.length >= config.batchSize) {
flushEvents();
}
} else {
sendSingleEvent(event);
}
};
const trackConversion = (experimentId, properties = {}) => {
track(experimentId, "conversion", {
...properties,
value: properties.value || 1,
});
};
const clearAssignments = () => {
assignments.clear();
};
const getAssignments = () => {
return Object.fromEntries(
Array.from(assignments.entries()).map(([key, value]) => [
key,
value.variant,
])
);
};
const cleanup = () => {
if (flushTimer) {
clearInterval(flushTimer);
flushTimer = null;
}
flushEvents();
};
// Return SDK interface
return {
// Core SDK methods
getVariant,
getVariantConfig,
getAssignmentInfo,
isAssignmentExpired: (experimentId) => {
const assignment = assignments.get(experimentId);
return assignment ? isAssignmentExpired(assignment) : false;
},
isAssignmentExpiringSoon,
refreshAssignment: (experimentId) => getVariant(experimentId, true),
track,
trackConversion,
flushEvents,
clearAssignments,
getAssignments,
// User management methods
setUser: (userId, properties) =>
userManager.setAuthenticatedUser(userId, properties),
clearUser: () => userManager.clearUser(),
getUserId: getCurrentUserId,
getUserContext,
isAnonymous: () => userManager.isAnonymous,
// Utility methods
getSessionId: () => userManager.getSessionId(),
cleanup,
};
}
// Convenience functions for quick setup
function createAnonymousSleek(apiUrl, options = {}) {
return createSleekSDK(apiUrl, null, options);
}
function createAuthenticatedSleek(
apiUrl,
userId,
userProperties = {},
options = {}
) {
const userManager = new SleekUserManager(options.userManager || {});
userManager.setAuthenticatedUser(userId, userProperties);
return createSleekSDK(apiUrl, userManager, options);
}
exports.SleekUserManager = SleekUserManager;
exports.createAnonymousSleek = createAnonymousSleek;
exports.createAuthenticatedSleek = createAuthenticatedSleek;
exports.createSleekSDK = createSleekSDK;
exports.default = createSleekSDK;
//# sourceMappingURL=sleek.cjs.js.map