UNPKG

@o3r/third-party

Version:

This module provides a bridge to communicate with third parties via an iFrame solution.

254 lines (238 loc) • 9.42 kB
import { BehaviorSubject, distinctUntilChanged, fromEvent, firstValueFrom } from 'rxjs'; import { filter, map, share, timeout } from 'rxjs/operators'; import { v4 } from 'uuid'; /** * Default options that will represent the interface */ const defaultOptions = { bridgeName: 'abTestBridge', readyEventName: 'ab-test-ready', logger: console }; /** * Bridge between the application and a third party A/B testing provider. * Exposes a start and stop methods to allow the external script to set the list of experiments to run over the * application. * * Share the resulting list of experiments with the rest of the application via an observable. */ class AbTestBridge { /** * AbTestBridge constructor * @param isExperimentEqual check two different experiments match to identify an experiment to start or to stop * @param options configure the communication with the A/B testing third party provider */ constructor(isExperimentEqual, options) { this.isExperimentEqual = isExperimentEqual; /** * Behaviour subject to control the experiments via the exposed interface */ this.experimentSubject$ = new BehaviorSubject([]); this.experiments$ = this.experimentSubject$.pipe(distinctUntilChanged((experimentsA, experimentsB) => experimentsB.length === experimentsA.length && experimentsA.every((eA) => experimentsB.find((eB) => isExperimentEqual(eA, eB))))); this.options = { ...defaultOptions, ...options }; if (window[this.options.bridgeName]) { this.log(`An instance of ${this.options.bridgeName} already exists. This AbTestBridge instance will be ignored`); } else { window[this.options.bridgeName] = { start: this.start.bind(this), stop: this.stop.bind(this) }; } document.dispatchEvent(new CustomEvent(this.options.readyEventName)); } /** * Use configured logger to log AB testing related information * @param args */ log(...args) { (this.options.logger.debug || this.options.logger.log)('A/B Test', ...args); } /** * @inheritDoc */ start(experiments) { this.log('Start experiment', experiments); const currentProfile = this.experimentSubject$.getValue(); this.experimentSubject$.next([ ...currentProfile, ...(Array.isArray(experiments) ? experiments : [experiments]).filter((exp) => !currentProfile.some((expB) => this.isExperimentEqual(exp, expB))) ]); } /** * @inheritDoc */ stop(experiments) { this.log('Stop experiment', experiments); const currentExperiments = this.experimentSubject$.getValue(); if (experiments) { // Stop the mentioned experiment this.experimentSubject$.next(currentExperiments.filter((expB) => !(Array.isArray(experiments) ? experiments : [experiments]).some((expA) => this.isExperimentEqual(expB, expA)))); } else { // Stop all the experiment this.experimentSubject$.next([]); } } } /** * Default options for an IFrameBridge */ const IFRAME_BRIDGE_DEFAULT_OPTIONS = { handshakeTries: 10, handshakeTimeout: 200, messageWithResponseTimeout: 1000 }; /** * Verifies if a message respects the format expected by an IFrameBridge * @param message */ function isSupportedMessage(message) { return typeof message === 'object' && !!message.action && !!message.version && !!message.channelId; } /** * Generates the html content of an iframe * @param scriptUrl script to be executed inside the iframe * @param additionalHeader custom html headers stringified */ function generateIFrameContent(scriptUrl, additionalHeader = '') { return `<html> <head> <script> class Bridge { handshakeDone = false; queuedMessages = []; channelId; messagesBuffer = []; listener; constructor() { if (window.parent) { window.addEventListener('message', (event) => { const message = event.data; if (this.isValidMessage(message)) { if (message.action === 'HANDSHAKE_PARENT') { this.channelId = message.channelId; this.sendMessage({action: 'HANDSHAKE_CHILD', version: '1.0', id: message.id}); this.handshakeDone = true; this.queuedMessages.forEach((queuedMessage) => this.sendMessage(queuedMessage)); this.queuedMessages = []; } else if (this.channelId && this.channelId === message.channelId) { // actual message if (this.listener) { this.listener(message); } else { this.messagesBuffer.push(message); } } } }); } else { throw new Error('Error in child frame bridge: can\\'t access parent window.'); } } register(handlerFunction, replayMissedMessages) { if (!this.listener) { this.listener = handlerFunction; if (replayMissedMessages) { this.messagesBuffer.forEach((message) => handlerFunction(message)); } this.messagesBuffer = []; } } isValidMessage(message) { return !!message.action && !!message.version && !!message.channelId; } sendMessage(message) { if(this.handshakeDone) { window.parent.postMessage({...message, channelId: this.channelId}, '*'); } else { this.queuedMessages.push(message); } } uuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } } const BRIDGE = new Bridge(); </script> <script src='${scriptUrl}'></script> ${additionalHeader} </head> <body></body> </html>`; } /** * Bridge that exposes an easy abstraction layer to communicate between a Host and an IFrame using the * postMessage API. */ class IframeBridge { constructor(parent, child, options = {}) { this.child = child; this.options = { ...IFRAME_BRIDGE_DEFAULT_OPTIONS, ...options }; this.channelId = v4(); this.internalMessages$ = fromEvent(parent, 'message').pipe(filter((event) => { const messageEvent = event; return isSupportedMessage(messageEvent.data) && messageEvent.data.channelId === this.channelId; }), map((event) => event.data), share()); this.messages$ = this.internalMessages$.pipe( // Here we remove all the messages having an "ID" because they are bound to their corresponding request and // are returned directly by the function sendMessageAndWaitForResponse filter((message) => !message.id)); this.handshakePromise = this.handshake(); } async handshake() { for (let i = 0; i < this.options.handshakeTries; i++) { try { await this._sendMessageAndWaitForResponse({ action: 'HANDSHAKE_PARENT', version: '1.0' }, this.options.handshakeTimeout); return; } catch { } } throw new Error('Handshake failed.'); } _sendMessage(message, messageId) { if (this.child.contentWindow) { this.child.contentWindow.postMessage({ ...message, channelId: this.channelId, id: messageId }, '*'); } } _sendMessageAndWaitForResponse(message, timeoutMilliseconds = this.options.messageWithResponseTimeout) { const id = v4(); const promise = firstValueFrom(this.internalMessages$.pipe(filter((response) => response.id === id), timeout(timeoutMilliseconds))); void this._sendMessage(message, id); return promise; } /** * Method to send a message to the script run in the iframe * @param message message object * @param messageId message identifier */ async sendMessage(message, messageId) { await this.handshakePromise; this._sendMessage(message, messageId); } /** * Method to send a message to the script run in the iframe and wait for an answer * @param message * @param timeoutMilliseconds */ async sendMessageAndWaitForResponse(message, timeoutMilliseconds = this.options.messageWithResponseTimeout) { await this.handshakePromise; return this._sendMessageAndWaitForResponse(message, timeoutMilliseconds); } } /** * Generated bundle index. Do not edit. */ export { AbTestBridge, IFRAME_BRIDGE_DEFAULT_OPTIONS, IframeBridge, generateIFrameContent, isSupportedMessage }; //# sourceMappingURL=o3r-third-party.mjs.map