abby-client
Version:
[](https://coveralls.io/github/GreetzNL/abby-client) [](https://tra
529 lines (478 loc) • 15.2 kB
JavaScript
const murmurhash = require('murmurhash');
const cookie = require('cookie');
const url = require('url');
const uuidv4 = require('uuid/v4');
const pkg = require('../package.json');
const sync = require('./sync');
const expressionEvaluator = require('expression-evaluator');
const { toggleTypes } = require('../enums');
const Logger = require('./logger');
const HASH_SEED = 1;
const MAX_HASH_VALUE = Math.pow(2, 32);
const MAX_TRAFFIC_VALUE = 10000;
const COOKIE_NAME = 'ABBY_TOGGLES';
const COOKIE_SESSION = 'ABBY_SESSION';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7 * 52; // 1year in seconds
const COOKIE_SESSION_MAX_AGE = 60 * 45;// 45minutes in seconds;
const CONTROL_VARIANT = 'control';
const SEPARATOR = '|';
// region typedef
/**
* @typedef {Object} Synchroniser
* @param {string} lastSync
* @param {Array.<Toggle>} data
*/
/**
* @typedef {Object} Toggle
* @property {Number} id
* @property {String} name
* @property {Boolean} active
* @property {Array<Tag>} tags
* @property {String} testType
* @property {Object} targeting
* @property {Object} audience
* @property {String} type
* @property {Array.<Variant>} variants
* @property {boolean} [rebalance]
*/
/**
* @typedef {Object} Variant
* @property {String} name
* @property {String} code
* @property {Object.<String,String>} properties
*/
/**
* @typedef {Object} Tag
* @property {String} name
* @property {String} code
*/
/**
* @typedef {Object} Configs
* @property {String} API_ENDPOINT
* @property {String} [tags]
* @property {Object} [Logger]
*/
/**
* @typedef {Object} Logger
* @function {Void} log
* @function {Void} debug
* @function {Void} error
*/
/**
* @typedef {Object} UserToggle
* @property {string} toggleCode
* @property {Object} toggle
* @property {String} toggle.code
* @property {Array.<string>} toggle.tags
* @property {string} variantCode
* @property {Object} [variant]
* @property {String} [variant.code]
* @property {Object} [variant.properties]
* @property {boolean} [variant.original]
* @property {string} [hash]
* @property {string} type
* @property {boolean} paused
*/
/**
* @typedef {Object} UserToggleFilter
* @property {Array.<String>} [tags]
*/
// endregion
/**
* will return the current hash or generated a new hash if has is not present
*
* @param {string} toggleCode
* @param {UserToggle} [toggle]
* @returns {string}
*/
function toggleHash(toggleCode, toggle) {
return toggle && isFinite(parseInt(toggle.hash))
? toggle.hash
: generateHash(`${uuidv4()}_${toggleCode}`);
}
/**
* Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE)
* @param {string} toggleId String value for toggle ID
* @return {number} the generated hash value
*/
function generateHash(toggleId) {
let hashValue = murmurhash.v3(toggleId, HASH_SEED),
ratio = hashValue / MAX_HASH_VALUE;
return parseInt(ratio * MAX_TRAFFIC_VALUE, 10);
}
/**
* Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE)
* @param {Toggle} toggle Toggle object for toggle
* @param {number} hash number value of generated hash
* @param {UserToggle} currentToggle UserToggle object for current toggle
* @return {string} the id of available variant
*/
function getVariantCode(toggle, hash, currentToggle) {
const percentage = (hash * 100) / MAX_TRAFFIC_VALUE;
const variants = toggle.variants.filter(variant => {
const { start, end } = variant.range;
return percentage > start && percentage <= end;
});
if (variants.length === 0) {
return CONTROL_VARIANT;
} else if (!currentToggle || currentToggle.variantCode === CONTROL_VARIANT) {
return variants[0].code;
} else {
return currentToggle.variantCode;
}
}
/**
* will return empty object if variation is not found
*
* @param {Toggle} toggle
* @param {string} variantCode
* @returns {Variation}
*/
function getVariant(toggle, variantCode) {
let variants = toggle.variants;
if (Array.isArray(variants)) {
for (let i = 0, ii = variants.length; i < ii; i++) {
if (variants[i].code === variantCode) {
return variants[i];
}
}
}
return null;
}
/**
* will add only the active non-paused TOGGLES to the request
* @param {http.ClientRequest} req
* @param {Array.<UserToggle>} toggles
*/
function assignToRequest(req, toggles) {
req.toggles = {};
for (let toggle of toggles) {
if (toggle.type === toggleTypes.EXPERIMENT && toggle.paused) {
continue;
}
req.toggles[toggle.toggleCode] = toggle;
}
}
/**
* returns ths ABBY_TOGGLES cookie value which expires after 1 YEAR
* @param {Object} http.ServerResponse object value for http response
* @param {Array<UserToggle>} toggles array list of cookie data
* @return {void}
*/
function togglesCookieValue(toggles) {
let path = '/',
maxAge = COOKIE_MAX_AGE,
expires = new Date(Date.now() + maxAge);
const value = toggles
.map(toggle => `${toggle.toggleCode}${SEPARATOR}${toggle.variantCode}${SEPARATOR}${toggle.hash}`)
.join(',');
return cookie.serialize(COOKIE_NAME, value,
{
path,
maxAge,
expires,
secure: true
});
}
/**
* sets X-Abby-Version header
* @param {http.ServerResponse} res
* @param {Object} pkg
* @param {Synchroniser} synchroniser
*/
function assignVersion(res, pk, synchroniser) {
if (typeof pkg.version === 'string') {
res.setHeader('X-Abby-Version', pkg.version);
}
if (typeof synchroniser.lastSync === 'string') {
res.setHeader('X-Abby-Sync', synchroniser.lastSync);
}
}
/**
* returns the ABBY_SESSION cookie value
* @param {http.ServerResponse} object value for http response
* @param {boolean} newSession
* @param {string} sessionId
* @return {Void}
*/
function sessionCookieValue(newSession, sessionId) {
let path = '/',
maxAge = COOKIE_SESSION_MAX_AGE,
expires = new Date(Date.now() + (maxAge * 1000));
return cookie.serialize(COOKIE_SESSION, newSession ? uuidv4() : sessionId, {
path,
maxAge,
expires,
secure: true
});
}
/**
* @param {http.ClientRequest} req value for http response
* @return {Void}
*/
function getCookieData(req) {
return req.headers.cookie ? cookie.parse(req.headers.cookie) : {};
}
/**
*
* @param {string} data
* @returns {Object.<string, UserToggle>}
*/
function getCurrentToggles(data) {
if (typeof data !== 'string') {
return {};
}
let toggles = {};
data.split(',').forEach(value => {
let [ toggleCode, variantCode, hash] = value.split(SEPARATOR);
toggles[toggleCode] = { toggleCode, variantCode, hash };
});
return toggles;
}
/**
*
* @param {http.ClientRequest} req
* @returns {Object.<string, string>}
*/
function getForcedTogglesFromQuery(req) {
const { query: { abby_toggle: query } } = url.parse(req.url, true);
if (typeof query !== 'string') {
return {};
}
let toggles = {};
query.split(',').forEach(value => {
let [toggleCode, variantCode] = value.split(SEPARATOR);
toggles[toggleCode] = variantCode;
});
return toggles;
}
/**
*
* @param {string} sessionId
*/
function validateSession(sessionId) {
return typeof sessionId !== 'string' || sessionId.trim().length === 0;
}
/**
*
* @param {Toggle} toggle
* @param {Variant} variant
* @param {string} hash
* @returns {UserToggle}
*
*/
function buildUserExperimentToggle(toggle, variant, hash) {
return {
toggleCode: toggle.code,
toggle: {
code: toggle.code,
tags: toggle.tags.map(tag => tag.name),
audience: toggle.audience,
targeting: toggle.targeting
},
variantCode: variant.code,
variantProperties: variant.properties,
variant: {
code: variant.code,
properties: Object.assign({}, variant.properties),
original: variant.original
},
hash,
type: toggle.type,
paused: !toggle.active,
};
}
/**
*
* @param {Toggle} toggle
* @param {string} hash
* @returns {UserToggle}
*/
function buildUserDevelopmentToggle(toggle, hash) {
return {
toggleCode: toggle.code,
toggle: {
code: toggle.code,
tags: toggle.tags.map(tag => tag.name),
},
variantCode: toggle.active,
hash,
type: toggle.type,
paused: !toggle.active,
};
}
/**
*
* @param {Array.<UserToggle>} toggles
* @param {UserToggleFilter} filter
*/
function filterUserToggles(toggles, filter = {}) {
let result = toggles;
if (filter.tags) {
result = result.filter(({ toggle }) =>
filter.tags.every(tag => toggle.tags.find(value => value === tag))
);
}
return result;
}
/**
*
* returns all targetedExperiments which do not have original variant
* original variant will not be used when implementing experiments
* because original variants are the original view of the website/channel
*
* @param {Array.<UserToggle>} userToggles
* @param {Object} props
* @param {UserToggleFilter?} filter
*/
function getTargetedExperiments(userToggles, props, filter = {}) {
return filterUserToggles(userToggles, filter)
.filter(({ toggle, variant, type }) => type === toggleTypes.EXPERIMENT && !variant.original && expressionEvaluator.evaluate(toggle.targeting, props));
}
/**
* sets the cookie on the response and preserves any
* existing cookies that do not start with ABBY_
* adds all toggles also the paused/non-active user toggles
*
* @param {http.ServerResponse} res
* @param {Array.<String>} values
*/
function assignCookies(res, ...values) {
let cookies = [],
cookieHeader = res.getHeader('Set-Cookie');
if (typeof cookieHeader !== 'undefined' && cookieHeader !== null) {
if (Array.isArray(cookieHeader)) {
cookies = cookie.concat(cookieHeader);
} else {
cookies.push(cookieHeader);
}
}
// remove all current abby cookies
cookies = cookies.filter(cookie => !cookie.startsWith('ABBY_'));
// push the new values
cookies = cookies.concat(values);
res.setHeader('Set-Cookie', cookies);
}
/**
*
* @param {Toggle} experiment
* @param {Array<String>} [rebalance] optional list of experiment codes to be rebalance
* @returns {Boolean}
*/
function rebalanceExperiment(experiment, rebalance = []) {
if (experiment.rebalance) {
return true;
}
if (Array.isArray(rebalance)) {
return rebalance.find(code => code === experiment.code);
}
return false;
}
/**
* check if all toggles are enforced from query or not
* @param {http.ClientRequest} req
* @returns {Boolean}
*/
function areTogglesEnforcedToControl(req) {
const { query: { abby_enforce_control } } = url.parse(req.url, true);
return abby_enforce_control === 'true';
}
/**
* get all toggles with enforced value equal to control
* @param {Object.<string, UserToggle>} toggles
*/
function setTogglesToControl(toggles) {
for (let toggle in toggles) {
toggles[toggle].variantCode = 'control';
}
}
function abby(logger, synchroniser, configs) {
/**
*
* when the user already has a session cookie set than only change the forced toggles
* otherwise validate the current toggles and migrate to different variant if needed
*
* @param {http.ClientRequest} req
* @param {http.ServerResponse} res
* @param {Object?} props
* @param {Object?} config
* @param {Array<String>} config.rebalance
*/
function handle(req, res, props = {}, config = {}) {
const cookies = getCookieData(req);
const sessionCookie = cookies[COOKIE_SESSION];
const newSession = validateSession(sessionCookie);
const forcedToggles = getForcedTogglesFromQuery(req);
let currentToggles = getCurrentToggles(cookies[COOKIE_NAME]);
let /**Object.<string, UserToggle>*/toggleMap = {};
if (areTogglesEnforcedToControl(req)) {
setTogglesToControl(currentToggles);
}
for (let /**Toggle*/ toggle of /**Array.<Toggle>*/synchroniser.data) {
const { code: toggleCode, type: toggleType } = toggle;
let variantCode;
/**
* @type {UserToggle}
*/
let userToggle;
const currentToggle = currentToggles[toggleCode];
const forced = forcedToggles[toggleCode];
const hash = toggleHash(toggleCode, currentToggle);
if (toggleType === toggleTypes.EXPERIMENT) {
if (forced) {
variantCode = forced;
} else if (newSession) {
if (!expressionEvaluator.evaluate(toggle.audience, props)) {
continue;
}
variantCode = getVariantCode(toggle, hash, currentToggle);
} else if (currentToggle) {
if (rebalanceExperiment(toggle, config.rebalance)) {
variantCode = getVariantCode(toggle, hash, null);
} else {
variantCode = currentToggle.variantCode;
}
}
const variant = getVariant(toggle, variantCode);
if (variant === null) {
continue;
}
userToggle = buildUserExperimentToggle(toggle, variant, hash);
} else if (toggleType === toggleTypes.DEVELOPMENT) {
if (forced) {
toggle.active = forced === 'true';
}
else if (!newSession && !currentToggle) {
continue;
}
userToggle = buildUserDevelopmentToggle(toggle, hash);
} else {
continue;
}
toggleMap[toggleCode] = userToggle;
}
let toggles = Object.values(toggleMap);
assignToRequest(req, toggles);
assignVersion(res, pkg, synchroniser);
// order is important otherwise toggles might not get assigned
assignCookies(res, togglesCookieValue(toggles), sessionCookieValue(newSession, sessionCookie));
}
handle.ready = () => synchroniser.load(configs);
handle.getTargetedExperiments = getTargetedExperiments;
handle.areTogglesEnforcedToControl = areTogglesEnforcedToControl;
handle.setTogglesToControl = setTogglesToControl;
return handle;
}
module.exports = function (/**Configs*/configs, /**Synchroniser?*/synchroniser = sync) {
let { tags, apiEndpoint, logger } = Object.assign({}, configs);
let logger_ = Logger.instance(logger ? logger : console);
if (!tags) {
logger_.error(new Error('You should pass <tags>'));
return;
}
if (!apiEndpoint) {
logger_.error(new Error('You should pass <apiEndpoint>'));
return;
}
return abby(logger_, synchroniser, configs);
};