UNPKG

abby-client

Version:

[![Coverage Status](https://coveralls.io/repos/github/GreetzNL/abby-client/badge.svg)](https://coveralls.io/github/GreetzNL/abby-client) [![Build Status](https://travis-ci.com/GreetzNL/abby-client.svg?token=n8i4tz6gpgW4zgU5pvDP&branch=master)](https://tra

529 lines (478 loc) 15.2 kB
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); };