UNPKG

@tinytapanalytics/sdk

Version:

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

505 lines (435 loc) 14.8 kB
/** * Network Manager for handling HTTP requests with retry logic and error handling */ import { TinyTapAnalyticsConfig, NetworkRequest, NetworkResponse, SDKError } from '../types/index'; import { ErrorHandler } from './ErrorHandler'; import packageJson from '../../package.json'; export class NetworkManager { private config: TinyTapAnalyticsConfig; private errorHandler: ErrorHandler; constructor(config: TinyTapAnalyticsConfig, errorHandler: ErrorHandler) { this.config = config; this.errorHandler = errorHandler; } /** * Send a network request with retry logic */ public async request<T = any>(request: NetworkRequest): Promise<NetworkResponse<T>> { const maxAttempts = request.retry?.maxAttempts || this.config.retry?.maxAttempts || 3; const baseDelay = request.retry?.baseDelay || this.config.retry?.baseDelay || 1000; const maxDelay = request.retry?.maxDelay || this.config.retry?.maxDelay || 30000; let lastError: Error | null = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const response = await this.makeRequest<T>(request); if (this.config.debug) { console.log('TinyTapAnalytics: Request successful', { url: request.url, status: response.status, attempt }); } return response; } catch (error) { lastError = error as Error; if (this.config.debug) { console.warn('TinyTapAnalytics: Request failed', { url: request.url, attempt, error: error instanceof Error ? error.message : 'Unknown error' }); } // Don't retry on certain status codes if (error instanceof Error && 'status' in error) { const statusCode = (error as any).status; if (statusCode === 400 || statusCode === 401 || statusCode === 403 || statusCode === 404) { throw error; } } // Wait before retrying (exponential backoff with jitter) if (attempt < maxAttempts) { const delay = Math.min( baseDelay * Math.pow(2, attempt - 1) + Math.random() * 1000, maxDelay ); await this.sleep(delay); } } } // All attempts failed if (lastError) { this.errorHandler.handle(lastError, 'network_request'); throw lastError; } throw new Error('Request failed after all retry attempts'); } /** * Send events in batch */ public async sendBatch(events: any[]): Promise<void> { const batchRequest: NetworkRequest = { url: `${this.config.endpoint}/api/v1/events/batch`, method: 'POST', headers: { 'Content-Type': 'application/json', // Note: Events endpoint uses [AllowAnonymous] - no auth header needed // Website validation is done via website_id in the payload 'X-TinyTapAnalytics-SDK': packageJson.version, 'X-TinyTapAnalytics-User-Agent': navigator.userAgent }, body: JSON.stringify(events), timeout: this.config.timeout || 5000 }; await this.request(batchRequest); } /** * Send a single event */ public async sendEvent(event: any): Promise<void> { const eventRequest: NetworkRequest = { url: `${this.config.endpoint}/api/v1/events`, method: 'POST', headers: { 'Content-Type': 'application/json', // Note: Events endpoint uses [AllowAnonymous] - no auth header needed // Website validation is done via website_id in the payload 'X-TinyTapAnalytics-SDK': packageJson.version, 'X-TinyTapAnalytics-User-Agent': navigator.userAgent }, body: JSON.stringify(event), timeout: this.config.timeout || 5000 }; await this.request(eventRequest); } /** * Check if the SDK can connect to the API */ public async ping(): Promise<boolean> { try { const pingRequest: NetworkRequest = { url: `${this.config.endpoint}/api/v1/ping`, method: 'GET', headers: { 'Authorization': `Bearer ${this.config.apiKey}` }, timeout: 3000, retry: { maxAttempts: 1, baseDelay: 0, maxDelay: 0 } }; await this.request(pingRequest); return true; } catch (error) { return false; } } /** * Make the actual HTTP request */ private async makeRequest<T>(request: NetworkRequest): Promise<NetworkResponse<T>> { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); // Set up timeout const timeout = request.timeout || this.config.timeout || 5000; const timeoutId = setTimeout(() => { xhr.abort(); reject(new Error(`Request timeout after ${timeout}ms`)); }, timeout); xhr.onreadystatechange = () => { if (xhr.readyState === XMLHttpRequest.DONE) { clearTimeout(timeoutId); const response: NetworkResponse<T> = { status: xhr.status, statusText: xhr.statusText, data: this.parseResponse(xhr.responseText), headers: this.parseHeaders(xhr.getAllResponseHeaders()), request }; if (xhr.status >= 200 && xhr.status < 300) { resolve(response); } else { const error = new Error(`Request failed with status ${xhr.status}: ${xhr.statusText}`) as any; error.status = xhr.status; error.response = response; reject(error); } } }; xhr.onerror = () => { clearTimeout(timeoutId); reject(new Error('Network error occurred')); }; xhr.onabort = () => { clearTimeout(timeoutId); reject(new Error('Request was aborted')); }; // Open the request xhr.open(request.method, request.url, true); // Set headers if (request.headers) { Object.entries(request.headers).forEach(([key, value]) => { try { xhr.setRequestHeader(key, value); } catch (error) { // Some headers like User-Agent, Cookie, etc. can't be set in browsers if (this.config.debug) { console.warn(`TinyTapAnalytics: Could not set header ${key}`, error); } } }); } // Send the request // Note: request.body is already a JSON string, don't stringify again xhr.send(request.body || null); }); } /** * Parse response body */ private parseResponse(responseText: string): any { if (!responseText) { return null; } try { return JSON.parse(responseText); } catch (error) { return responseText; } } /** * Parse response headers */ private parseHeaders(headerString: string): Record<string, string> { const headers: Record<string, string> = {}; if (!headerString) { return headers; } headerString.split('\r\n').forEach(line => { const colonIndex = line.indexOf(':'); if (colonIndex > 0) { const key = line.substring(0, colonIndex).trim().toLowerCase(); const value = line.substring(colonIndex + 1).trim(); headers[key] = value; } }); return headers; } /** * Sleep for specified milliseconds */ private sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Check if the current network connection supports sending data */ public canSendData(): boolean { // Check if online if (!navigator.onLine) { return false; } // Check network information if available const connection = (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection; if (connection) { // Don't send on very slow connections to preserve user experience if (connection.effectiveType === 'slow-2g') { return false; } // Respect user's data saver preference if (connection.saveData) { return false; } } return true; } /** * Use sendBeacon for critical events when page is unloading */ public sendBeacon(event: any): boolean { if (typeof navigator.sendBeacon !== 'function') { return false; } const url = `${this.config.endpoint}/api/v1/events`; const blob = new Blob([JSON.stringify(event)], { type: 'application/json' }); try { return navigator.sendBeacon(url, blob); } catch (error) { if (this.config.debug) { console.warn('TinyTapAnalytics: sendBeacon failed', error); } return false; } } /** * Send event using image beacon (CORS-free method) * This works across all domains without CORS restrictions */ public sendImageBeacon(event: any): Promise<boolean> { return new Promise((resolve) => { const img = new Image(); const url = new URL(`${this.config.endpoint}/api/v1/events/pixel`); // Encode event data as query parameters (with size limit) const eventData = this.compressEventForBeacon(event); url.searchParams.append('d', btoa(JSON.stringify(eventData))); url.searchParams.append('t', Date.now().toString()); img.onload = () => { if (this.config.debug) { console.log('TinyTapAnalytics: Image beacon sent successfully'); } resolve(true); }; img.onerror = () => { if (this.config.debug) { console.warn('TinyTapAnalytics: Image beacon failed'); } resolve(false); }; // Set src to trigger the request img.src = url.toString(); }); } /** * Send event with automatic fallback strategy * 1. Try XHR/fetch (best for rich data) * 2. Fall back to sendBeacon (reliable, no CORS) * 3. Fall back to image beacon (works everywhere) */ public async sendWithFallback(event: any): Promise<boolean> { // Strategy 1: Try regular request first try { await this.sendEvent(event); return true; } catch (error) { if (this.config.debug) { console.warn('TinyTapAnalytics: Primary send failed, trying fallback', error); } // Strategy 2: Try sendBeacon if available if (typeof navigator.sendBeacon === 'function') { const beaconSuccess = this.sendBeacon(event); if (beaconSuccess) { if (this.config.debug) { console.log('TinyTapAnalytics: Event sent via sendBeacon'); } return true; } } // Strategy 3: Use image beacon as final fallback if (this.config.debug) { console.log('TinyTapAnalytics: Using image beacon fallback'); } return await this.sendImageBeacon(event); } } /** * Compress event data for URL transmission (used by image beacon) * Removes large fields and keeps only essential data */ private compressEventForBeacon(event: any): any { return { e: event.event_type, // event type w: event.website_id, // website id s: event.session_id, // session id u: event.user_id, // user id (optional) t: event.timestamp, // timestamp p: event.page_url, // page url m: this.compressMetadata(event.metadata) // compressed metadata }; } /** * Compress metadata to fit in URL */ private compressMetadata(metadata: any): any { if (!metadata) { return {}; } // Keep only essential fields to avoid URL length limits const essential: any = { device_type: metadata.device_type, viewport_width: metadata.viewport_width, viewport_height: metadata.viewport_height, language: metadata.language }; // Add custom data if it's small const customData = { ...metadata }; delete customData.device_type; delete customData.viewport_width; delete customData.viewport_height; delete customData.screen_width; delete customData.screen_height; delete customData.timezone; delete customData.language; delete customData.user_context; delete customData.sdk_version; const customDataStr = JSON.stringify(customData); if (customDataStr.length < 500) { essential.custom = customData; } return essential; } /** * Make request with CORS credentials if configured */ public async requestWithCredentials<T>(request: NetworkRequest): Promise<NetworkResponse<T>> { // Add withCredentials flag for cross-origin requests that need cookies return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); // Enable credentials for cross-origin requests if configured if (this.config.withCredentials) { xhr.withCredentials = true; } // Set up timeout const timeout = request.timeout || this.config.timeout || 5000; const timeoutId = setTimeout(() => { xhr.abort(); reject(new Error(`Request timeout after ${timeout}ms`)); }, timeout); xhr.onreadystatechange = () => { if (xhr.readyState === XMLHttpRequest.DONE) { clearTimeout(timeoutId); const response: NetworkResponse<T> = { status: xhr.status, statusText: xhr.statusText, data: this.parseResponse(xhr.responseText), headers: this.parseHeaders(xhr.getAllResponseHeaders()), request }; if (xhr.status >= 200 && xhr.status < 300) { resolve(response); } else { const error = new Error(`Request failed with status ${xhr.status}: ${xhr.statusText}`) as any; error.status = xhr.status; error.response = response; reject(error); } } }; xhr.onerror = () => { clearTimeout(timeoutId); reject(new Error('Network error occurred (possibly CORS)')); }; xhr.onabort = () => { clearTimeout(timeoutId); reject(new Error('Request was aborted')); }; // Open the request xhr.open(request.method, request.url, true); // Set headers if (request.headers) { Object.entries(request.headers).forEach(([key, value]) => { try { xhr.setRequestHeader(key, value); } catch (error) { // Some headers like User-Agent can't be set in browsers if (this.config.debug) { console.warn(`TinyTapAnalytics: Could not set header ${key}`); } } }); } // Send the request xhr.send(request.body || null); }); } }