@trillet-ai/web-sdk
Version:
Trillet Web SDK for real-time audio communication with AI agents
132 lines (131 loc) • 5.15 kB
JavaScript
/**
* Browser Fingerprinting Utility
* Generates a deterministic fingerprint based on stable browser/hardware characteristics.
* Used for demo link usage limit tracking.
*
* Strategy:
* 1. localStorage persistence — generate once, reuse across tabs/sessions forever
* 2. Stable hardware-only seed — if storage is cleared, the same hardware signals
* produce the same hash, so the fingerprint stays consistent
* 3. Server-side IP fallback — handled by the backend when no fingerprint is sent
*/
const STORAGE_KEY = 'trillet_dfp';
export class BrowserFingerprint {
/**
* Generate (or retrieve) a deterministic browser fingerprint.
* Cached in memory → localStorage → derived from stable hardware signals.
*/
static async generate() {
// 1. In-memory cache (fastest)
if (this.cachedFingerprint) {
return this.cachedFingerprint;
}
// 2. Persisted in localStorage (survives tab close / browser restart)
const stored = this.getStored();
if (stored) {
this.cachedFingerprint = stored;
return stored;
}
// 3. Derive from stable hardware signals and persist
const signals = this.getStableSignals();
const fingerprint = await this.hashComponents(signals);
this.persist(fingerprint);
this.cachedFingerprint = fingerprint;
return fingerprint;
}
/**
* Collect only hardware / environment signals that do NOT change between
* tabs, sessions, or browser restarts on the same device.
*
* Deliberately excluded (volatile / anti-fingerprint randomised):
* - canvas.toDataURL() — randomised per origin in many browsers
* - font detection — noisy across rendering states
* - screen.availWidth/Height — changes with taskbar / dock resize
* - navigator.doNotTrack — user can toggle
* - timezoneOffset — changes with DST
* - navigator.userAgent — changes on browser update
*/
static getStableSignals() {
return {
// Hardware — fixed for a given device
screen: {
width: window.screen.width,
height: window.screen.height,
colorDepth: window.screen.colorDepth,
pixelRatio: window.devicePixelRatio,
},
hardwareConcurrency: navigator.hardwareConcurrency,
deviceMemory: navigator.deviceMemory,
platform: navigator.platform,
maxTouchPoints: navigator.maxTouchPoints,
// WebGL vendor/renderer — GPU-based, stable
webgl: this.getWebGLFingerprint(),
// Locale — stable unless user changes OS language or moves timezone
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
language: navigator.language,
};
}
/**
* WebGL vendor + renderer string (GPU identifier, very stable)
*/
static getWebGLFingerprint() {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl)
return 'no-webgl';
const glContext = gl;
const debugInfo = glContext.getExtension('WEBGL_debug_renderer_info');
const vendor = debugInfo
? glContext.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL)
: glContext.getParameter(glContext.VENDOR);
const renderer = debugInfo
? glContext.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL)
: glContext.getParameter(glContext.RENDERER);
return `${vendor}~${renderer}`;
}
catch (_a) {
return 'webgl-error';
}
}
/**
* SHA-256 hash of the signals object → hex string
*/
static async hashComponents(components) {
const data = new TextEncoder().encode(JSON.stringify(components));
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// ── localStorage helpers ───────────────────────────────────────────
static getStored() {
try {
return localStorage.getItem(STORAGE_KEY);
}
catch (_a) {
return null;
}
}
static persist(fingerprint) {
try {
localStorage.setItem(STORAGE_KEY, fingerprint);
}
catch (_a) {
// Storage full or disabled — fingerprint still works in-memory
}
}
/**
* Clear both in-memory and persisted fingerprint (useful for testing)
*/
static clearCache() {
this.cachedFingerprint = null;
try {
localStorage.removeItem(STORAGE_KEY);
}
catch (_a) {
// Ignore
}
}
}
BrowserFingerprint.cachedFingerprint = null;