UNPKG

@tinytapanalytics/sdk

Version:

Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time

438 lines (378 loc) 11.6 kB
/** * Service Worker Manager for TinyTapAnalytics SDK * Handles offline event queuing and background synchronization */ import { TinyTapAnalyticsConfig, QueuedEvent } from '../types/index'; interface ServiceWorkerConfig { enableOfflineQueue: boolean; maxOfflineEvents: number; syncInterval: number; } export class ServiceWorkerManager { private config: TinyTapAnalyticsConfig; private swConfig: ServiceWorkerConfig; private registration: ServiceWorkerRegistration | null = null; private isSupported: boolean; constructor(config: TinyTapAnalyticsConfig) { this.config = config; this.isSupported = 'serviceWorker' in navigator && 'PushManager' in window; this.swConfig = { enableOfflineQueue: config.enableOfflineQueue ?? true, maxOfflineEvents: config.maxOfflineEvents ?? 1000, syncInterval: config.syncInterval ?? 30000 // 30 seconds }; } /** * Initialize Service Worker if supported and enabled */ public async init(): Promise<void> { if (!this.isSupported || !this.swConfig.enableOfflineQueue) { return; } try { // Register service worker with cache-busting timestamp const swUrl = `/tinytapanalytics-sw.js?v=${Date.now()}`; this.registration = await navigator.serviceWorker.register(swUrl, { scope: '/', updateViaCache: 'none' }); // Set up message handling this.setupMessageHandling(); // Initialize background sync if supported if ('sync' in window.ServiceWorkerRegistration.prototype) { await this.setupBackgroundSync(); } if (this.config.debug) { console.log('TinyTapAnalytics: Service Worker registered successfully'); } } catch (error) { if (this.config.debug) { console.warn('TinyTapAnalytics: Service Worker registration failed:', error); } } } /** * Queue event for offline processing */ public async queueOfflineEvent(event: QueuedEvent): Promise<void> { if (!this.isSupported || !this.registration) { return; } try { // Send event to service worker for offline storage await this.sendMessageToSW({ type: 'QUEUE_OFFLINE_EVENT', payload: event }); } catch (error) { if (this.config.debug) { console.warn('TinyTapAnalytics: Failed to queue offline event:', error); } } } /** * Trigger background sync for queued events */ public async triggerSync(): Promise<void> { if (!this.registration || !('sync' in this.registration)) { return; } try { await (this.registration as any).sync.register('tinytapanalytics-sync'); } catch (error) { if (this.config.debug) { console.warn('TinyTapAnalytics: Background sync registration failed:', error); } } } /** * Get offline queue statistics */ public async getOfflineStats(): Promise<{ queueSize: number; lastSync: number }> { if (!this.isSupported || !this.registration) { return { queueSize: 0, lastSync: 0 }; } try { const response = await this.sendMessageToSW({ type: 'GET_OFFLINE_STATS' }); return response || { queueSize: 0, lastSync: 0 }; } catch (error) { return { queueSize: 0, lastSync: 0 }; } } /** * Clear offline queue */ public async clearOfflineQueue(): Promise<void> { if (!this.isSupported || !this.registration) { return; } try { await this.sendMessageToSW({ type: 'CLEAR_OFFLINE_QUEUE' }); } catch (error) { if (this.config.debug) { console.warn('TinyTapAnalytics: Failed to clear offline queue:', error); } } } /** * Setup message handling between main thread and service worker */ private setupMessageHandling(): void { if (!navigator.serviceWorker) { return; } navigator.serviceWorker.addEventListener('message', (event) => { const { type, payload } = event.data; switch (type) { case 'SYNC_COMPLETED': if (this.config.debug) { console.log('TinyTapAnalytics: Background sync completed', payload); } break; case 'SYNC_FAILED': if (this.config.debug) { console.warn('TinyTapAnalytics: Background sync failed', payload); } break; case 'OFFLINE_EVENT_QUEUED': if (this.config.debug) { console.log('TinyTapAnalytics: Event queued for offline processing'); } break; } }); } /** * Setup background sync registration */ private async setupBackgroundSync(): Promise<void> { if (!this.registration || !('sync' in this.registration)) { return; } try { // Register recurring sync await (this.registration as any).sync.register('tinytapanalytics-sync'); } catch (error) { if (this.config.debug) { console.warn('TinyTapAnalytics: Initial background sync registration failed:', error); } } } /** * Send message to service worker and wait for response */ private async sendMessageToSW(message: any): Promise<any> { if (!this.registration || !this.registration.active) { throw new Error('Service Worker not active'); } return new Promise((resolve, reject) => { const messageChannel = new MessageChannel(); messageChannel.port1.onmessage = (event) => { if (event.data.error) { reject(new Error(event.data.error)); } else { resolve(event.data); } }; this.registration!.active!.postMessage(message, [messageChannel.port2]); // Timeout after 5 seconds setTimeout(() => { reject(new Error('Service Worker message timeout')); }, 5000); }); } /** * Check if Service Worker features are supported */ public isServiceWorkerSupported(): boolean { return this.isSupported; } /** * Generate Service Worker code for deployment */ public static generateServiceWorkerCode(config: Partial<TinyTapAnalyticsConfig> = {}): string { const cacheName = `tinytapanalytics-offline-v${Date.now()}`; const maxEvents = config.maxOfflineEvents ?? 1000; const endpoint = config.endpoint ?? 'https://api.tinytapanalytics.com'; return ` // TinyTapAnalytics Service Worker for Offline Event Processing const CACHE_NAME = '${cacheName}'; const MAX_OFFLINE_EVENTS = ${maxEvents}; const API_ENDPOINT = '${endpoint}'; self.addEventListener('install', (event) => { self.skipWaiting(); }); self.addEventListener('activate', (event) => { event.waitUntil(self.clients.claim()); }); // Handle background sync self.addEventListener('sync', (event) => { if (event.tag === 'tinytapanalytics-sync') { event.waitUntil(syncOfflineEvents()); } }); // Handle messages from main thread self.addEventListener('message', (event) => { const { type, payload } = event.data; const port = event.ports[0]; switch (type) { case 'QUEUE_OFFLINE_EVENT': queueOfflineEvent(payload).then(() => { port.postMessage({ success: true }); }).catch((error) => { port.postMessage({ error: error.message }); }); break; case 'GET_OFFLINE_STATS': getOfflineStats().then((stats) => { port.postMessage(stats); }).catch((error) => { port.postMessage({ error: error.message }); }); break; case 'CLEAR_OFFLINE_QUEUE': clearOfflineQueue().then(() => { port.postMessage({ success: true }); }).catch((error) => { port.postMessage({ error: error.message }); }); break; } }); // Queue event for offline processing async function queueOfflineEvent(event) { try { const db = await openDB(); const tx = db.transaction(['events'], 'readwrite'); await tx.objectStore('events').add({ ...event, queuedAt: Date.now() }); // Notify main thread self.clients.matchAll().then(clients => { clients.forEach(client => { client.postMessage({ type: 'OFFLINE_EVENT_QUEUED', payload: event }); }); }); } catch (error) { console.error('Failed to queue offline event:', error); throw error; } } // Sync offline events to server async function syncOfflineEvents() { try { const db = await openDB(); const tx = db.transaction(['events'], 'readonly'); const events = await tx.objectStore('events').getAll(); if (events.length === 0) return; // Send events in batches const batchSize = 10; for (let i = 0; i < events.length; i += batchSize) { const batch = events.slice(i, i + batchSize); try { const response = await fetch(API_ENDPOINT + '/api/v1/events/batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(batch.map(e => e.payload)) }); if (response.ok) { // Remove successfully sent events const deleteTx = db.transaction(['events'], 'readwrite'); for (const event of batch) { await deleteTx.objectStore('events').delete(event.id); } } } catch (error) { console.error('Failed to sync event batch:', error); } } // Notify main thread self.clients.matchAll().then(clients => { clients.forEach(client => { client.postMessage({ type: 'SYNC_COMPLETED', payload: { eventCount: events.length } }); }); }); } catch (error) { console.error('Background sync failed:', error); self.clients.matchAll().then(clients => { clients.forEach(client => { client.postMessage({ type: 'SYNC_FAILED', payload: { error: error.message } }); }); }); } } // Get offline queue statistics async function getOfflineStats() { try { const db = await openDB(); const tx = db.transaction(['events'], 'readonly'); const count = await tx.objectStore('events').count(); return { queueSize: count, lastSync: Date.now() }; } catch (error) { return { queueSize: 0, lastSync: 0 }; } } // Clear offline queue async function clearOfflineQueue() { try { const db = await openDB(); const tx = db.transaction(['events'], 'readwrite'); await tx.objectStore('events').clear(); } catch (error) { console.error('Failed to clear offline queue:', error); throw error; } } // Open IndexedDB for offline storage function openDB() { return new Promise((resolve, reject) => { const request = indexedDB.open('TinyTapAnalyticsOffline', 1); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains('events')) { const store = db.createObjectStore('events', { keyPath: 'id', autoIncrement: true }); store.createIndex('queuedAt', 'queuedAt'); store.createIndex('priority', 'priority'); } }; }); } `; } /** * Clean up Service Worker */ public async destroy(): Promise<void> { if (this.registration) { try { await this.registration.unregister(); this.registration = null; } catch (error) { if (this.config.debug) { console.warn('TinyTapAnalytics: Service Worker cleanup failed:', error); } } } } }