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