UNPKG

@sleekio/sdk

Version:

Modern A/B Testing SDK for JavaScript Applications - Production-ready with graceful fallbacks

620 lines (527 loc) 17.2 kB
'use strict'; 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