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 } from './waitforit.js';
import Beacon from './beacon.js';
import { assign } from './ponyfills/objects.js';
import { buildOptions } from './build-options.js';
/**
* @typedef {Promise} SubscribablePromise
* @property {function(function):undefined} then Then
* @property {function(function):undefined} listen Listen
* @property {function(function):undefined} catch Catch
* @property {function(function):undefined} finally Finally
*/
/**
* 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 {Function} 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 {Function} 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);
/**
* Preload all keys under under the specified prefixes.
*
* @param {Array.<String>} prefixes A list of prefixes to keys to load.
* @param {Boolean} configOnly If true, only the config would be loaded. (default: false)
* @param {Boolean} immediate Forces the requests to the server. (default: false)
* @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.<Object|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
*/
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);
/**
* Send an event to the events endpoint.
*
* @param {String} type The type associated with the event.
* @param metadata {Object} Any metadata to attach to the event.
* @param flush {Boolean} 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,
sid: context.sid,
metadata: metadata
}), flush);
emit(context, EvolvClient.EVENT_EMITTED, type, metadata);
};
/**
* Confirm that the consumer has successfully received and applied values, making them eligible for inclusion in
* optimization statistics.
*/
this.confirm = function() {
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) {
return;
}
store.activeEntryPoints()
.then(function(entryPointEids) {
if (!entryPointEids.length) {
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) {
return;
}
const timestamp = (new Date()).getTime();
const contextConfirmations = confirmableAllocations.map(function(alloc) {
return {
cid: alloc.cid,
timestamp: timestamp
}
});
// We will deprecate 'confirmations' in favor of 'experiments.confirmations'
// When deprecated delete below and uncomment next line
// context.set('experiments.confirmations', contextConfirmations.concat(confirmations));
const newConfirmations = contextConfirmations.concat(confirmations);
context.update({
'confirmations': newConfirmations,
'experiments': {
'confirmations': newConfirmations
}
});
confirmableAllocations.forEach(function(alloc) {
eventBeacon.emit('confirmation', assign({
uid: alloc.uid,
sid: alloc.sid,
eid: alloc.eid,
cid: alloc.cid
}, context.remoteContext));
});
eventBeacon.flush();
emit(context, EvolvClient.CONFIRMED);
});
});
};
/**
* Marks a consumer as unsuccessfully retrieving and / or applying requested values, making them ineligible for
* inclusion in optimization statistics.
*
* @param details {Object} Optional. 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 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
}
});
// We will deprecate 'contaminations' in favor of 'experiments.contaminations'
// When deprecated delete below and uncomment next line
// context.set('experiments.contaminations', contextContaminations.concat(contaminations));
const newContaminations = contextContaminations.concat(contaminations);
context.update({
'contaminations': newContaminations,
'experiments': {
'contaminations': newContaminations
}
});
contaminatableAllocations.forEach(function(alloc) {
eventBeacon.emit('contamination', assign({
uid: alloc.uid,
sid: alloc.sid,
eid: alloc.eid,
cid: alloc.cid,
contaminationReason: details
}, context.remoteContext));
});
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 {String} sid A globally unique session 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, sid, remoteContext, localContext) {
if (initialized) {
throw new Error('Evolv: Client is already initialized');
}
if (!uid) {
throw new Error('Evolv: "uid" must be specified');
}
if (!sid) {
throw new Error('Evolv: "sid" must be specified');
}
context.initialize(uid, sid, remoteContext, localContext);
store.initialize(context);
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'