@mparticle/web-sdk
Version:
mParticle core SDK for web applications
566 lines (486 loc) • 22.5 kB
text/typescript
import { IKitConfigs } from "./configAPIClient";
import { UserAttributeFilters } from "./forwarders.interfaces";
import { IMParticleUser } from "./identity-user-interfaces";
import KitFilterHelper from "./kitFilterHelper";
import {
Dictionary,
parseSettingsString,
generateUniqueId,
isFunction,
AttributeValue,
isEmpty,
} from "./utils";
import { SDKIdentityApi } from "./identity.interfaces";
import { SDKLoggerApi } from "./sdkRuntimeModels";
import { IStore, LocalSessionAttributes } from "./store";
import { UserIdentities } from "@mparticle/web-sdk";
import { IdentityType, PerformanceMarkType } from "./types";
// https://docs.rokt.com/developers/integration-guides/web/library/attributes
export type RoktAttributeValueArray = Array<string | number | boolean>;
export type RoktAttributeValueType = string | number | boolean | undefined | null;
export type RoktAttributeValue = RoktAttributeValueType | RoktAttributeValueArray;
export type RoktAttributes = Record<string, RoktAttributeValue>;
export interface IRoktPartnerExtensionData<T> {
[extensionName: string]: T;
}
// https://docs.rokt.com/developers/integration-guides/web/library/select-placements-options
export interface IRoktSelectPlacementsOptions {
attributes: RoktAttributes;
identifier?: string;
}
interface IRoktPlacement {}
export interface IRoktSelection {
close: () => void;
getPlacements: () => Promise<IRoktPlacement[]>;
}
export interface IRoktLauncher {
selectPlacements: (options: IRoktSelectPlacementsOptions) => Promise<IRoktSelection>;
hashAttributes: (attributes: RoktAttributes) => Promise<Record<string, string>>;
use: <T>(name: string) => Promise<T>;
}
export interface IRoktMessage {
messageId: string;
methodName: string;
payload: any;
resolve?: Function;
reject?: Function;
}
export interface RoktKitFilterSettings {
userAttributeFilters?: UserAttributeFilters;
filterUserAttributes?: (userAttributes: Dictionary<string>, filterList: number[]) => Dictionary<string>;
filteredUser?: IMParticleUser | null;
}
export interface IRoktKit {
filters: RoktKitFilterSettings;
filteredUser: IMParticleUser | null;
launcher: IRoktLauncher | null;
userAttributes: Dictionary<string>;
hashAttributes: (attributes: RoktAttributes) => Promise<RoktAttributes>;
selectPlacements: (options: IRoktSelectPlacementsOptions) => Promise<IRoktSelection>;
setExtensionData<T>(extensionData: IRoktPartnerExtensionData<T>): void;
use: <T>(name: string) => Promise<T>;
launcherOptions?: Dictionary<any>;
}
export interface IRoktOptions {
sandbox?: boolean;
launcherOptions?: IRoktLauncherOptions;
domain?: string;
}
export type IRoktLauncherOptions = Dictionary<any>;
// The purpose of this class is to create a link between the Core mParticle SDK and the
// Rokt Web SDK via a Web Kit.
// The Rokt Manager should load before the Web Kit and stubs out many of the
// Rokt Web SDK functions with an internal message queue in case a Rokt function
// is requested before the Rokt Web Kit or SDK is finished loaded.
// Once the Rokt Kit is attached to the Rokt Manager, we can consider the
// Rokt Manager in a "ready" state and it can begin sending data to the kit.
//
// https://github.com/mparticle-integrations/mparticle-javascript-integration-rokt
export default class RoktManager {
public kit: IRoktKit = null;
public filters: RoktKitFilterSettings = {};
private currentUser: IMParticleUser | null = null;
private messageQueue: Map<string, IRoktMessage> = new Map();
private sandbox: boolean | null = null;
private placementAttributesMapping: Dictionary<string>[] = [];
private identityService: SDKIdentityApi;
private store: IStore;
private launcherOptions?: IRoktLauncherOptions;
private logger: SDKLoggerApi;
private domain?: string;
private mappedEmailShaIdentityType?: string | null;
private captureTiming?: (metricsName: string) => void;
/**
* Initializes the RoktManager with configuration settings and user data.
*
* @param {IKitConfigs} roktConfig - Configuration object containing user attribute filters and settings
* @param {IMParticleUser} filteredUser - User object with filtered attributes
* @param {SDKIdentityApi} identityService - The mParticle Identity instance
* @param {SDKLoggerApi} logger - The mParticle Logger instance
* @param {IRoktOptions} options - Options for the RoktManager
* @param {Function} captureTiming - Function to capture performance timing marks
*
* @throws Logs error to console if placementAttributesMapping parsing fails
*/
public init(
roktConfig: IKitConfigs,
filteredUser: IMParticleUser,
identityService: SDKIdentityApi,
store: IStore,
logger?: SDKLoggerApi,
options?: IRoktOptions,
captureTiming?: (metricsName: string) => void
): void {
const { userAttributeFilters, settings } = roktConfig || {};
const { placementAttributesMapping, hashedEmailUserIdentityType } = settings || {};
this.mappedEmailShaIdentityType = hashedEmailUserIdentityType?.toLowerCase() ?? null;
this.identityService = identityService;
this.store = store;
this.logger = logger;
this.captureTiming = captureTiming;
this.filters = {
userAttributeFilters,
filterUserAttributes: KitFilterHelper.filterUserAttributes,
filteredUser: filteredUser,
};
try {
this.placementAttributesMapping = parseSettingsString(placementAttributesMapping);
} catch (error) {
this.logger.error('Error parsing placement attributes mapping from config: ' + error);
}
// This is the global setting for sandbox mode
// It is set here and passed in to the createLauncher method in the Rokt Kit
// This is not to be confused for the `sandbox` flag in the selectPlacements attributes
// as that is independent of this setting, though they share the same name.
const sandbox = options?.sandbox || false;
// Launcher options are set here for the kit to pick up and pass through
// to the Rokt Launcher.
this.launcherOptions = { sandbox, ...options?.launcherOptions };
if (options?.domain) {
this.domain = options.domain;
}
}
public attachKit(kit: IRoktKit): void {
this.kit = kit;
this.processMessageQueue();
}
/**
* Renders ads based on the options provided
*
* @param {IRoktSelectPlacementsOptions} options - The options for selecting placements, including attributes and optional identifier
* @returns {Promise<IRoktSelection>} A promise that resolves to the selection
*
* @example
* // Correct usage with await
* await window.mParticle.Rokt.selectPlacements({
* attributes: {
* email: 'user@example.com',
* customAttr: 'value'
* }
* });
*/
public async selectPlacements(options: IRoktSelectPlacementsOptions): Promise<IRoktSelection> {
if (this.captureTiming) {
this.captureTiming(PerformanceMarkType.JointSdkSelectPlacements);
}
// Queue if kit isn't ready OR if identity is in flight
if (!this.isReady() || this.store?.identityCallInFlight) {
return this.deferredCall<IRoktSelection>('selectPlacements', options);
}
try {
const { attributes } = options;
const sandboxValue = attributes?.sandbox || null;
const mappedAttributes = this.mapPlacementAttributes(attributes, this.placementAttributesMapping);
this.currentUser = this.identityService.getCurrentUser();
const currentUserIdentities = this.currentUser?.getUserIdentities()?.userIdentities || {};
const currentEmail = currentUserIdentities.email;
const newEmail = mappedAttributes.email as string;
let currentHashedEmail: string | undefined;
let newHashedEmail: string | undefined;
// Hashed email identity is valid if it is set to Other-Other10
const isValidHashedEmailIdentityType =
this.mappedEmailShaIdentityType &&
IdentityType.getIdentityType(this.mappedEmailShaIdentityType) !== false;
if (isValidHashedEmailIdentityType) {
currentHashedEmail = currentUserIdentities[this.mappedEmailShaIdentityType];
newHashedEmail =
(mappedAttributes['emailsha256'] as string) ||
(mappedAttributes[this.mappedEmailShaIdentityType] as string) ||
undefined;
}
const emailChanged = this.hasIdentityChanged(currentEmail, newEmail);
const hashedEmailChanged = this.hasIdentityChanged(currentHashedEmail, newHashedEmail);
const newIdentities: UserIdentities = {};
if (emailChanged) {
newIdentities.email = newEmail;
if (newEmail) {
this.logger.warning(`Email mismatch detected. Current email differs from email passed to selectPlacements call. Proceeding to call identify with email from selectPlacements call. Please verify your implementation.`);
}
}
if (hashedEmailChanged) {
newIdentities[this.mappedEmailShaIdentityType] = newHashedEmail;
this.logger.warning(`emailsha256 mismatch detected. Current mParticle hashedEmail differs from hashedEmail passed to selectPlacements call. Proceeding to call identify with hashedEmail from selectPlacements call. Please verify your implementation.`);
}
if (!isEmpty(newIdentities)) {
// Call identify with the new user identities
try {
await new Promise<void>((resolve, reject) => {
this.identityService.identify({
userIdentities: {
...currentUserIdentities,
...newIdentities
}
}, () => {
resolve();
});
});
} catch (error) {
this.logger.error('Failed to identify user with new email: ' + JSON.stringify(error));
}
}
// Refresh current user identities to ensure we have the latest values before building enrichedAttributes
this.currentUser = this.identityService.getCurrentUser();
const finalUserIdentities = this.currentUser?.getUserIdentities()?.userIdentities || {};
this.setUserAttributes(mappedAttributes);
const enrichedAttributes: RoktAttributes = {
...mappedAttributes,
...(sandboxValue !== null ? { sandbox: sandboxValue } : {}),
};
// Propagate email from current user identities if not already in attributes
if (finalUserIdentities.email && !enrichedAttributes.email) {
enrichedAttributes.email = finalUserIdentities.email;
}
// Propagate emailsha256 from current user identities if not already in attributes
if (isValidHashedEmailIdentityType) {
const hashedEmail = finalUserIdentities[this.mappedEmailShaIdentityType];
if (
hashedEmail &&
!enrichedAttributes.emailsha256 &&
!enrichedAttributes[this.mappedEmailShaIdentityType]
) {
enrichedAttributes.emailsha256 = hashedEmail;
}
}
this.filters.filteredUser = this.currentUser || this.filters.filteredUser || null;
const enrichedOptions = {
...options,
attributes: enrichedAttributes,
};
return this.kit.selectPlacements(enrichedOptions);
} catch (error) {
return Promise.reject(error instanceof Error ? error : new Error('Unknown error occurred'));
}
}
/**
* Hashes attributes and returns both original and hashed versions
* with Rokt-compatible key names (like emailsha256, mobilesha256)
*
*
* @param {RoktAttributes} attributes - Attributes to hash
* @returns {Promise<RoktAttributes>} Object with both original and hashed attributes
*
*/
public async hashAttributes(attributes: RoktAttributes): Promise<RoktAttributes> {
try {
if (!attributes || typeof attributes !== 'object') {
return {};
}
// Get own property keys only
const keys = Object.keys(attributes);
if (keys.length === 0) {
return {};
}
// Hash all attributes in parallel
const hashPromises = keys.map(async (key) => {
const attributeValue = attributes[key] as RoktAttributeValueType;
const hashedValue = await this.hashSha256(attributeValue);
return { key, attributeValue, hashedValue };
});
const results = await Promise.all(hashPromises);
// Build the result object
const hashedAttributes: RoktAttributes = {};
for (const { key, attributeValue, hashedValue } of results) {
hashedAttributes[key] = attributeValue;
if (hashedValue) {
hashedAttributes[`${key}sha256`] = hashedValue;
}
}
return hashedAttributes;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to hashAttributes, returning an empty object: ${errorMessage}`);
return {};
}
}
public setExtensionData<T>(extensionData: IRoktPartnerExtensionData<T>): void {
if (!this.isReady()) {
this.deferredCall<void>('setExtensionData', extensionData);
return;
}
try {
this.kit.setExtensionData<T>(extensionData);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error('Error setting extension data: ' + errorMessage);
}
}
public use<T>(name: string): Promise<T> {
if (!this.isReady()) {
return this.deferredCall<T>('use', name);
}
try {
return this.kit.use<T>(name);
} catch (error) {
return Promise.reject(error instanceof Error ? error : new Error('Error using extension: ' + name));
}
}
/**
* Hashes an attribute using SHA-256
*
* @param {string | number | boolean | undefined | null} attribute - The value to hash
* @returns {Promise<string | undefined | null>} SHA-256 hashed value or undefined/null
*
*/
public async hashSha256(attribute: RoktAttributeValueType): Promise<string | undefined | null> {
if (attribute === null || attribute === undefined) {
this.logger.warning(`hashSha256 received null/undefined as input`);
return attribute as null | undefined;
}
try {
const normalizedValue = String(attribute).trim().toLocaleLowerCase();
return await this.sha256Hex(normalizedValue);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to hashSha256, returning undefined: ${errorMessage}`);
return undefined;
}
}
public getLocalSessionAttributes(): LocalSessionAttributes {
return this.store.getLocalSessionAttributes();
}
public setLocalSessionAttribute(key: string, value: AttributeValue): void {
this.store.setLocalSessionAttribute(key, value);
}
private isReady(): boolean {
// The Rokt Manager is ready when a kit is attached and has a launcher
return Boolean(this.kit && this.kit.launcher);
}
private setUserAttributes(attributes: RoktAttributes): void {
const reservedAttributes = ['sandbox'];
const filteredAttributes = {};
for (const key in attributes) {
if (attributes.hasOwnProperty(key) && reservedAttributes.indexOf(key) === -1) {
const value = attributes[key];
filteredAttributes[key] = Array.isArray(value) ? JSON.stringify(value) : value;
}
}
try {
this.currentUser.setUserAttributes(filteredAttributes);
} catch (error) {
this.logger.error('Error setting user attributes: ' + error);
}
}
private mapPlacementAttributes(attributes: RoktAttributes, placementAttributesMapping: Dictionary<string>[]): RoktAttributes {
const mappingLookup: { [key: string]: string } = {};
for (const mapping of placementAttributesMapping) {
mappingLookup[mapping.map] = mapping.value;
}
const mappedAttributes: RoktAttributes = {};
for (const key in attributes) {
if (attributes.hasOwnProperty(key)) {
const newKey = mappingLookup[key] || key;
mappedAttributes[newKey] = attributes[key];
}
}
return mappedAttributes;
}
public onIdentityComplete(): void {
if (this.isReady()) {
this.currentUser = this.identityService.getCurrentUser();
// Process any queued selectPlacements calls that were waiting for identity
this.processMessageQueue();
}
}
private processMessageQueue(): void {
if (!this.isReady() || this.messageQueue.size === 0) {
return;
}
this.logger?.verbose(`RoktManager: Processing ${this.messageQueue.size} queued messages`);
const messagesToProcess = Array.from(this.messageQueue.values());
// Clear the queue immediately to prevent re-processing
this.messageQueue.clear();
messagesToProcess.forEach((message) => {
if(!(message.methodName in this) || !isFunction(this[message.methodName])) {
this.logger?.error(`RoktManager: Method ${message.methodName} not found`);
return;
}
this.logger?.verbose(`RoktManager: Processing queued message: ${message.methodName} with payload: ${JSON.stringify(message.payload)}`);
// Capture resolve/reject functions before async processing
const resolve = message.resolve;
const reject = message.reject;
const handleError = (error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger?.error(`RoktManager: Error processing message '${message.methodName}': ${errorMessage}`);
if (reject) {
reject(error);
}
};
try {
const result = (this[message.methodName] as Function)(message.payload);
// Handle both sync and async methods
Promise.resolve(result)
.then((resolvedResult) => {
if (resolve) {
resolve(resolvedResult);
}
})
.catch(handleError);
} catch (error) {
handleError(error);
}
});
}
private queueMessage(message: IRoktMessage): void {
this.messageQueue.set(message.messageId, message);
}
private deferredCall<T>(methodName: string, payload: any): Promise<T> {
return new Promise<T>((resolve, reject) => {
const messageId = `${methodName}_${generateUniqueId()}`;
this.queueMessage({
messageId,
methodName,
payload,
resolve,
reject,
});
});
}
/**
* Hashes a string input using SHA-256 and returns the hex digest
* Uses the Web Crypto API for secure hashing
*
* @param {string} input - The string to hash
* @returns {Promise<string>} The SHA-256 hash as a hexadecimal string
*/
private async sha256Hex(input: string): Promise<string> {
const encoder = new TextEncoder();
const encodedInput = encoder.encode(input);
const digest = await crypto.subtle.digest('SHA-256', encodedInput);
return this.arrayBufferToHex(digest);
}
/**
* Converts an ArrayBuffer to a hexadecimal string representation
* Each byte is converted to a 2-character hex string with leading zeros
*
* @param {ArrayBuffer} buffer - The buffer to convert
* @returns {string} The hexadecimal string representation
*/
private arrayBufferToHex(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let hexString = '';
for (let i = 0; i < bytes.length; i++) {
const hexByte = bytes[i].toString(16).padStart(2, '0');
hexString += hexByte;
}
return hexString;
}
/**
* Checks if an identity value has changed by comparing current and new values
*
* @param {string | undefined} currentValue - The current identity value
* @param {string | undefined} newValue - The new identity value to compare against
* @returns {boolean} True if the identity has changed (new value exists and differs from current), false otherwise
*/
private hasIdentityChanged(currentValue: string | undefined, newValue: string | undefined): boolean {
if (!newValue) {
return false;
}
if (!currentValue) {
return true; // New value exists but no current value
}
if (currentValue !== newValue) {
return true; // Values are different
}
return false; // Values are the same
}
}