@thumbmarkjs/thumbmarkjs
Version:
   • 7.19 kB
text/typescript
import { optionsInterface, DEFAULT_API_ENDPOINT, OptionsAfterDefaults } from '../options';
import { componentInterface } from '../factory';
import { getVisitorId, setVisitorId } from '../utils/visitorId';
import { getVersion } from "../utils/version";
import { hash } from '../utils/hash';
import { stableStringify } from '../utils/stableStringify';
import { getCache, getApiResponseExpiry, setCache } from "../utils/cache";
// ===================== Types & Interfaces =====================
/**
* Info returned from the API (IP, classification, uniqueness, etc)
*/
export interface infoInterface {
ip_address?: {
ip_address: string,
ip_identifier: string,
autonomous_system_number: number,
ip_version: 'v6' | 'v4',
},
classification?: {
tor: boolean,
vpn: boolean,
bot: boolean,
datacenter: boolean,
danger_level: number, // 5 is highest and should be blocked. 0 is no danger.
},
uniqueness?: {
score: number | string
},
country?: {
iso_code: string,
name: string,
continent: {
code: string,
name: string,
},
},
visitor?: {
id: string,
isNew: boolean,
firstSeen: string,
lastSeen: string,
},
signals?: {
timezone_country_mismatch?: boolean,
},
timed_out?: boolean; // added for timeout handling
}
/**
* API response structure
*/
export interface apiResponse {
info?: infoInterface;
version?: string;
components?: componentInterface;
visitorId?: string;
thumbmark?: string;
requestId?: string;
metadata?: string | object;
}
// ===================== API Call Logic =====================
export class ApiError extends Error {
constructor(public status: number) {
super(`HTTP error! status: ${status}`);
}
}
let currentApiPromise: Promise<apiResponse> | null = null;
let apiPromiseResult: apiResponse | null = null;
const MAX_RETRIES = 3;
const RETRY_BACKOFF_MS = 200;
/**
* Calls the API endpoint once. Returns the response data on success.
* Throws ApiError on HTTP errors, or a native error on network failures.
*/
async function callApi(
endpoint: string, body: any, options: OptionsAfterDefaults, visitorId: string | null,
): Promise<apiResponse> {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'x-api-key': options.api_key!,
'Authorization': 'custom-authorized',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) throw new ApiError(response.status);
const data = await response.json();
if (data.visitorId && data.visitorId !== visitorId) setVisitorId(data.visitorId, options);
apiPromiseResult = data;
setCachedApiResponse(options, data);
return data;
}
/**
* Calls callApi with retries on network errors.
* HTTP errors (ApiError) are not retried — only network failures.
*/
async function callApiWithRetry(
endpoint: string, body: any, options: OptionsAfterDefaults, visitorId: string | null,
): Promise<apiResponse> {
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
if (attempt > 0) await new Promise(r => setTimeout(r, attempt * RETRY_BACKOFF_MS));
try {
return await callApi(endpoint, body, options, visitorId);
} catch (error) {
if (error instanceof ApiError || attempt === MAX_RETRIES - 1) throw error;
}
}
throw new Error('Unreachable');
}
/**
* Calls the Thumbmark API with the given components, using caching and deduplication.
* Returns a promise for the API response or null on error.
*/
export const getApiPromise = (
options: OptionsAfterDefaults,
components: componentInterface
): Promise<apiResponse | null> => {
// 1. If a result is already cached and caching is enabled, return it.
if (options.cache_api_call) {
// Check the in-memory cache
if (apiPromiseResult) {
return Promise.resolve(apiPromiseResult);
}
// Check the localStorage cache
const cached = getCachedApiResponse(options);
if (cached) {
return Promise.resolve(cached);
}
// 2. If a request is already in flight, return that promise to prevent duplicate calls.
// Moved inside the cache_api_call check to avoid holding onto promises when caching is disabled.
if (currentApiPromise) {
return currentApiPromise;
}
}
// 3. Otherwise, initiate a new API call with timeout.
const apiEndpoint = options.api_endpoint || DEFAULT_API_ENDPOINT;
const endpoint = `${apiEndpoint}/thumbmark`;
const visitorId = getVisitorId(options);
const requestBody: any = {
components,
options,
clientHash: hash(stableStringify(components)),
version: getVersion()
};
if (visitorId) {
requestBody.visitorId = visitorId;
}
// Resolve metadata if it's a function, otherwise use as-is
if (options.metadata) {
const resolvedMetadata = typeof options.metadata === 'function'
? options.metadata()
: options.metadata;
if (resolvedMetadata) {
const metadataLength = typeof resolvedMetadata === 'string'
? resolvedMetadata.length
: JSON.stringify(resolvedMetadata).length;
if (metadataLength > 1000) {
console.error('ThumbmarkJS: Metadata exceeds 1000 characters. Skipping metadata.');
} else {
requestBody.metadata = resolvedMetadata;
}
}
}
const timeoutMs = options.timeout || 5000;
const apiCall = callApiWithRetry(endpoint, requestBody, options, visitorId)
.finally(() => { currentApiPromise = null; });
const timeout = new Promise<apiResponse>((resolve) => {
setTimeout(() => {
const cache = getCache(options);
resolve(cache?.apiResponse || { info: { timed_out: true }, ...(visitorId && { visitorId }) });
}, timeoutMs);
});
currentApiPromise = Promise.race([apiCall, timeout]);
return currentApiPromise;
};
/**
* If a valid cached api response exists, returns it
* @param options
*/
export function getCachedApiResponse(
options: Pick<OptionsAfterDefaults, 'property_name_factory'>,
): apiResponse | undefined {
const cache = getCache(options);
if (cache && cache.apiResponse && cache.apiResponseExpiry && Date.now() <= cache.apiResponseExpiry) {
return cache.apiResponse;
}
return;
}
/**
* Writes the api response to the cache according to the options
* @param options
* @param response
*/
export function setCachedApiResponse(
options: Pick<OptionsAfterDefaults, 'cache_api_call' | 'cache_lifetime_in_ms' | 'property_name_factory'>, response: apiResponse
): void {
if (!options.cache_api_call || !options.cache_lifetime_in_ms) {
return;
}
setCache(options, {
apiResponseExpiry: getApiResponseExpiry(options),
apiResponse: response,
});
}