import Context, {
CONTEXT_INITIALIZED,
CONTEXT_VALUE_ADDED,
CONTEXT_VALUE_CHANGED,
CONTEXT_VALUE_REMOVED
} from './context.js';
import Store, { EFFECTIVE_GENOME_UPDATED, REQUEST_FAILED } from './store.js';
import { waitFor, waitOnceFor, emit, destroyScope, removeListener } from './waitforit.js';
import Beacon from './beacon.js';
import { assign } from './ponyfills/objects.js';
import { buildOptions } from './build-options.js';
import MiniPromise from './ponyfills/minipromise.js';
import { addDateTimeToContext } from './time-context.js';
/**
* @typedef {Promise} SubscribablePromise
* @description Object similar to a standard Promise but which can also be subscribed to to observe subsequent values
* Note that the standard promise methods will only handle the first calls to `resolve()` or `reject()`.
* @property {function(function):undefined} then Equivalent to `Promise.then()`
* @property {function(function):function} listen Subscribes listener to the promise. Returns a function that can be called to unsubscribe
* @property {function(function):undefined} catch Equivalent to `Promise.catch()`
* @property {function(function):undefined} finally Equivalent to `Promise.finally()`
*/
/**
* @callback Listener
* @param {string} eventName
* @param {...*} args
*/
/**
* The EvolvClient provides a low level integration with the Evolv participant APIs.
*
* The client provides asynchronous access to key states, values, contexts, and configurations.
*
* @param opts {Partial<EvolvClientOptions>} An object of options for the client.
* @constructor
*/
function EvolvClient(opts) {
let initialized = false;
const options = buildOptions(opts);
const store = options.store || new Store(options);
const context = options.context || new Context(store);
/** @type Partial<EmitterOptions> */
const beaconOptions = {
blockTransmit: options.bufferEvents,
clientName: options.clientName
};
const contextBeacon = options.analytics ? new Beacon(options.endpoint + '/' + options.environment + '/data', context, beaconOptions) : null;
const eventBeacon = options.beacon || new Beacon(options.endpoint + '/' + options.environment + '/events', context, beaconOptions);
/**
* The context against which the key predicates will be evaluated.
*/
Object.defineProperty(this, 'context', { get: function() { return context; } });
/**
* The current environment id.
*/
Object.defineProperty(this, 'environment', { get: function() { return options.environment; } });
/**
* Add listeners to lifecycle events that take place in to client.
*
* Currently supported events:
* * "initialized" - Called when the client is fully initialized and ready for use with (topic, options)
* * "context.initialized" - Called when the context is fully initialized and ready for use with (topic, updated_context)
* * "context.changed" - Called whenever a change is made to the context values with (topic, updated_context)
* * "context.value.removed" - Called when a value is removed from context with (topic, key, updated_context)
* * "context.value.added" - Called when a new value is added to the context with (topic, key, value, local, updated_context)
* * "context.value.changed" - Called when a value is changed in the context (topic, key, value, before, local, updated_context)
* * "context.destroyed" - Called when the context is destroyed with (topic, context)
* * "genome.request.sent" - Called when a request for a genome is sent with (topic, requested_keys)
* * "config.request.sent" - Called when a request for a config is sent with (topic, requested_keys)
* * "genome.request.received" - Called when the result of a request for a genome is received (topic, requested_keys)
* * "config.request.received" - Called when the result of a request for a config is received (topic, requested_keys)
* * "request.failed" - Called when a request fails (topic, source, requested_keys, error)
* * "genome.updated" - Called when the stored genome is updated (topic, allocation_response)
* * "config.updated" - Called when the stored config is updated (topic, config_response)
* * "effective.genome.updated" - Called when the effective genome is updated (topic, effectiveGenome)
* * "store.destroyed" - Called when the store is destroyed (topic, store)
* * "confirmed" - Called when the consumer is confirmed (topic)
* * "contaminated" - Called when the consumer is contaminated (topic)
* * "event.emitted" - Called when an event is emitted through the beacon (topic, type, score)
*
* @param {String} topic The event topic on which the listener should be invoked.
* @param {Listener} listener The listener to be invoked for the specified topic.
* @method
* @see {@link EvolvClient#once} for listeners that should only be invoked once.
*/
this.on = waitFor.bind(undefined, context);
/**
* Add a listener to a lifecycle event to be invoked once on the next instance of the
* event to take place in to client.
*
* See the "on" function for supported events.
*
* @param {String} topic The event topic on which the listener should be invoked.
* @param {Listener} listener The listener to be invoked for the specified topic.
* @method
* @see {@link EvolvClient#on} for listeners that should be invoked on each event.
*/
this.once = waitOnceFor.bind(undefined, context);
/**
* Remove a listener from a lifecycle event.
*
* See the "on" function for supported events.
*
* @param {String} topic The event topic from which the listener should be removed.
* @param {Listener} listener The listener to be removed from the specified topic.
* @method
* @see {@link EvolvClient#on} for listeners that should be invoked on each event.
*/
this.off = removeListener.bind(undefined, context);
/**
* Preload all keys under the specified prefixes.
*
* @param {Array.<String>} prefixes A list of prefixes to keys to load.
* @param {Boolean} [configOnly = false] If true, only the config would be loaded.
* @param {Boolean} [immediate = false] Forces the requests to the server.
* @method
*/
this.preload = store.preload.bind(store);
/**
* Get the value of a specified key.
*
* @param {String} key The key of the value to retrieve.
* @returns {SubscribablePromise.<*|Error>} A SubscribablePromise that resolves to the value of the specified key.
* @method
*/
this.get = store.get.bind(store);
/**
* Check if a specified key is currently active.
*
* @param {String} key The key to check.
* @returns {SubscribablePromise.<Boolean|Error>} A SubscribablePromise that resolves to true if the specified key is
* active.
* @method
*/
this.isActive = store.isActive.bind(store);
/**
* Check all active keys that start with the specified prefix.
*
* @param {String} [prefix] The prefix of the keys to check.
* @returns {SubscribablePromise.<{ current: string[], previous: string[] }|Error>} A SubscribablePromise that resolves to object
* describing the state of active keys.
* @method
*/
this.getActiveKeys = store.getActiveKeys.bind(store);
/**
* Clears the active keys to reset the key states.
*
* @param {String} [prefix] The prefix of the keys clear.
* @method
* @deprecated
*/
this.clearActiveKeys = store.clearActiveKeys.bind(store);
/**
* Reevaluates the current context.
*
* @method
*/
this.reevaluateContext = store.reevaluateContext.bind(store);
/**
* Get the configuration for a specified key.
*
* @param {String} key The key to retrieve the configuration for.
* @returns {SubscribablePromise.<*|Error>} A SubscribablePromise that resolves to the configuration of the
* specified key.
* @method
*/
this.getConfig = store.getConfig.bind(store);
/**
* Get the display name for a specified type and key.
*
* @param {String} type The type of entity we're retrieving the display name for. Allow values: 'experiments'
* @param {String} key The key/id to retrieve the display name for.
* @returns {SubscribablePromise.<*|Error>} A SubscribablePromise that resolves to the configuration of the
* specified key.
* @method
*/
this.getDisplayName = store.getDisplayName.bind(store);
/**
* Get the configuration for a specified key.
*
* @param {String} key The key of the property to retrieve.
* @returns {SubscribablePromise.<*|Error>} A SubscribablePromise that resolves to the configuration of the
* specified key.
* @method
*/
this.getEnvConfig = store.getEnvConfig.bind(store);
/**
* Send an event to the events endpoint.
*
* @param {String} type The type associated with the event.
* @param {Object} [metadata] Any metadata to attach to the event.
* @param {Boolean} [flush = false] If true, the event will be sent immediately.
*/
this.emit = function(type, metadata, flush) {
context.pushToArray('events', {type: type, timestamp: (new Date()).getTime()});
eventBeacon.emit(type, assign({
uid: context.uid,
metadata: metadata
}), flush);
emit(context, EvolvClient.EVENT_EMITTED, type, metadata);
};
// TODO AP-2318 prevent sending confirmations when every stat comes from analytics. Prior to that, these are still needed
/*let getSessionBasedExps = function() {
let sessionBasedExps = {};
((store.configuration && store.configuration._experiments) || []).forEach(function(experiment) {
if (experiment._optimization_metric === 'SESSION') {
sessionBasedExps[experiment.id] = true;
}
});
return sessionBasedExps;
}*/
/**
* Confirm that the consumer has successfully received and applied values, making them eligible for inclusion in
* optimization statistics.
*/
this.confirm = function() {
// eslint-disable-next-line es/no-promise
return new MiniPromise.createPromise(function(resolve) {
waitFor(context, EFFECTIVE_GENOME_UPDATED,function() {
const remoteContext = context.remoteContext;
const allocations = (remoteContext.experiments || {}).allocations // undefined is a valid state, we want to know if its undefined
if (!store.configuration || !allocations || !allocations.length) {
resolve();
return;
}
// TODO AP-2318 prevent sending confirmations when every stat comes from analytics. Prior to that, these are still needed
// const sessionBasedExps = getSessionBasedExps();
store.activeEntryPoints()
.then(function(entryPointEids) {
if (!entryPointEids.length) {
resolve();
return;
}
const confirmations = context.get('experiments.confirmations') || [];
const confirmedCids = confirmations.map(function(conf) {
return conf.cid;
});
const contaminations = context.get('experiments.contaminations') || [];
const contaminatedCids = contaminations.map(function(cont) {
return cont.cid;
});
const confirmableAllocations = allocations.filter(function(alloc) {
return confirmedCids.indexOf(alloc.cid) < 0 && contaminatedCids.indexOf(alloc.cid) < 0 && store.activeEids.has(alloc.eid) && entryPointEids.indexOf(alloc.eid) >= 0;
});
if (!confirmableAllocations.length) {
resolve();
return;
}
const timestamp = (new Date()).getTime();
const contextConfirmations = confirmableAllocations.map(function(alloc) {
return {
cid: alloc.cid,
timestamp: timestamp
}
});
context.set('experiments.confirmations', contextConfirmations.concat(confirmations));
confirmableAllocations.forEach(function(alloc) {
// Only confirm for non session based experiments -- session based use the analytics data
// TODO AP-2318 prevent sending confirmations when every stat comes from analytics. Prior to that, these are still needed
// !sessionBasedExps[alloc.eid] && eventBeacon.emit('confirmation', {
eventBeacon.emit('confirmation', {
uid: alloc.uid,
eid: alloc.eid,
cid: alloc.cid
});
});
eventBeacon.flush();
emit(context, EvolvClient.CONFIRMED);
resolve();
return;
});
});
});
};
/**
* Marks a consumer as unsuccessfully retrieving and / or applying requested values, making them ineligible for
* inclusion in optimization statistics.
*
* @param {Object} [details] Information on the reason for contamination. If provided, the object should
* contain a reason. Optionally, a 'details' value should be included for extra debugging info
* @param {boolean} [allExperiments = false] If true, the user will be excluded from all optimizations, including optimization
* not applicable to this page
*/
this.contaminate = function(details, allExperiments) {
const remoteContext = context.remoteContext;
const allocations = (remoteContext.experiments || {}).allocations; // undefined is a valid state, we want to know if its undefined
if (!allocations || !allocations.length) {
return;
}
if (details && !details.reason) {
throw new Error('Evolv: contamination details must include a reason');
}
const contaminations = context.get('experiments.contaminations') || [];
const contaminatedCids = contaminations.map(function(conf) {
return conf.cid;
});
const contaminatableAllocations = allocations.filter(function(alloc) {
return contaminatedCids.indexOf(alloc.cid) < 0 && (allExperiments || store.activeEids.has(alloc.eid));
});
if (!contaminatableAllocations.length) {
return;
}
const timestamp = (new Date()).getTime();
const contextContaminations = contaminatableAllocations.map(function(alloc) {
return {
cid: alloc.cid,
timestamp: timestamp,
contaminationReason: details
}
});
context.set('experiments.contaminations', contextContaminations.concat(contaminations));
contaminatableAllocations.forEach(function(alloc) {
eventBeacon.emit('contamination', {
uid: alloc.uid,
eid: alloc.eid,
cid: alloc.cid,
contaminationReason: details
});
});
eventBeacon.flush();
emit(context, EvolvClient.CONTAMINATED);
};
/**
* Initializes the client with required context information.
*
* @param {String} uid A globally unique identifier for the current participant.
* @param {Object} [remoteContext] A map of data used for evaluating context predicates and analytics.
* @param {Object} [localContext] A map of data used only for evaluating context predicates.
*/
this.initialize = function (uid, remoteContext, localContext) {
if (initialized) {
throw new Error('Evolv: Client is already initialized');
}
if (!uid) {
throw new Error('Evolv: "uid" must be specified');
}
context.initialize(uid, remoteContext, localContext);
store.initialize(context);
addDateTimeToContext(context, options.pollForTimeUpdates);
store.getClientContext()
.then(function(c) {
if (!c) {
return;
}
const updated = assign({}, c);
if (updated.browser) {
updated.web = {
client: {
browser: updated.browser
}
};
delete updated.browser;
}
context.update(updated, false);
})
.catch(function() {
console.log('Evolv: Failed to retrieve client context');
});
if (options.analytics) {
/*eslint no-unused-vars: ["error", { "argsIgnorePattern": "ctx" }]*/
waitFor(context, CONTEXT_INITIALIZED, function (type, ctx) {
contextBeacon.emit(type, context.remoteContext);
});
waitFor(context, CONTEXT_VALUE_ADDED, function (type, key, value, local) {
if (local) {
return;
}
contextBeacon.emit(type, {key: key, value: value});
});
waitFor(context, CONTEXT_VALUE_CHANGED, function (type, key, value, before, local) {
if (local) {
return;
}
contextBeacon.emit(type, {key: key, value: value});
});
waitFor(context, CONTEXT_VALUE_REMOVED, function (type, key, local) {
if (local) {
return;
}
contextBeacon.emit(type, {key: key});
});
}
if (options.autoConfirm) {
this.confirm();
waitFor(context, REQUEST_FAILED, this.contaminate.bind(this));
}
initialized = true;
emit(context, EvolvClient.INITIALIZED, options);
};
/**
* Force all beacons to transmit.
*/
this.flush = function() {
eventBeacon.flush();
if (options.analytics) {
contextBeacon.flush();
}
};
/**
* If the client was configured with
* bufferEvents: true
* then calling this will allow data to be sent back to Evolv
*/
this.allowEvents = function() {
eventBeacon.unblockAndFlush();
if (options.analytics) {
contextBeacon.unblockAndFlush();
}
};
/**
* Destroy the client and its dependencies.
*/
this.destroy = function () {
this.flush();
store.destroy();
context.destroy();
destroyScope(context);
};
}
EvolvClient.INITIALIZED = 'initialized';
EvolvClient.CONFIRMED = 'confirmed';
EvolvClient.CONTAMINATED = 'contaminated';
EvolvClient.EVENT_EMITTED = 'event.emitted';
export default EvolvClient;
export { default as MiniPromise } from './ponyfills/minipromise.js'