import { MPID } from '@mparticle/web-sdk'; import Constants from './constants'; import { SDKInitConfig } from './sdkRuntimeModels'; import { IKitConfigs } from './configAPIClient'; const { Messages } = Constants; type valueof = T[keyof T]; type AttributeValue = string | number | boolean | null | undefined; // Placeholder for Dictionary-like Types export type Dictionary = Record; export type Environment = valueof; const createCookieString = (value: string): string => replaceCommasWithPipes(replaceQuotesWithApostrophes(value)); const revertCookieString = (value: string): string => replacePipesWithCommas(replaceApostrophesWithQuotes(value)); const inArray = (items: any[], name: any): boolean => { if (!items) { return false; } let i = 0; if (Array.prototype.indexOf) { return items.indexOf(name, 0) >= 0; } else { for (var n = items.length; i < n; i++) { if (i in items && items[i] === name) { return true; } } } return false; }; const findKeyInObject = (obj: any, key: string): string => { if (key && obj) { for (var prop in obj) { if ( obj.hasOwnProperty(prop) && prop.toLowerCase() === key.toLowerCase() ) { return prop; } } } return null; }; const generateDeprecationMessage = ( methodName: string, isDeprecated?: boolean, alternateMethod?: string, docsUrl?: string, ): string => { const messageArray: string[] = [methodName]; if (isDeprecated) { messageArray.push(Messages.DeprecationMessages.MethodHasBeenDeprecated); } else { messageArray.push(Messages.DeprecationMessages.MethodMarkedForDeprecationPostfix); } if (alternateMethod) { messageArray.push(Messages.DeprecationMessages.AlternativeMethodPrefix); messageArray.push(alternateMethod + "."); } if (docsUrl) { messageArray.push("See - " + docsUrl); } return messageArray.join(' '); }; function generateHash(name: string): number { let hash: number = 0; let character: number; if (name === undefined || name === null) { return 0; } name = name.toString().toLowerCase(); if (Array.prototype.reduce) { return name.split('').reduce(function(a: number, b: string) { a = (a << 5) - a + b.charCodeAt(0); return a & a; }, 0); } if (name.length === 0) { return hash; } for (let i = 0; i < name.length; i++) { character = name.charCodeAt(i); hash = (hash << 5) - hash + character; hash = hash & hash; } return hash; } const generateRandomValue = (value?: string): string => { let randomValue: string; let a: number; if (window.crypto && window.crypto.getRandomValues) { // @ts-ignore randomValue = window.crypto.getRandomValues(new Uint8Array(1)); // eslint-disable-line no-undef } if (randomValue) { // @ts-ignore return (a ^ (randomValue[0] % 16 >> (a / 4))).toString(16); } return (a ^ ((Math.random() * 16) >> (a / 4))).toString(16); }; const generateUniqueId = (a: string = ''): string => // https://gist.github.com/jed/982883 // Added support for crypto for better random a // if the placeholder was passed, return ? generateRandomValue(a) // if the placeholder was passed, return : // [1e7] -> // 10000000 + // -1e3 -> // -1000 + // -4e3 -> // -4000 + // -8e3 -> // -80000000 + // -1e11 -> //-100000000000, `${1e7}-${1e3}-${4e3}-${8e3}-${1e11}`.replace( /[018]/g, // zeroes, ones, and eights with generateUniqueId // random hex digits ); /** * Returns a value between 1-100 inclusive. */ const getRampNumber = (value?: string): number => { if (!value) { return 100; } const hash = generateHash(value); return Math.abs(hash % 100) + 1; }; const isObject = (value: any): boolean => { var objType = Object.prototype.toString.call(value); return objType === '[object Object]' || objType === '[object Error]'; }; const parseNumber = (value: string | number): number => { if (isNaN(value as number) || !isFinite(value as number)) { return 0; } var floatValue = parseFloat(value as string); return isNaN(floatValue) ? 0 : floatValue; }; const parseSettingsString = (settingsString: string): Dictionary[] => { try { return settingsString ? JSON.parse(settingsString.replace(/"/g, '"')) : []; } catch (error) { throw new Error('Settings string contains invalid JSON'); } }; const parseStringOrNumber = ( value: string | number ): string | number | null => { if (isStringOrNumber(value)) { return value; } else { return null; } }; const replaceCommasWithPipes = (value: string): string => value.replace(/,/g, '|'); const replacePipesWithCommas = (value: string): string => value.replace(/\|/g, ','); const replaceApostrophesWithQuotes = (value: string): string => value.replace(/\'/g, '"'); const replaceQuotesWithApostrophes = (value: string): string => value.replace(/\"/g, "'"); const replaceMPID = (value: string, mpid: MPID): string => value.replace('%%mpid%%', mpid); const replaceAmpWithAmpersand = (value: string): string => value.replace(/&/g, '&'); const createCookieSyncUrl = ( mpid: MPID, pixelUrl: string, redirectUrl?: string, domain?: string, base64Mpid?: string ): string => { const modifiedPixelUrl = replaceAmpWithAmpersand(pixelUrl); const modifiedDirectUrl = redirectUrl ? replaceAmpWithAmpersand(redirectUrl) : null; let url = replaceMPID(modifiedPixelUrl, mpid); const redirect = modifiedDirectUrl ? replaceMPID(modifiedDirectUrl, mpid) : ''; let fullUrl = url + encodeURIComponent(redirect); if (domain) { const separator = fullUrl.includes('?') ? '&' : '?'; fullUrl += `${separator}domain=${domain}`; } if (base64Mpid) { const separator = fullUrl.includes('?') ? '&' : '?'; fullUrl += `${separator}google_hm=${base64Mpid}`; } return fullUrl; }; // Google's cookie matching service requires `google_hm` values to be web-safe // base64 (RFC 4648 ยง5) without padding, otherwise the write to the hosted // match table fails: https://developers.google.com/authorized-buyers/rtb/cookie-guide // (String#replaceAll is not available in our ES5 browser targets.) const WEB_SAFE_BASE64_REPLACEMENTS: Dictionary = { '+': '-', '/': '_', '=': '', }; const toWebSafeBase64 = (value: string): string => btoa(value).replace(/[+/=]/g, c => WEB_SAFE_BASE64_REPLACEMENTS[c]); // FIXME: REFACTOR for V3 // only used in store.js to sanitize server-side formatting of // booleans when checking for `isDevelopmentMode` // Should be removed in v3 const returnConvertedBoolean = (data: string | boolean | number) => { if (data === 'false' || data === '0') { return false; } else { return Boolean(data); } }; const decoded = (s: string): string => decodeURIComponent(s.replace(/\+/g, ' ')); const converted = (s: string): string => { if (s.indexOf('"') === 0) { s = s .slice(1, -1) .replace(/\\"/g, '"') .replace(/\\\\/g, '\\'); } return s; }; const isString = (value: any): boolean => typeof value === 'string'; const isNumber = (value: any): boolean => typeof value === 'number'; const isBoolean = (value: any): boolean => typeof value === 'boolean'; const isFunction = (fn: any): boolean => typeof fn === 'function'; const isValidAttributeValue = (value: any): boolean => { return value !== undefined && !isObject(value) && !Array.isArray(value); } const isValidCustomFlagProperty = (value: any): boolean => isNumber(value) || isString(value) || isBoolean(value); const toDataPlanSlug = (value: any): string => // Make sure we are only acting on strings or numbers isStringOrNumber(value) ? value .toString() .toLowerCase() .replace(/[^0-9a-zA-Z]+/g, '_') : ''; const isDataPlanSlug = (str: string): boolean => str === toDataPlanSlug(str); const isStringOrNumber = (value: any): boolean => isString(value) || isNumber(value); const isEmpty = (value: Dictionary | string | null | undefined): boolean => value == null || !(Object.keys(value) || value).length; const mergeObjects = (...objects: T[]): T => { return Object.assign({}, ...objects); }; const moveElementToEnd = (array: T[], index: number): T[] => array.slice(0, index).concat(array.slice(index + 1), array[index]); const queryStringParser = ( url: string, keys: string[] = [] ): Dictionary => { let urlParams: URLSearchParams | URLSearchParamsFallback; let results: Dictionary = {}; let lowerCaseUrlParams: Dictionary = {}; if (!url) return results; if (typeof URL !== 'undefined' && typeof URLSearchParams !== 'undefined') { const urlObject = new URL(url); urlParams = new URLSearchParams(urlObject.search); } else { urlParams = queryStringParserFallback(url); } urlParams.forEach((value, key) => { lowerCaseUrlParams[key.toLowerCase()] = value; }); if (isEmpty(keys)) { return lowerCaseUrlParams; } else { keys.forEach(key => { const value = lowerCaseUrlParams[key.toLowerCase()]; if (value) { results[key] = value; } }); } return results; }; interface URLSearchParamsFallback { get: (key: string) => string | null; forEach: (callback: (value: string, key: string) => void) => void; } const queryStringParserFallback = (url: string): URLSearchParamsFallback => { const params: Dictionary = {}; const queryString = url.split('?')[1] || ''; const pairs = queryString.split('&'); pairs.forEach(pair => { const [key, ...valueParts] = pair.split('='); const value = valueParts.join('='); if (key && value !== undefined) { try { params[key] = decodeURIComponent(value || ''); } catch (e) { console.error(`Failed to decode value for key ${key}: ${e}`); } } }); return { get: function(key: string) { return params[key]; }, forEach: function(callback: (value: string, key: string) => void) { for (var key in params) { if (params.hasOwnProperty(key)) { callback(params[key], key); } } }, }; }; // Get cookies as a dictionary const getCookies = (keys?: string[]): Dictionary => { // Helper function to parse cookies from document.cookie const parseCookies = (): string[] => { try { if (typeof window === 'undefined') { return []; } return window.document.cookie.split(';').map(cookie => cookie.trim()); } catch (e) { console.error('Unable to parse cookies', e); return []; } }; // Helper function to filter cookies by keys const filterCookies = ( cookies: string[], keys?: string[] ): Dictionary => { const results: Dictionary = {}; for (const cookie of cookies) { const [key, value] = cookie.split('='); if (!keys || keys.includes(key)) { results[key] = value; } } return results; }; // Parse cookies from document.cookie const parsedCookies: string[] = parseCookies(); // Filter cookies by keys if provided return filterCookies(parsedCookies, keys); }; const getHref = (): string => { return typeof window !== 'undefined' && window.location ? window.location.href : ''; }; const filterDictionaryWithHash = ( dictionary: Dictionary, filterList: any[], hashFn: (key: string) => any ): Dictionary => { const filtered = {}; if (!isEmpty(dictionary)) { for (const key in dictionary) { if (dictionary.hasOwnProperty(key)) { const hashedKey = hashFn(key); if (!inArray(filterList, hashedKey)) { filtered[key] = dictionary[key]; } } } } return filtered; } const parseConfig = (config: SDKInitConfig, moduleName: string, moduleId: number): IKitConfigs | null => { return config.kitConfigs?.find((kitConfig: IKitConfigs) => kitConfig.name === moduleName && kitConfig.moduleId === moduleId ) || null; } /** * Obfuscates an object by replacing all primitive values with their type names, * while preserving the structure of nested objects and arrays. * This is useful for logging data structures without exposing PII. * * @param value - The value to obfuscate * @returns The obfuscated value with types instead of actual values * * @example * obfuscateData({ email: 'user@email.com', age: 30 }) * // Returns: { email: 'string', age: 'number' } * * obfuscateData({ tags: ['premium', 'verified'] }) * // Returns: { tags: ['string', 'string'] } */ const obfuscateData = (value: any): any => { // Preserve null and undefined if (value === null || value === undefined) { return value; } // Handle arrays - recursively obfuscate each element if (Array.isArray(value)) { return value.map(item => obfuscateData(item)); } // Handle objects - recursively obfuscate each property if (isObject(value)) { const obfuscated: Dictionary = {}; for (const key in value) { if (value.hasOwnProperty(key)) { obfuscated[key] = obfuscateData(value[key]); } } return obfuscated; } // For primitives and other types, return the type as a string return typeof value; }; /** * For verbose logging: returns raw data when isDevelopmentMode is true, else obfuscated data. * Used when logging payloads so PII is not exposed in production. * * @param data - The value to log * @param isDevelopmentMode - When true, returns data unchanged, when false or undefined, returns obfuscated data * @returns Raw data when isDevelopmentMode is true, otherwise obfuscated structure * * @example * obfuscateDevData({ email: 'user@email.com' }, true) * // Returns: { email: 'user@email.com' } * * obfuscateDevData({ email: 'user@email.com' }, false) * // Returns: { email: 'string' } */ const obfuscateDevData = (data: any, isDevelopmentMode?: boolean): any => isDevelopmentMode ? data : obfuscateData(data); // Standalone version of jQuery.extend, from https://github.com/dansdom/extend // https://go.mparticle.com/work/SQDSDKS-6047 function extend(...args: any[]): any { const objectHelper = { hasOwn: Object.prototype.hasOwnProperty, class2type: {} as Record, type(obj: any): string { return obj == null ? String(obj) : objectHelper.class2type[ Object.prototype.toString.call(obj) ] || 'object'; }, isPlainObject(obj: any): boolean { if ( !obj || objectHelper.type(obj) !== 'object' || obj.nodeType || objectHelper.isWindow(obj) ) { return false; } try { if ( obj.constructor && !objectHelper.hasOwn.call(obj, 'constructor') && !objectHelper.hasOwn.call( obj.constructor.prototype, 'isPrototypeOf' ) ) { return false; } } catch (e) { return false; } let key; for (key in obj) { } // eslint-disable-line no-empty return ( key === undefined || objectHelper.hasOwn.call(obj, key) ); }, isArray: Array.isArray || function(obj: any): boolean { return objectHelper.type(obj) === 'array'; }, isFunction(obj: any): boolean { return objectHelper.type(obj) === 'function'; }, isWindow(obj: any): boolean { return obj != null && obj == obj.window; }, }; let options: any, name: string, src: any, copy: any, copyIsArray: boolean, clone: any, target = args[0] || {}, i = 1, deep = false; const length = args.length; if (typeof target === 'boolean') { deep = target; target = args[1] || {}; i = 2; } if (typeof target !== 'object' && !objectHelper.isFunction(target)) { target = {}; } if (length === i) { target = {}; --i; } for (; i < length; i++) { if ((options = args[i]) != null) { for (name in options) { if ( name === '__proto__' || name === 'constructor' || name === 'prototype' ) { continue; } if (!Object.prototype.hasOwnProperty.call(options, name)) { continue; } src = target[name]; copy = options[name]; if (target === copy) { continue; } if ( deep && copy && (objectHelper.isPlainObject(copy) || (copyIsArray = objectHelper.isArray(copy))) ) { if (copyIsArray) { copyIsArray = false; clone = src && objectHelper.isArray(src) ? src : []; } else { clone = src && objectHelper.isPlainObject(src) ? src : {}; } target[name] = extend(deep, clone, copy); } else if (copy !== undefined) { target[name] = copy; } } } } return target; } const getErrorMessage = ( e: unknown, fallback = 'unknown error' ): string => { if (e instanceof Error) { return e.message; } if (typeof e === 'string') { return e; } // fetch-mock and some network libs reject with { message } rather than Error if ( e && typeof e === 'object' && typeof (e as { message?: unknown }).message === 'string' ) { return (e as { message: string }).message; } return fallback; }; export { createCookieString, revertCookieString, createCookieSyncUrl, toWebSafeBase64, valueof, AttributeValue, converted, decoded, obfuscateDevData, filterDictionaryWithHash, findKeyInObject, generateDeprecationMessage, generateHash, generateUniqueId, getErrorMessage, getRampNumber, inArray, isObject, isStringOrNumber, obfuscateData, parseConfig, parseNumber, parseSettingsString, parseStringOrNumber, replaceCommasWithPipes, replacePipesWithCommas, replaceApostrophesWithQuotes, replaceQuotesWithApostrophes, returnConvertedBoolean, toDataPlanSlug, isString, isNumber, isFunction, isDataPlanSlug, isEmpty, isValidAttributeValue, isValidCustomFlagProperty, mergeObjects, moveElementToEnd, queryStringParser, getCookies, getHref, replaceMPID, replaceAmpWithAmpersand, extend, };