├── .babelrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── package.json ├── src └── index.js └── test ├── index.test.js └── mocha.opts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /build/ 3 | *.local.js 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Left empty in order to include the build directory for npm publish 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5.1" 4 | - "5.0" 5 | - "4.2" 6 | - "4.1" 7 | - "4.0" 8 | script: 9 | - npm test 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # deo 2 | 3 | [![npm version](https://img.shields.io/npm/v/deo.svg)](https://www.npmjs.com/package/deo) [![License](https://img.shields.io/npm/l/deo.svg)](https://www.npmjs.com/package/deo) [![Build Status](https://travis-ci.org/producthunt/deo.svg)](https://travis-ci.org/producthunt/deo) 4 | 5 | Minimalistic, safe and easy to use 12factor config manager for Node.js 6 | 7 | ## Table of Contents 8 | 9 | 1. [Installation](#installation) 10 | 1. [Motivation](#motivation) 11 | 1. [Usage](#usage) 12 | 1. [Spec](#spec) 13 | 1. [Development](#development) 14 | 1. [Contributing](#contributing) 15 | 1. [License](#license) 16 | 17 | ## Installation 18 | 19 | ``` 20 | $ npm install deo --save 21 | ``` 22 | 23 | ## Motivation 24 | 25 | > The twelve-factor app stores config in environment variables (often shortened 26 | > to env vars or env). Env vars are easy to change between deploys without 27 | > changing any code; unlike config files, there is little chance of them being 28 | > checked into the code repo accidentally; and unlike custom config files, or 29 | > other config mechanisms such as Java System Properties, they are a language- 30 | > and OS-agnostic standard. 31 | 32 | However, there are a couple of things you must consider before you start 33 | referring to `process.env` all over the place: 34 | 35 | - Spreading the knowledge on where your config values are coming from is not great. Ideally, we want to encapsulate this in a single place. 36 | - `process.env` does not guarantee that the given environment variable will exist, you have to do this manually 37 | - People often end up with `.env.example` files, documents describing everything 38 | that needs to be set so you can run the app etc. 39 | - Some config values are almost static, they are not sensitive, they do not change 40 | in the different environments, however we still refer to them as config. So we 41 | end up either putting them inside the env vars or creating a different "special" config objects 42 | 43 | `deo` can will help you deal with those problems and more. Here is how: 44 | 45 | - Safe by design: every specified config entry must be set, otherwise it will 46 | throw an exception 47 | - It makes the entire config immutable, therefore you cannot incidentally 48 | reassign a config value. Trying to do so will result in an exception. 49 | - Encapsulates the knowledge about where your config data is coming from. 50 | - It comes with a minimalistic API, there is only one function that you can 51 | call, therefore it's very easy to stub/mock or replace, if necessary, with an alternative solution 52 | 53 | ## Usage 54 | 55 | Let's start with the following simple express app: 56 | 57 | ```js 58 | import express from 'express' 59 | import cookieParser from 'cookie-parser' 60 | 61 | const app = express() 62 | 63 | app.use(cookieParser(process.env.SECRET)) 64 | 65 | app.get('/', (req, res) => { 66 | res.send('Hello World') 67 | }) 68 | 69 | app.listen(process.env.PORT, process.env.HOSTNAME) 70 | ``` 71 | 72 | The app requires you to specify: 73 | 74 | - cookie secret 75 | - port 76 | - hostname 77 | 78 | This is very obvious, since our app is quite simple and just in a single file. 79 | Let's say we decided to refactor it a little bit and extract the configurations 80 | into another file, since a couple of weeks later, the app config is all over the 81 | place: 82 | 83 | `config.js`: 84 | ```js 85 | export default { 86 | port: process.env.PORT, 87 | hostname: process.env.HOSTNAME, 88 | secret: process.env.SECRET, 89 | } 90 | ``` 91 | 92 | And then our app becomes: 93 | 94 | ```js 95 | import config from './config' 96 | 97 | // ... 98 | 99 | app.use(cookieParser(config.secret)) 100 | 101 | // ... 102 | 103 | app.listen(config.port, config.hostname) 104 | ``` 105 | 106 | Very nice! However, what if we want to enforce the presence of let's say secret? 107 | Easy! 108 | 109 | ```js 110 | const secret = process.env.SECRET; 111 | 112 | if (!secret) throw new Error('oops, secret is required!') 113 | 114 | export default { 115 | port: process.env.PORT, 116 | hostname: process.env.HOSTNAME, 117 | secret, 118 | } 119 | ``` 120 | 121 | OK... so, we solve this issue, but now we have to remember to do it for every single 122 | required entry... 123 | 124 | A couple of week later happily using our config, it turns out that the 125 | `process.env.SECRET` on staging is not what we have configured. We start 126 | investigating and we find the following code: 127 | 128 | ```js 129 | // NOTE: temporairly override for testing 130 | config.secret = 'test'; 131 | ``` 132 | 133 | Oh, boy! How did we commit that without noticing? We are now eager to fix this 134 | issue by making our config immutable: 135 | 136 | ```js 137 | const secret = process.env.SECRET; 138 | 139 | if (!secret) throw new Error('oops, secret is required!') 140 | 141 | const config = { 142 | secret, 143 | port: process.env.PORT, 144 | hostname: process.env.HOSTNAME, 145 | } 146 | 147 | Object.freeze(config) 148 | 149 | export default config 150 | ``` 151 | 152 | Woohoo! We just resolved our issue... except, we totally forgot that 153 | `Object.freeze` is shallow, and if we add another object inside `config` 154 | it will not be frozen. Duh... 155 | 156 | Here is how we can replace our implementation with `deo`, that will take care of 157 | immutability, constancy and will force us to set all config entires: 158 | 159 | ```js 160 | export default deo({ 161 | server: { 162 | hostname: 'localhost', // default value, replace with the SERVER_HOSTNAME env var 163 | port: 4000, // default value, replace with the SERVER_PORT env var 164 | }, 165 | secret: null, // no default, it will force us to set it, otherwise will throw 166 | }) 167 | ``` 168 | 169 | And our app: 170 | 171 | ```js 172 | import config from './config' 173 | 174 | // ... 175 | 176 | app.use(cookieParser(config('secret'))) 177 | 178 | // ... 179 | 180 | app.listen(config('server.port'), config('server.host')) 181 | ``` 182 | 183 | And that's it, we are good to go! 184 | 185 | **`deo` also works great with envc, dotenv and other `.env` file loaders**: 186 | 187 | `.env`: 188 | 189 | ```shell 190 | PORT=4000 191 | ``` 192 | 193 | `index.js`: 194 | 195 | ```js 196 | import envc from 'envc' 197 | 198 | envc() // this will load .env in `process.env` 199 | 200 | const config = deo({ 201 | port: 3000 202 | }) 203 | 204 | console.log(config('port')) // => 4000 205 | ``` 206 | 207 | **You can also provide a custom env object different from process.env:** 208 | 209 | ```js 210 | const customEnv = { 211 | FOO: 3, 212 | } 213 | 214 | const config = deo({ 215 | foo: 0, 216 | }, customEnv) 217 | 218 | console.log(config('foo')) // => 3 219 | ``` 220 | 221 | ## Development 222 | 223 | #### Setup 224 | 225 | ```shell 226 | $ git clone 227 | $ cd deo 228 | $ npm install 229 | ``` 230 | 231 | #### Tests 232 | 233 | Linters: 234 | 235 | ```shell 236 | $ npm run test:lint 237 | ``` 238 | 239 | Tests: 240 | 241 | ```shell 242 | $ npm run test:tests 243 | ``` 244 | 245 | All: 246 | 247 | ```shell 248 | $ npm test 249 | ``` 250 | 251 | ## Contributing 252 | 253 | Bug reports and pull requests are welcome on GitHub. This project is intended to be a 254 | safe, welcoming space for collaboration, and contributors are expected to adhere 255 | to the [Contributor Covenant](http://contributor-covenant.org/) code of conduct. 256 | 257 | ## License 258 | 259 | [![Product Hunt](http://i.imgur.com/dtAr7wC.png)](https://www.producthunt.com) 260 | 261 | ``` 262 | _________________ 263 | < The MIT License > 264 | ----------------- 265 | \ ^__^ 266 | \ (oo)\_______ 267 | (__)\ )\/\ 268 | ||----w | 269 | || || 270 | ``` 271 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deo", 3 | "version": "0.0.2", 4 | "description": "12factor config manager", 5 | "homepage": "https://github.com/producthunt/deo", 6 | "author": { 7 | "name": "Veselin Todorov", 8 | "email": "hi@vesln.com", 9 | "url": "https://github.com/vesln" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git://github.com/producthunt/deo.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/producthunt/deo/issues" 17 | }, 18 | "main": "build/index.js", 19 | "scripts": { 20 | "prepublish": "npm run clean && npm run build", 21 | "build": "babel src --out-dir build", 22 | "clean": "rimraf build", 23 | "test:lint": "standard --verbose | snazzy", 24 | "test:unit": "NODE_ENV=test mocha test/**/*.test.js", 25 | "test": "npm run test:lint && npm run test:unit" 26 | }, 27 | "keywords": [ 28 | "config", 29 | "configurations", 30 | "env", 31 | "envc", 32 | "dotenv", 33 | "12 factor" 34 | ], 35 | "license": "MIT", 36 | "devDependencies": { 37 | "babel-cli": "^6.3.17", 38 | "babel-eslint": "^5.0.0-beta6", 39 | "babel-preset-es2015": "^6.3.13", 40 | "babel-register": "^6.3.13", 41 | "chai": "^3.4.1", 42 | "mocha": "^2.3.4", 43 | "rimraf": "^2.5.0", 44 | "snazzy": "^2.0.1", 45 | "standard": "^5.4.1" 46 | }, 47 | "standard": { 48 | "parser": "babel-eslint", 49 | "global": [ 50 | "describe", 51 | "it", 52 | "before", 53 | "beforeEach", 54 | "after", 55 | "afterEach", 56 | "expect" 57 | ] 58 | }, 59 | "dependencies": { 60 | "immu": "^2.0.1", 61 | "pathval": "^0.1.1" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import val from 'pathval' 2 | import immu from 'immu' 3 | 4 | /** 5 | * Check if `obj` is an object. 6 | * 7 | * @returns {Boolean} 8 | * @private 9 | */ 10 | 11 | function isObject (obj) { 12 | return typeof obj === 'object' && obj !== null 13 | } 14 | 15 | /** 16 | * Merge the default values and the enviornment values. 17 | * 18 | * @param {Object} spec - the default values 19 | * @param {Object} env - the environment object 20 | * @param {String} [previous] - the previous path 21 | * @returns {Object} 22 | * @private 23 | */ 24 | 25 | function merge (spec, env, previous) { 26 | let ret = Object.create({}) 27 | 28 | Object.keys(spec).forEach((key) => { 29 | const val = spec[key] 30 | const path = previous ? `${previous}_${key}` : key 31 | 32 | ret[key] = isObject(val) 33 | ? merge(val, env, path) 34 | : env[path.toUpperCase()] || spec[key] 35 | 36 | if (ret[key] == null) { 37 | const configKey = path.replace(/_/g, '.') 38 | throw new TypeError(`'${configKey}' must be set!`) 39 | } 40 | }) 41 | 42 | return ret 43 | } 44 | 45 | /** 46 | * Create a config getter. 47 | * 48 | * @param {Object} values - default values 49 | * @param {Object} [env] - environment variables 50 | * @returns {Function} 51 | * @public 52 | */ 53 | 54 | export default function createConfig (values, env = process.env) { 55 | const data = immu(merge(values, env)) 56 | 57 | return function config (key) { 58 | const value = val.get(data, key) 59 | 60 | if (value == null) { 61 | throw new TypeError(`'${key}' is not set!`) 62 | } 63 | 64 | return value 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | 3 | import deo from '../src' 4 | 5 | describe('deo', () => { 6 | it('returns the default value if the key is not present in the env', () => { 7 | const config = deo({ foo: true }) 8 | expect(config('foo')).to.equal(true) 9 | }) 10 | 11 | it('throws an error if the key does not exist', () => { 12 | const config = deo({}) 13 | 14 | expect(() => { 15 | config('foo') 16 | }).to.throw("'foo' is not set!") 17 | }) 18 | 19 | it('throws an error if any key is null or undefined', () => { 20 | expect(() => { 21 | deo({ foo: { bar: null } }) 22 | }).to.throw("'foo.bar' must be set!") 23 | }) 24 | 25 | it('returns an immutable structure', () => { 26 | const config = deo({ 27 | foo: { bar: 0 } 28 | }) 29 | 30 | const value = config('foo') 31 | 32 | expect(() => { 33 | value.bar = 1 34 | }).to.throw('Cannot change value "bar" to "1" of an immutable property') 35 | }) 36 | 37 | it('can lookup nested keys', () => { 38 | const config = deo({ 39 | foo: { bar: 0 } 40 | }) 41 | 42 | expect(config('foo.bar')).to.equal(0) 43 | }) 44 | 45 | it('returns the env value if the key is present in the env', () => { 46 | const config = deo( 47 | { foo: 'default', bar: { baz: 4, boo: { boo: 3 } } }, 48 | { FOO: 'env', BAR_BAZ: 5, BAR_BOO_BOO: 4 } 49 | ) 50 | 51 | expect(config('foo')).to.equal('env') 52 | expect(config('bar.baz')).to.equal(5) 53 | expect(config('bar.boo.boo')).to.equal(4) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --full-trace 2 | --compilers js:babel-register 3 | --slow 1000 4 | --timeout 1500 5 | --reporter spec 6 | --------------------------------------------------------------------------------