@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
text/typescript
/**
* 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);
}
}
}
}
}