@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
JavaScript
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