New Case Study:See how Anthropic automated 95% of dependency reviews with Socket.Learn More
Socket
Sign inDemoInstall
Socket

node-rollout

Package Overview
Dependencies
Maintainers
3
Versions
17
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

node-rollout - npm Package Compare versions

Comparing version

to
1.0.0

.idea/codeStyles/Project.xml

206

index.js
var crypto = require('crypto')
, util = require('util')
, Promise = require('bluebird')
, EventEmitter = require('events').EventEmitter
var Promise = require('bluebird')
function defaultCondition() {
return true
}
module.exports = function (client) {

@@ -15,3 +9,2 @@ return new Rollout(client)

function Rollout(client) {
EventEmitter.call(this)
this.client = client

@@ -21,29 +14,27 @@ this._handlers = {}

util.inherits(Rollout, EventEmitter)
Rollout.prototype.handler = function (key, flags) {
Rollout.prototype.handler = function (key, modifiers) {
var self = this
this._handlers[key] = flags
var orig_percentages = []
var keys = Object.keys(flags).map(function (k) {
orig_percentages.push(flags[k].percentage)
return key + ':' + k
this._handlers[key] = modifiers
var configPercentages = []
var configKeys = Object.keys(modifiers).map(function (modName) {
configPercentages.push(modifiers[modName].percentage)
return key + ':' + modName
})
this.client.mget(keys, function (err, percentages) {
var _keys = []
var nullKey = false
percentages.forEach(function (p, i) {
return getRedisKeys(this.client, configKeys)
.then(function(persistentPercentages) {
var persistKeys = []
persistentPercentages.forEach(function (p, i) {
if (p === null) {
var val = Math.max(0, Math.min(100, orig_percentages[i] || 0))
nullKey = true
_keys.push(keys[i], val)
p = normalizePercentageRange(configPercentages[i])
persistKeys.push(configKeys[i], JSON.stringify(p))
persistentPercentages[i] = p
}
})
if (nullKey) {
self.client.mset(_keys, function () {
self.emit('ready')
if (persistKeys.length) {
return setRedisKeys(self.client, persistKeys)
.then(function() {
return persistentPercentages
})
} else {
self.emit('ready')
}
return persistentPercentages
})

@@ -54,43 +45,38 @@ }

var multi = this.client.multi()
// Accumulate get calls into a single "multi" query
var promises = keys.map(function (k) {
return this.get(k[0], k[1], k[2], multi).reflect()
}.bind(this))
// Perform the batch query
return new Promise(function (resolve, reject) {
multi.exec(function (err, result) {
if (err) return reject(err)
resolve(result)
})
multi.exec(promiseCallback(resolve, reject))
})
.then(Promise.all.bind(Promise, promises))
.then(function () {
return Promise.all(promises)
})
}
Rollout.prototype.get = function (key, id, opt_values, multi) {
var flags = this._handlers[key]
opt_values = opt_values || { id: id }
opt_values.id = opt_values.id || id
var modifiers = this._handlers[key]
var likely = this.val_to_percent(key + id)
var _id = {
id: id
}
if (!opt_values) opt_values = _id
if (!opt_values.id) opt_values.id = id
var keys = Object.keys(flags).map(function (k) {
return key + ':' + k
var keys = Object.keys(modifiers).map(function (modName) {
return key + ':' + modName
})
var client = multi || this.client
return new Promise(function (resolve, reject) {
client.mget(keys, function (err, result) {
if (err) return reject(err)
resolve(result)
})
})
return getRedisKeys(multi || this.client, keys)
.then(function (percentages) {
var i = 0
var deferreds = []
for (var modifier in flags) {
var output
for (var modName in modifiers) {
// in the circumstance that the key is not found, default to original value
if (percentages[i] === null) {
percentages[i] = flags[modifier].percentage
percentages[i] = normalizePercentageRange(modifiers[modName].percentage)
}
if (likely < percentages[i]) {
if (!flags[modifier].condition) flags[modifier].condition = defaultCondition
var output = flags[modifier].condition(opt_values[modifier])
if (isPercentageInRange(likely, percentages[i])) {
if (!modifiers[modName].condition) {
modifiers[modName].condition = defaultCondition
}
output = modifiers[modName].condition(opt_values[modName])
if (output) {

@@ -100,5 +86,7 @@ if (typeof output.then === 'function') {

// Reflect the Promise to coalesce rejections
deferreds.push(Promise.resolve(output).reflect())
output = Promise.resolve(output).reflect()
output.handlerModifier = modName
deferreds.push(output)
} else {
return true
return modName
}

@@ -118,6 +106,5 @@ }

resultValue = resultPromise.value()
// Treat resolved conditions with non-false values as affirmative
// (This is to handle `Promise.resolve()` and `Promise.resolve(null)`)
if (resultValue !== false) {
return true
// Treat resolved conditions with truthy values as affirmative
if (resultValue) {
return resultPromise.handlerModifier
}

@@ -130,42 +117,33 @@ }

throw new Error('Not inclusive of any partition for key[' + key + '] id[' + id + ']')
}.bind(this))
})
}
Rollout.prototype.update = function (key, percentage_map) {
var keys = []
for (var k in percentage_map) {
keys.push(key + ':' + k, percentage_map[k])
Rollout.prototype.update = function (key, modifierPercentages) {
var persistKeys = [], modName, p
for (modName in modifierPercentages) {
p = normalizePercentageRange(modifierPercentages[modName])
persistKeys.push(key + ':' + modName, JSON.stringify(p))
}
return new Promise(function (resolve, reject) {
this.client.mset(keys, function (err, result) {
if (err) return reject(err)
resolve(result)
})
}.bind(this))
return setRedisKeys(this.client, persistKeys)
}
Rollout.prototype.mods = function (name) {
Rollout.prototype.modifiers = function (handlerName) {
var keys = []
var names = []
for (var flag in this._handlers[name]) {
keys.push(name + ':' + flag)
names.push(flag)
var modNames = []
for (var modName in this._handlers[handlerName]) {
keys.push(handlerName + ':' + modName)
modNames.push(modName)
}
return new Promise(function (resolve, reject) {
this.client.mget(keys, function (err, result) {
if (err) return reject(err)
resolve(result)
return getRedisKeys(this.client, keys)
.then(function (values) {
var modifiers = {}
values.forEach(function (val, i) {
modifiers[modNames[i]] = JSON.parse(val)
})
}.bind(this))
.then(function (values) {
var flags = {}
values.forEach(function (val, i) {
flags[names[i]] = val
})
return flags
})
return modifiers
})
}
Rollout.prototype.flags = function () {
return Object.keys(this._handlers)
Rollout.prototype.handlers = function () {
return Promise.resolve(Object.keys(this._handlers))
}

@@ -178,1 +156,51 @@

}
function defaultCondition() {
return true
}
function clampPercentage(val) {
return Math.max(0, Math.min(100, +(val || 0)))
}
function normalizePercentageRange(val) {
if (val && typeof val === 'object') {
return {
min: clampPercentage(val.min),
max: clampPercentage(val.max)
}
}
return clampPercentage(val)
}
function isPercentageInRange(val, range) {
// Redis stringifies everything, so ranges must be reified
if (typeof range === 'string') {
range = JSON.parse(range)
}
if (range && typeof range === 'object') {
return val > range.min && val <= range.max
}
return val < range
}
function getRedisKeys(client, keys) {
return new Promise(function (resolve, reject) {
client.mget(keys, promiseCallback(resolve, reject))
})
}
function setRedisKeys(client, keys) {
return new Promise(function (resolve, reject) {
client.mset(keys, promiseCallback(resolve, reject))
})
}
function promiseCallback(resolve, reject) {
return function (err, result) {
if (err) {
return reject(err)
}
resolve(result)
}
}
{
"name": "node-rollout",
"version": "0.4.4",
"version": "1.0.0",
"description": "feature rollout management",

@@ -5,0 +5,0 @@ "author": "Ali Faiz & Dustin Diaz",

@@ -6,2 +6,6 @@ ## Node Rollout

### Example Usage
#### Installation
``` sh

@@ -11,4 +15,6 @@ npm install node-rollout --save

#### Basic Configuration
``` js
// configuration.js
// basic_configuration.js
var client = require('redis').createClient()

@@ -25,3 +31,3 @@ var rollout = require('node-rollout')(client)

condition: function (val) {
return val.match(/@company-email\.com$/)
return /@company-email\.com$/.test(val)
}

@@ -35,2 +41,12 @@ },

}
},
// Asynchronous database lookup
admin: {
percentage: 100,
condition: function (val) {
return db.lookupUser(val)
.then(function (user) {
return user.isAdmin()
})
}
}

@@ -43,5 +59,5 @@ })

``` js
// A typical Express app
// A typical Express app demonstrating rollout flags
...
var rollout = require('./configuration')
var rollout = require('./basic_configuration')

@@ -53,8 +69,9 @@ app.get('/', new_homepage, old_homepage)

employee: req.current_user.email,
geo: [req.current_user.lat, req.current_user.lon]
geo: [req.current_user.lat, req.current_user.lon],
admin: req.current_user.id
})
.then(function () {
res.render('home/new-index')
})
.otherwise(next)
.then(function () {
res.render('home/new-index')
})
.catch(next)
}

@@ -68,3 +85,41 @@

#### Experiment groups
``` js
// experiment_groups_configuration.js
var client = require('redis').createClient()
var rollout = require('node-rollout')(client)
// An experiment with 3 randomly-assigned groups
rollout.handler('homepage_variant', {
versionA: {
percentage: { min: 0, max: 33 }
},
versionB: {
percentage: { min: 33, max: 66 }
},
versionC: {
percentage: { min: 66, max: 100 }
}
})
module.exports = rollout
```
``` js
// A typical Express app demonstrating experiment groups
...
var rollout = require('./experiment_groups_configuration')
app.get('/', homepage)
function homepage(req, res, next) {
rollout.get('homepage_variant', req.current_user.id)
.then(function (version) {
console.assert(/^version(A|B|C)$/.test(version) === true)
res.render('home/' + version)
})
}
```
### API Options

@@ -81,8 +136,8 @@

rollout.get('button_test', 123)
.then(function () {
render('blue_button')
})
.otherwise(function () {
render('red_button')
})
.then(function () {
render('blue_button')
})
.catch(function () {
render('red_button')
})

@@ -92,15 +147,15 @@ rollout.get('another_feature', 123, {

})
.then(function () {
render('blue_button')
})
.otherwise(function () {
render('red_button')
})
.then(function () {
render('blue_button')
})
.catch(function () {
render('red_button')
})
```
#### `rollout.multi(keys)`
The value of this method lets you do a batch redis call (using `redis.multi()`) allowing you to get multiple flags in one request
The value of this method lets you do a batch redis call (using `redis.multi()`) allowing you to get multiple rollout handler results in one request
- `keys`: `Array` A list of tuples containing what you would ordinarily pass to `get`
- returns `SettlePromise`
- returns `Promise`

@@ -115,7 +170,7 @@ ``` js

])
.then(function (results) {
results.forEach(function (r) {
console.log(i.isFulfilled()) // Or 'isRejected()'
})
.then(function (results) {
results.forEach(function (r) {
console.log(i.isFulfilled()) // Or 'isRejected()'
})
})

@@ -125,17 +180,20 @@ rollout.get('another_feature', 123, {

})
.then(function () {
render('blue_button')
})
.otherwise(function () {
render('red_button')
})
.then(function () {
render('blue_button')
})
.catch(function () {
render('red_button')
})
```
#### `rollout.handler(key, flags)`
#### `rollout.handler(key, modifiers)`
- `key`: `String` The rollout feature key
- `flags`: `Object`
- `flagname`: `String` The name of the flag. Typically `id`, `employee`, `ip`, or any other arbitrary item you would want to modify the rollout
- `percentage`: `NumberRange` from 0 - 100. Can be set to a third decimal place such as `0.001` or `99.999`. Or simply `0` to turn off a feature, or `100` to give a feature to all users
- `modifiers`: `Object`
- `modName`: `String` The name of the modifier. Typically `id`, `employee`, `ip`, or any other arbitrary item you would want to modify the rollout
- `percentage`:
- `Number` from `0` - `100`. Can be set to a third decimal place such as `0.001` or `99.999`. Or simply `0` to turn off a feature, or `100` to give a feature to all users
- `Object` containing `min` and `max` keys representing a range of `Number`s between `0` - `100`
- `condition`: `Function` a white-listing method by which you can add users into a group. See examples.
- if `condition` returns a `Promise` (*a thenable object*), then it will use the fulfillment of the `Promise` to resolve or reject the `handler`
- Conditions will only be accepted if they return/resolve with a "truthy" value

@@ -152,3 +210,3 @@ ``` js

condition: function (val) {
return val.match(/@company-email\.com$/)
return /@company-email\.com$/.test(val)
}

@@ -170,5 +228,7 @@ },

#### `rollout.update(key, flags)`
#### `rollout.update(key, modifierPercentages)`
- `key`: `String` The rollout feature key
- `flags`: `Object` mapping of `flagname`:`String` to `percentage`:`Number`
- `modifierPercentages`: `Object` mapping of `modName`:`String` to `percentage`
- `Number` from `0` - `100`. Can be set to a third decimal place such as `0.001` or `99.999`. Or simply `0` to turn off a feature, or `100` to give a feature to all users
- `Object` containing `min` and `max` keys representing a range of `Number`s between `0` - `100`
- returns `Promise`

@@ -187,18 +247,24 @@

#### `rollout.mods(flagname)`
- `flagname`: `String` the rollout feature key
- returns `Promise`: resolves with the flags, their names, and values
#### `rollout.modifiers(handlerName)`
- `handlerName`: `String` the rollout feature key
- returns `Promise`: resolves to a modifiers `Object` mapping `modName`: `percentage`
``` js
rollout.mods('new_homepage').then(function (mods) {
flags.employee == 100
flags.geo_sf == 50.000
flags.id == 33.333
rollout.modifiers('new_homepage')
.then(function (modifiers) {
console.assert(modifiers.employee == 100)
console.assert(modifiers.geo_sf == 50.000)
console.assert(modifiers.id == 33.333)
})
```
#### `rollout.flags()`
#### `rollout.handlers()`
- return `Promise`: resolves with an array of configured rollout handler names
``` js
rollout.flags() == ['new_homepage', 'other_secret_feature']
rollout.handlers()
.then(function (handlers) {
console.assert(handlers[0] === 'new_homepage')
console.assert(handlers[1] === 'other_secret_feature')
})
```

@@ -214,6 +280,8 @@

### User Interface
Consider using [rollout-ui](https://github.com/ded/rollout-ui) to administrate the values of your rollout flags in real-time (as opposed to doing a full deploy). It will make your life much easier and you'll be happy :)
Consider using [rollout-ui](https://github.com/ded/rollout-ui) to administrate the values of your rollouts in real-time (as opposed to doing a full deploy). It will make your life much easier and you'll be happy :)
**Note:** `rollout-ui` does not yet support experiment groups and percentage ranges.
### License MIT
Happy rollout!

@@ -28,3 +28,3 @@ var chai = require('chai')

it('fulfills', function () {
subject.handler('secret_feature', {
return subject.handler('secret_feature', {
employee: {

@@ -35,9 +35,75 @@ percentage: 100,

})
return expect(subject.get('secret_feature', 123, {
employee: 'me@company.com'
})).to.be.fulfilled
.then(function () {
var result = subject.get('secret_feature', 123, {
employee: 'me@company.com'
})
return expect(result).to.be.fulfilled
})
})
it('fulfills with applicable modifier for percentage', function () {
return subject.handler('secret_feature', {
everyone: {
percentage: 0
},
employee: {
percentage: 100,
condition: isCompanyEmail
}
})
.then(function () {
var result = subject.get('secret_feature', 123, {
employee: 'me@company.com'
})
return expect(result).to.eventually.equal('employee')
})
})
context('percentage range', function () {
beforeEach(function () {
sinon.stub(subject, 'val_to_percent')
return subject.handler('secret_feature', {
groupA: {
percentage: { min: 0, max: 25 }
},
groupB: {
percentage: { min: 25, max: 50 }
},
groupC: {
percentage: { min: 50, max: 100 }
}
})
})
afterEach(function () {
subject.val_to_percent.restore()
})
it('fulfills with applicable modifier for range', function () {
subject.val_to_percent.onCall(0).returns(37)
var result = subject.get('secret_feature', 123)
return expect(result).to.eventually.equal('groupB')
})
it('fulfills multiple with applicable modifiers for ranges', function () {
subject.val_to_percent.onCall(0).returns(12)
subject.val_to_percent.onCall(1).returns(49.97)
subject.val_to_percent.onCall(2).returns(72)
return subject.multi([
['secret_feature', 123],
['secret_feature', 321],
['secret_feature', 213]
])
.then(function(results) {
expect(results[0].isFulfilled()).to.be.true
expect(results[0].value()).to.equal('groupA')
expect(results[1].isFulfilled()).to.be.true
expect(results[1].value()).to.equal('groupB')
expect(results[2].isFulfilled()).to.be.true
expect(results[2].value()).to.equal('groupC')
})
})
})
it('fulfills when condition returns a resolved promise', function () {
subject.handler('promise_secret_feature', {
return subject.handler('promise_secret_feature', {
beta_testa: {

@@ -47,3 +113,3 @@ percentage: 100,

return new Promise(function (resolve) {
setTimeout(resolve, 10)
setTimeout(resolve.bind(null, true), 10)
})

@@ -53,9 +119,12 @@ }

})
return expect(subject.get('promise_secret_feature', 123, {
beta_testa: 'foo'
})).to.be.fulfilled
.then(function () {
var result = subject.get('promise_secret_feature', 123, {
beta_testa: 'foo'
})
return expect(result).to.be.fulfilled
})
})
it('rejects when condition returns a rejected promise', function () {
subject.handler('promise_secret_feature', {
return subject.handler('promise_secret_feature', {
beta_testa: {

@@ -68,13 +137,16 @@ percentage: 100,

})
return expect(subject.get('promise_secret_feature', 123, {
beta_testa: 'foo'
})).to.be.rejected
.then(function () {
var result = subject.get('promise_secret_feature', 123, {
beta_testa: 'foo'
})
return expect(result).to.be.rejected
})
})
it('fulfills if `any` condition passes', function () {
subject.handler('mixed_secret_feature', {
return subject.handler('mixed_secret_feature', {
beta_testa: {
percentage: 100,
condition: function () {
return Promise.resolve()
return Promise.resolve(true)
}

@@ -85,3 +157,3 @@ },

condition: function () {
return Promise.resolve()
return Promise.resolve(true)
}

@@ -102,17 +174,19 @@ },

})
return expect(subject.get('mixed_secret_feature', 123, {
beta_testa: 'foo',
beta_testa1: 'foo',
beta_testa2: 'foo',
beta_testa3: 'foo'
})).to.be.fulfilled
.then(function () {
var result = subject.get('mixed_secret_feature', 123, {
beta_testa: 'foo',
beta_testa1: 'foo',
beta_testa2: 'foo',
beta_testa3: 'foo'
})
return expect(result).to.be.fulfilled
})
})
it('rejects if all conditions fail', function () {
subject.handler('mixed_secret_feature', {
return subject.handler('mixed_secret_feature', {
beta_testa1: {
percentage: 0,
condition: function () {
return Promise.resolve()
return Promise.resolve(true)
}

@@ -133,12 +207,14 @@ },

})
return expect(subject.get('mixed_secret_feature', 123, {
beta_testa1: 'foo',
beta_testa2: 'foo',
beta_testa3: 'foo'
})).to.be.rejected
.then(function () {
var result = subject.get('mixed_secret_feature', 123, {
beta_testa1: 'foo',
beta_testa2: 'foo',
beta_testa3: 'foo'
})
return expect(result).to.be.rejected
})
})
it('can retrieve all mod values', function (done) {
subject.handler('super_secret', {
it('can retrieve percentage mod values', function () {
return subject.handler('super_secret', {
foo: {

@@ -151,6 +227,7 @@ percentage: 12

})
subject.on('ready', function () {
subject.mods('super_secret').then(function (mods) {
expect(mods).to.deep.equal({foo: '12', bar: '34'})
done()
.then(function () {
var result = subject.modifiers('super_secret')
return expect(result).to.eventually.deep.equal({
foo: 12,
bar: 34
})

@@ -160,3 +237,21 @@ })

it('can retrieve all flagnames', function () {
it('can retrieve range mod values', function () {
return subject.handler('super_secret', {
foo: {
percentage: { min: 0, max: 50 }
},
bar: {
percentage: { min: 50, max: 100 }
}
})
.then(function () {
var result = subject.modifiers('super_secret')
return expect(result).to.eventually.deep.equal({
foo: { min: 0, max: 50 },
bar: { min: 50, max: 100 }
})
})
})
it('can retrieve all handler names', function () {
var o = {

@@ -167,9 +262,14 @@ foo: {

}
subject.handler('youza', o)
subject.handler('huzzah', o)
expect(subject.flags()).to.deep.equal(['youza', 'huzzah'])
return Promise.all([
subject.handler('youza', o),
subject.handler('huzzah', o)
])
.then(function () {
var result = subject.handlers()
return expect(result).to.eventually.deep.equal(['youza', 'huzzah'])
})
})
it('gets multiple keys', function () {
subject.handler('secret_feature', {
return subject.handler('secret_feature', {
employee: {

@@ -180,5 +280,2 @@ percentage: 100,

})
return subject.get('secret_feature', 123, {
employee: 'me@company.com'
})
.then(function () {

@@ -192,5 +289,5 @@ return subject.multi([

expect(result[0].isFulfilled()).to.be.true
expect(result[0].value()).to.be.true
expect(result[0].value()).to.equal('employee')
expect(result[1].isFulfilled()).to.be.true
expect(result[1].value()).to.be.true
expect(result[1].value()).to.equal('employee')
expect(result[2].isRejected()).to.be.true

@@ -211,3 +308,3 @@ })

subject.val_to_percent.returns(51.001)
subject.handler('another_feature', {
return subject.handler('another_feature', {
id: {

@@ -217,9 +314,11 @@ percentage: 51.000

})
var out = subject.get('another_feature', 123)
return expect(out).to.be.rejected
.then(function () {
var result = subject.get('another_feature', 123)
return expect(result).to.be.rejected
})
})
it('should be able to update a key', function (done) {
it('should be able to update a key with a percentage', function () {
subject.val_to_percent.returns(50)
subject.handler('button_test', {
return subject.handler('button_test', {
id: {

@@ -229,30 +328,46 @@ percentage: 100

})
Promise.resolve()
.then(function() {
return new Promise(function(resolve) {
subject.on('ready', function () {
var out = subject.get('button_test', 123)
expect(out).to.be.fulfilled
resolve(null)
})
var result = subject.get('button_test', 123)
return expect(result).to.be.fulfilled
})
.then(function () {
return subject.update('button_test', {
id: 49
})
.then(function () {
var result = subject.get('button_test', 123)
return expect(result).to.be.rejected
})
})
})
it('should be able to update a key with a range', function () {
subject.val_to_percent.returns(50)
return subject.handler('experiment', {
groupA: {
percentage: 100
},
groupB: {
percentage: 0
}
})
.then(function() {
var result = subject.get('experiment', 123)
return expect(result).to.eventually.equal('groupA')
})
.then(function () {
return new Promise(function(resolve) {
subject.update('button_test', {
id: 49
})
.then(function () {
var out = subject.get('button_test', 123)
expect(out).to.be.rejected.notify(resolve)
})
return subject.update('experiment', {
groupA: { min: 0, max: 49 },
groupB: { min: 49, max: 100 }
})
.then(function () {
var result = subject.get('experiment', 123)
return expect(result).to.eventually.equal('groupB')
})
})
.then(done)
})
it('is optimistic', function (done) {
it('is optimistic', function () {
subject.val_to_percent.returns(49)
subject.handler('super_secret', {
return subject.handler('super_secret', {
id: {

@@ -268,9 +383,8 @@ // give feature to 49% of users

})
subject.on('ready', function () {
var out = subject.get('super_secret', 123, {
.then(function () {
var result = subject.get('super_secret', 123, {
employee: 'regular@gmail.com'
})
// is rejected by company email, but falls within allowed regular users
expect(out).to.be.fulfilled.notify(done)
return expect(result).to.eventually.equal('id')
})

@@ -277,0 +391,0 @@ })