UNPKG

pivot

Version:

Simple feature, multi-variant and A/B testing

223 lines (187 loc) 5.48 kB
/** * Module dependencies */ require = require("require-component")(require); var Batch = require("batch") , debug = require("debug")("pivot:core") , each = require("each") , find = require("find"); /** * Expose the protoype */ var pivot = exports = module.exports; /** * Noop */ function noop () {} /** * Add an application-wide feature * * @param {String} name * @param {Array|Function} variants * @param {Function} done * @return {Pivot} * @api public */ pivot.feature = function(name, variants, done) { var self = this; // Defaults if(typeof variants === "function" || typeof variants === "undefined") { done = variants; variants = [false, true]; } done = done || noop; debug("notifying backend about feature '"+name+"' with variants:",variants); self.callbacks.lookup(name, variants, function(err) { if(err) return done(err); debug("saving feature '"+name+"'"); self.features.push({name: name, variants: variants}); done(); }); return this; }; /** * Handle a request and assign the variants to the user * * @param {HTTPRequest} req * @param {HTTPResponse} res * @param {Function} next * @return {Pivot} * @api public */ pivot.handle = function(req, res, next) { var self = this; res.on("header", function() { debug("serializing features", res.locals.features); (self.callbacks.serialize || function(features, req, res, cb) { // Default to setting a cookie res.cookie("pivot", JSON.stringify(features)); cb(); })(res.locals.features, req, res, noop); // TODO How should we handle when errors occur? }); var batch = new Batch; // Deserialize the current experiments for the user batch.push(function(done) { (self.callbacks.deserialize || function(req, res, cb) { // Default to getting a cookie cb(null, (req.cookies ? safeParse(req.cookies.pivot) : {})); })(req, res, done); }); // Lookup the user batch.push(function(done) { (self.callbacks.findUser || function(req, res, cb) { // Default to req.user if not set cb(null, req.user); })(req, res, done); }); batch.end(function(err, results) { if(err) return next(err); /** * Set a feature variant for the request * * There's one of four things that can happen in this flow: * * 1. User does not have that feature set and we assign it a variant * 2. User has the variant assigned and the feature settings haven't changed * 3. User has the variant assigned but the features settings have been updated and need to be reapplied * 4. User has set the variant in browser (i.e. with cookies) and what we would assign is different * * The current implementation supports 1, 2 and 3, which are the easy things * to do. We'll need to figure out how to do 4. */ function assignFeature (name, variants, done) { done = done || noop; var user = results[1]; return self._assignVariant(name, variants, user, function(err, variant) { if(err) return done(err); res.locals.features[name] = variant; debug("setting",name,"to",variant,"for user:",user); done(null, variant); }); }; /** * Assign the deserialized features to the request */ debug("assigning deserialized features", results[0]); res.locals.features = results[0] || {}; /** * Alias `assignFeature` for ad-hoc features and feature reading */ res.feature = assignFeature; /** * Apply all of the application-wide features */ var defaults = new Batch; debug("assigning application-wide features", self.features); each(self.features, function(feature) { defaults.push(function(done) { assignFeature(feature.name, feature.variants, done); }); }); defaults.end(function(err) { debug("done assigning features"); next(err); }); }); return this; }; /** * Lookup a feature variant for a user * * @param {String} name * @param {Object} user * @param {Function} done * @api public */ pivot.variant = function(name, user, done) { var variants = find(this.features, {name: name}); if(!variants) return done(new Error("Could not find feature '"+name+"'")); return this._assignVariant(name, variants, user, done); }; pivot.serialize = function(fn) { this.callbacks.serialize = fn; return this; }; pivot.deserialize = function(fn) { this.callbacks.deserialize = fn; return this; }; pivot.findUser = function(fn) { this.callbacks.findUser = fn; return this; }; pivot.lookup = function(fn) { this.callbacks.lookup = fn; return this; }; pivot.assign = function(fn) { this.callbacks.assign = fn; return this; }; /** * Assign a feature variant to a user * * @param {String} name * @param {Array} variants * @param {Object} user * @param {Function} done * @api private */ pivot._assignVariant = function(name, variants, user, done) { var self = this; done = done || noop; debug("looking up feature '"+name+"' with variants:",variants," for user:",user); self.callbacks.lookup(name, variants, function(err, featureSettings) { if(err) return done(err); debug("got settings for feature '"+name+"':", featureSettings); self.callbacks.assign(name, featureSettings, user, done); }); return this; }; function safeParse (string) { try { return JSON.parse(string); } catch(e) {} return null; }