/** * ThumbmarkJS: Main fingerprinting and API logic * * This module handles component collection, API calls, uniqueness scoring, and data filtering * for the ThumbmarkJS browser fingerprinting library. * */ import { defaultOptions, OptionsAfterDefaults, optionsInterface } from "../options"; import { timeoutInstance, componentInterface, componentFunctionInterface, tm_component_promises, customComponents, tm_experimental_component_promises, includeComponent as globalIncludeComponent } from "../factory"; import { hash } from "../utils/hash"; import { raceAllPerformance } from "../utils/raceAll"; import { getVersion } from "../utils/version"; import { filterThumbmarkData, getExcludeList } from './filterComponents' import { logThumbmarkData } from '../utils/log'; import { getApiPromise, ApiError, infoInterface } from "./api"; import { stableStringify } from "../utils/stableStringify"; /** * Final thumbmark response structure */ export interface ThumbmarkError { type: 'component_timeout' | 'component_error' | 'api_timeout' | 'api_error' | 'api_unauthorized' | 'network_error' | 'fatal'; message: string; component?: string; } export interface ThumbmarkResponse { /** Hash of all components - the main fingerprint identifier */ thumbmark: string; /** All resolved fingerprint components */ components: componentInterface; /** Information from the API (IP, classification, uniqueness score) */ info: infoInterface; /** Library version */ version: string; /** Persistent visitor identifier (requires API key) */ visitorId?: string; /** Performance timing for each component (only when options.performance is true) */ elapsed?: Record; /** Structured error array. Present only when errors occurred. */ error?: ThumbmarkError[]; /** Experimental components (only when options.experimental is true) */ experimental?: componentInterface; /** Unique identifier for this API request */ requestId?: string; /** Metadata echoed back from the API */ metadata?: string | object; } /** * Main entry point: collects all components, optionally calls API, and returns thumbmark data. * * @param options - Options for fingerprinting and API * @returns ThumbmarkResponse (elapsed is present only if options.performance is true) */ export async function getThumbmark( options?: optionsInterface, instanceCustomComponents: Record = {} ): Promise { // Early exit for non-browser environments (Node.js, Jest, SSR) if (typeof document === 'undefined' || typeof window === 'undefined') { return { thumbmark: '', components: {}, info: {}, version: getVersion(), error: [{ type: 'fatal', message: 'Browser environment required' }] }; } try { const _options = { ...defaultOptions, ...options } as OptionsAfterDefaults; const allErrors: ThumbmarkError[] = []; // Early logging decision const shouldLog = (_options.logging && !sessionStorage.getItem("_tmjs_l") && Math.random() < 0.0001); // Merge built-in and user-registered components const allComponents = { ...tm_component_promises, ...customComponents, ...instanceCustomComponents, } as Record; const { elapsed, resolvedComponents: clientComponentsResult, errors: componentErrors, pipelineTimings: mainPipelineTimings } = await resolveClientComponents(allComponents, _options); allErrors.push(...componentErrors); // Resolve experimental components only when logging let experimentalComponents = {}; let experimentalElapsed = {}; let expPipelineTimings: Record = {}; if (shouldLog || _options.experimental) { const { elapsed: expElapsed, resolvedComponents, errors: expErrors, pipelineTimings: expTimings } = await resolveClientComponents(tm_experimental_component_promises, _options); experimentalComponents = resolvedComponents; experimentalElapsed = expElapsed; expPipelineTimings = expTimings; allErrors.push(...expErrors); } const apiPromise = _options.api_key ? getApiPromise(_options, clientComponentsResult) : null; let apiResult = null; if (apiPromise) { try { apiResult = await apiPromise; } catch (error) { if (error instanceof ApiError && error.status === 403) { return { error: [{ type: 'api_unauthorized', message: 'Invalid API key or quota exceeded' }], components: {}, info: {}, version: getVersion(), thumbmark: '' }; } allErrors.push({ type: error instanceof ApiError ? 'api_error' : 'network_error', message: error instanceof Error ? error.message : String(error) }); } } // Surface API timeout as a structured error if (apiResult?.info?.timed_out) { allErrors.push({ type: 'api_timeout', message: 'API request timed out' }); } const filterStart = performance.now(); const apiComponents = filterThumbmarkData(apiResult?.components || {}, _options); const filterMs = performance.now() - filterStart; const components = { ...clientComponentsResult, ...apiComponents }; const info: infoInterface = apiResult?.info || { uniqueness: { score: 'api only' } }; // Use API thumbmark if available to ensure API/client sync, otherwise calculate locally let thumbmark: string; let stringifyMs = 0; let hashMs = 0; if (apiResult?.thumbmark) { thumbmark = apiResult.thumbmark; } else { const stringifyStart = performance.now(); const stringified = stableStringify(components); stringifyMs = performance.now() - stringifyStart; const hashStart = performance.now(); thumbmark = hash(stringified); hashMs = performance.now() - hashStart; } const version = getVersion(); // Only log to server when not in debug mode if (shouldLog) { logThumbmarkData(thumbmark, components, _options, experimentalComponents, allErrors).catch(() => { /* do nothing */ }); } // Accumulate _pipeline timings from both the main and (if run) experimental component phases. // Filter time includes: main component filter + optional experimental filter + apiComponents filter. const expFilterMs = expPipelineTimings['_pipeline.filter'] ?? 0; const _pipelineTimings: Record = { '_pipeline.dispatch': mainPipelineTimings['_pipeline.dispatch'], '_pipeline.resolve': mainPipelineTimings['_pipeline.resolve'], '_pipeline.filter': mainPipelineTimings['_pipeline.filter'] + expFilterMs + filterMs, '_pipeline.stringify': stringifyMs, '_pipeline.hash': hashMs, '_pipeline.assembly': 0, // placeholder, updated below after result construction }; // Only add 'elapsed' if performance is true // allElapsed holds a live reference to _pipelineTimings entries via spread — we update assembly after. // mainPipelineTimings contains both _pipeline.* keys (overridden by _pipelineTimings below) and // _dispatch. keys (per-component sync prelude timings) that flow through unchanged. const allElapsed: Record = { ...elapsed, ...experimentalElapsed, ...mainPipelineTimings, ..._pipelineTimings }; const maybeElapsed = _options.performance ? { elapsed: allElapsed } : {}; const assemblyStart = performance.now(); const result: ThumbmarkResponse = { ...(apiResult?.visitorId && { visitorId: apiResult.visitorId }), thumbmark, components: components, info, version, ...maybeElapsed, ...(allErrors.length > 0 && { error: allErrors }), ...(Object.keys(experimentalComponents).length > 0 && _options.experimental && { experimental: experimentalComponents }), ...(apiResult?.requestId && { requestId: apiResult.requestId }), ...(apiResult?.metadata && { metadata: apiResult.metadata }), }; // Update assembly timing in allElapsed directly (allElapsed is the same object referenced by result.elapsed). allElapsed['_pipeline.assembly'] = performance.now() - assemblyStart; return result; } catch (e) { return { thumbmark: '', components: {}, info: {}, version: getVersion(), error: [{ type: 'fatal', message: e instanceof Error ? e.message : String(e) }], }; } } // ===================== Component Resolution & Performance ===================== /** * Resolves and times all filtered component promises from a component function map. * * @param comps - Map of component functions * @param options - Options for filtering and timing * @returns Object with elapsed times, filtered resolved components, errors, and pipeline phase timings */ export async function resolveClientComponents( comps: { [key: string]: (options?: optionsInterface) => Promise }, options?: optionsInterface ): Promise<{ elapsed: Record, resolvedComponents: componentInterface, errors: ThumbmarkError[], pipelineTimings: Record }> { const opts = { ...defaultOptions, ...options }; const topLevelExcludes = getExcludeList(opts).filter(e => !e.includes('.')); const filtered = Object.entries(comps) .filter(([key]) => !opts?.exclude?.includes(key)) .filter(([key]) => !topLevelExcludes.includes(key)) .filter(([key]) => opts?.include?.some(e => e.includes('.')) ? opts?.include?.some(e => e.startsWith(key)) : opts?.include?.length === 0 || opts?.include?.includes(key) ); const keys = filtered.map(([key]) => key); const perComponentDispatch: Record = {}; const dispatchStart = performance.now(); const promises = filtered.map(([key, fn]) => { const t0 = performance.now(); const p = fn(options); perComponentDispatch[`_dispatch.${key}`] = performance.now() - t0; return p; }); const dispatchMs = performance.now() - dispatchStart; const resolveStart = performance.now(); const resolvedValues = await raceAllPerformance(promises, opts?.timeout || 5000, timeoutInstance); const resolveMs = performance.now() - resolveStart; const elapsed: Record = {}; const resolvedComponentsRaw: Record = {}; const errors: ThumbmarkError[] = []; resolvedValues.forEach((result, index) => { const key = keys[index]; elapsed[key] = result.elapsed ?? 0; if (result.error === 'timeout') { errors.push({ type: 'component_timeout', message: `Component '${key}' timed out`, component: key }); } else if (result.error) { errors.push({ type: 'component_error', message: result.error, component: key }); } if (result.value != null) { resolvedComponentsRaw[key] = result.value; } }); const filterStart = performance.now(); const resolvedComponents = filterThumbmarkData(resolvedComponentsRaw, opts); const filterMs = performance.now() - filterStart; const pipelineTimings: Record = { '_pipeline.dispatch': dispatchMs, '_pipeline.resolve': resolveMs, '_pipeline.filter': filterMs, ...perComponentDispatch, }; return { elapsed, resolvedComponents, errors, pipelineTimings }; } export { globalIncludeComponent as includeComponent };