UNPKG

@tinytapanalytics/sdk

Version:

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

543 lines (477 loc) 16 kB
/** * Environment Detector for framework, device, and platform detection * Provides comprehensive environment analysis for adaptive tracking */ import { EnvironmentInfo } from '../types/index'; export class EnvironmentDetector { private environmentInfo: EnvironmentInfo; private detectionCache: Map<string, any> = new Map(); constructor() { this.environmentInfo = this.detectEnvironment(); } /** * Get complete environment information */ public getEnvironmentInfo(): EnvironmentInfo { return { ...this.environmentInfo }; } /** * Check if current environment is a Single Page Application */ public isSPA(): boolean { return this.environmentInfo.isSPA; } /** * Check if current environment is Server-Side Rendered */ public isSSR(): boolean { return this.environmentInfo.isSSR; } /** * Get detected framework */ public getFramework(): 'react' | 'vue' | 'angular' | 'vanilla' | undefined { return this.environmentInfo.framework; } /** * Get platform type */ public getPlatform(): 'web' | 'mobile-web' | 'amp' | 'unknown' { return this.environmentInfo.platform; } /** * Get browser information */ public getBrowserInfo(): EnvironmentInfo['browser'] { return { ...this.environmentInfo.browser }; } /** * Get device information */ public getDeviceInfo(): EnvironmentInfo['device'] { return { ...this.environmentInfo.device }; } /** * Get network information */ public getNetworkInfo(): EnvironmentInfo['network'] | undefined { return this.environmentInfo.network ? { ...this.environmentInfo.network } : undefined; } /** * Check if device has touch support */ public hasTouchSupport(): boolean { return this.getCachedResult('touchSupport', () => { return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || 'msMaxTouchPoints' in navigator; }); } /** * Check if running in mobile browser */ public isMobile(): boolean { return this.environmentInfo.device.type === 'mobile'; } /** * Check if running in tablet browser */ public isTablet(): boolean { return this.environmentInfo.device.type === 'tablet'; } /** * Check if running in desktop browser */ public isDesktop(): boolean { return this.environmentInfo.device.type === 'desktop'; } /** * Check if running in WebView */ public isWebView(): boolean { return this.getCachedResult('webView', () => { const userAgent = navigator.userAgent.toLowerCase(); // Check for common WebView indicators return userAgent.includes('wv') || // Android WebView userAgent.includes('webview') || (window as any).AndroidInterface !== undefined || // Android app (window as any).webkit?.messageHandlers !== undefined || // iOS WKWebView userAgent.includes('fban') || // Facebook app userAgent.includes('fbav') || // Facebook app userAgent.includes('instagram') || userAgent.includes('twitter'); }); } /** * Check if browser supports modern features */ public supportsModernFeatures(): boolean { return this.getCachedResult('modernFeatures', () => { return 'fetch' in window && 'Promise' in window && 'IntersectionObserver' in window && 'requestIdleCallback' in window; }); } /** * Check if browser supports service workers */ public supportsServiceWorkers(): boolean { return this.getCachedResult('serviceWorkers', () => { return 'serviceWorker' in navigator; }); } /** * Check if browser supports local storage */ public supportsLocalStorage(): boolean { return this.getCachedResult('localStorage', () => { try { const test = 'tinytapanalytics_test'; localStorage.setItem(test, test); localStorage.removeItem(test); return true; } catch { return false; } }); } /** * Check if running in private/incognito mode */ public async isPrivateMode(): Promise<boolean> { return new Promise((resolve) => { // Test for Safari private mode if ('webkitRequestFileSystem' in window) { (window as any).webkitRequestFileSystem( 0, 1, () => resolve(false), () => resolve(true) ); return; } // Test for Chrome incognito if ('webkitTemporaryStorage' in navigator) { (navigator as any).webkitTemporaryStorage.queryUsageAndQuota( () => resolve(false), () => resolve(true) ); return; } // Test for Firefox private mode if ('MozAppearance' in document.documentElement.style) { const db = indexedDB.open('test'); db.onerror = () => resolve(true); db.onsuccess = () => resolve(false); return; } // Fallback test using localStorage quota try { if (localStorage.length === 0) { localStorage.setItem('private_test', '1'); if (localStorage.getItem('private_test') === '1') { localStorage.removeItem('private_test'); resolve(false); } else { resolve(true); } } else { resolve(false); } } catch { resolve(true); } }); } /** * Get viewport dimensions */ public getViewportDimensions(): { width: number; height: number } { return { width: window.innerWidth || document.documentElement.clientWidth, height: window.innerHeight || document.documentElement.clientHeight }; } /** * Get screen dimensions */ public getScreenDimensions(): { width: number; height: number } { return { width: screen.width, height: screen.height }; } /** * Get pixel density */ public getPixelDensity(): number { return window.devicePixelRatio || 1; } /** * Detect complete environment */ private detectEnvironment(): EnvironmentInfo { return { framework: this.detectFramework(), isSPA: this.detectSPA(), isSSR: this.detectSSR(), platform: this.detectPlatform(), browser: this.detectBrowser(), device: this.detectDevice(), network: this.detectNetwork() }; } /** * Detect JavaScript framework */ private detectFramework(): 'react' | 'vue' | 'angular' | 'vanilla' | undefined { return this.getCachedResult('framework', () => { // Check for React if ((window as any).React || document.querySelector('[data-reactroot]') || document.querySelector('[data-react-helmet]') || Array.from(document.querySelectorAll('*')).some(el => Object.keys(el).some(key => key.startsWith('__react')) )) { return 'react'; } // Check for Vue if ((window as any).Vue || document.querySelector('[data-v-]') || document.querySelector('[v-cloak]') || document.querySelector('.v-enter') || Array.from(document.querySelectorAll('*')).some(el => Array.from(el.attributes).some(attr => attr.name.startsWith('v-')) )) { return 'vue'; } // Check for Angular if ((window as any).ng || (window as any).angular || document.querySelector('[ng-app]') || document.querySelector('[ng-controller]') || document.querySelector('[ng-if]') || document.querySelector('ng-component') || Array.from(document.querySelectorAll('*')).some(el => el.tagName.includes('-') && el.tagName.includes('APP') )) { return 'angular'; } return 'vanilla'; }); } /** * Detect if running as Single Page Application */ private detectSPA(): boolean { return this.getCachedResult('spa', () => { // Check for common SPA indicators const hasPushState = 'pushState' in history; const hasHashRouting = window.location.hash.includes('#/'); const hasRouterMeta = !!document.querySelector('meta[name="router"]'); const hasFramework = this.detectFramework() !== 'vanilla'; // Check for dynamic content loading const hasAsyncModules = Array.from(document.scripts).some(script => script.type === 'module' || script.src.includes('chunk') || script.src.includes('bundle') ); return hasPushState && (hasHashRouting || hasRouterMeta || hasFramework || hasAsyncModules); }); } /** * Detect if Server-Side Rendered */ private detectSSR(): boolean { return this.getCachedResult('ssr', () => { // Check for SSR indicators const hasSSRMeta = document.querySelector('meta[name="generator"]') || document.querySelector('meta[name="rendered-by"]'); const hasNextJS = document.querySelector('#__next') || document.querySelector('script[src*="/_next/"]'); const hasNuxt = document.querySelector('#__nuxt') || document.querySelector('script[src*="/_nuxt/"]'); const hasSSRContent = document.documentElement.innerHTML.includes('data-ssr') || document.documentElement.innerHTML.includes('server-rendered'); return !!(hasSSRMeta || hasNextJS || hasNuxt || hasSSRContent); }); } /** * Detect platform type */ private detectPlatform(): 'web' | 'mobile-web' | 'amp' | 'unknown' { return this.getCachedResult('platform', () => { // Check for AMP if (document.querySelector('html[amp]') || document.querySelector('html[⚡]') || document.querySelector('script[src*="ampproject.org"]')) { return 'amp'; } // Check for mobile web by directly checking userAgent // (avoid calling this.isMobile() to prevent circular dependency) const userAgent = navigator.userAgent.toLowerCase(); const screenSize = this.getScreenDimensions(); const isMobileDevice = userAgent.includes('mobile') || userAgent.includes('iphone') || userAgent.includes('ipod') || screenSize.width <= 768; const isTabletDevice = userAgent.includes('tablet') || userAgent.includes('ipad') || (userAgent.includes('android') && !userAgent.includes('mobile')); if (isMobileDevice || isTabletDevice) { return 'mobile-web'; } // Default to web return 'web'; }); } /** * Detect browser information */ private detectBrowser(): EnvironmentInfo['browser'] { return this.getCachedResult('browser', () => { const userAgent = navigator.userAgent.toLowerCase(); let name = 'unknown'; let version = 'unknown'; let engine = 'unknown'; // Detect browser if (userAgent.includes('edg/')) { name = 'edge'; version = this.extractVersion(userAgent, 'edg/'); engine = 'blink'; } else if (userAgent.includes('chrome/')) { name = 'chrome'; version = this.extractVersion(userAgent, 'chrome/'); engine = 'blink'; } else if (userAgent.includes('firefox/')) { name = 'firefox'; version = this.extractVersion(userAgent, 'firefox/'); engine = 'gecko'; } else if (userAgent.includes('safari/') && !userAgent.includes('chrome')) { name = 'safari'; version = this.extractVersion(userAgent, 'version/'); engine = 'webkit'; } else if (userAgent.includes('opera/') || userAgent.includes('opr/')) { name = 'opera'; version = this.extractVersion(userAgent, userAgent.includes('opr/') ? 'opr/' : 'opera/'); engine = 'blink'; } return { name, version, engine }; }); } /** * Detect device information */ private detectDevice(): EnvironmentInfo['device'] { return this.getCachedResult('device', () => { const userAgent = navigator.userAgent.toLowerCase(); const screenSize = this.getScreenDimensions(); let type: 'desktop' | 'mobile' | 'tablet' = 'desktop'; let os = 'unknown'; // Detect device type if (userAgent.includes('tablet') || userAgent.includes('ipad') || (userAgent.includes('android') && !userAgent.includes('mobile'))) { type = 'tablet'; } else if (userAgent.includes('mobile') || userAgent.includes('iphone') || userAgent.includes('ipod') || screenSize.width <= 768) { type = 'mobile'; } // Detect OS (check mobile OS first since they contain desktop OS strings) if (userAgent.includes('iphone') || userAgent.includes('ipad') || userAgent.includes('ipod')) { os = 'ios'; } else if (userAgent.includes('android')) { os = 'android'; } else if (userAgent.includes('windows')) { os = 'windows'; } else if (userAgent.includes('macintosh') || userAgent.includes('mac os')) { os = 'macos'; } else if (userAgent.includes('linux')) { os = 'linux'; } return { type, os, screenSize }; }); } /** * Detect network information */ private detectNetwork(): EnvironmentInfo['network'] | undefined { const connection = (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection; if (!connection) { return undefined; } return { effectiveType: connection.effectiveType, downlink: connection.downlink, rtt: connection.rtt }; } /** * Extract version from user agent string */ private extractVersion(userAgent: string, pattern: string): string { const match = userAgent.match(new RegExp(pattern + '([\\d\\.]+)')); return match ? match[1] : 'unknown'; } /** * Cache detection results */ private getCachedResult<T>(key: string, detector: () => T): T { if (this.detectionCache.has(key)) { return this.detectionCache.get(key); } const result = detector(); this.detectionCache.set(key, result); return result; } /** * Update environment info (useful for dynamic changes) */ public updateEnvironmentInfo(): void { this.detectionCache.clear(); this.environmentInfo = this.detectEnvironment(); } /** * Get performance timing information */ public getPerformanceTiming(): Record<string, number> | null { if (!window.performance || !window.performance.timing) { return null; } const timing = window.performance.timing; return { navigationStart: timing.navigationStart, domContentLoaded: timing.domContentLoadedEventEnd - timing.navigationStart, loadComplete: timing.loadEventEnd - timing.navigationStart, domInteractive: timing.domInteractive - timing.navigationStart, firstPaint: this.getFirstPaintTime(), firstContentfulPaint: this.getFirstContentfulPaintTime() }; } /** * Get First Paint time */ private getFirstPaintTime(): number { if (window.performance && window.performance.getEntriesByType) { const paintEntries = window.performance.getEntriesByType('paint'); const firstPaint = paintEntries.find(entry => entry.name === 'first-paint'); return firstPaint ? firstPaint.startTime : 0; } return 0; } /** * Get First Contentful Paint time */ private getFirstContentfulPaintTime(): number { if (window.performance && window.performance.getEntriesByType) { const paintEntries = window.performance.getEntriesByType('paint'); const firstContentfulPaint = paintEntries.find(entry => entry.name === 'first-contentful-paint'); return firstContentfulPaint ? firstContentfulPaint.startTime : 0; } return 0; } }