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