UNPKG

good-env

Version:

Better environment variable handling for Twelve-Factor node apps

455 lines (417 loc) 14.1 kB
'use strict'; const { isIP } = require('node:net'); const { URL } = require('node:url'); const { makeGoodUrl } = require('./lib/url-types'); const ok = x => !!x; const isArray = Array.isArray; const is = x => Object.prototype.toString.call(x); const isString = x => is(x) === '[object String]'; const isNumber = x => is(x) === '[object Number]'; const isBoolean = x => is(x) === '[object Boolean]'; const isObject = x => is(x) === '[object Object]'; const isFunction = x => is(x) === '[object Function]'; const mapNums = items => items.map(t => parseFloat(t)); const validType = item => ['number', 'boolean', 'string'].includes(item); const DURATION_UNITS = { ms: 1, s: 1000, m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000, w: 7 * 24 * 60 * 60 * 1000 }; const DURATION_RE = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/i; const parseDuration = input => { if (isNumber(input)) return Number.isFinite(input) ? input : null; if (!isString(input)) return null; const match = input.trim().match(DURATION_RE); if (!match) return null; return parseFloat(match[1]) * DURATION_UNITS[match[2].toLowerCase()]; }; const parseDate = input => { if (input instanceof Date) { return Number.isFinite(input.getTime()) ? input : null; } if (!isString(input)) return null; const d = new Date(input.trim()); return Number.isFinite(d.getTime()) ? d : null; }; const store = { ...process.env }; module.exports = Object .create({ set (key, value) { process.env[key] = value; store[key] = value; }, async use (awsSecretsManager, secretId) { const { SecretsManagerClient, GetSecretValueCommand } = awsSecretsManager; const client = new SecretsManagerClient({ region: process.env.AWS_REGION || 'us-east-1' }); if (!secretId) { secretId = this.get(['AWS_SECRET_ID', 'SECRET_ID']); } if (!secretId) { throw new Error('\'secretId\' was not specified, and it wasn\'t found as \'AWS_SECRET_ID\' or \'SECRET_ID\' in the environment.'); } const response = await client.send( new GetSecretValueCommand({ SecretId: secretId, VersionStage: 'AWSCURRENT' // VersionStage defaults to AWSCURRENT if unspecified }) ); const secretStr = response.SecretString; const secret = JSON.parse(secretStr); Object.entries(secret).forEach(([key, value]) => { this.set(key, value); }); }, /** * @description Fetches an IP address from the environment. If the value found under the specified key is not a valid IPv4 * or IPv6 IP and there's no default value, null is returned. If a default value is provided and it is a valid IPv4 or IPv6 * IP, the default value is retruned. If the default value is not valid, null is returned. This function won't return a value * that is not a valid IP address. * @param {string} key - A unique key that points to the IP address * @param {string} defaultVal - The default IP address to be used if the key isn't found * @returns */ getIP (key, defaultVal) { const strIP = this.get(key); if (!strIP) { if (!isIP(defaultVal)) { return null; } else { return defaultVal; } } if (isIP(strIP)) return strIP; if (isIP(defaultVal)) return defaultVal; return null; }, /** * @description Fetches three commonly used AWS environment variables - access key id, secret access key and region. * Note: You can only pass in a default region. No defaults for access key id or access key will be honored. This also * function assumes the standard AWS naming convention being used. * @param {object} defaults * @returns {object} */ getAWS ({ region } = {}) { const { AWS_ACCESS_KEY_ID: awsKeyId, AWS_SECRET_ACCESS_KEY: awsSecretAccessKey, AWS_SESSION_TOKEN: awsSessionToken, AWS_REGION: awsRegion } = this.getAll({ AWS_ACCESS_KEY_ID: null, AWS_SECRET_ACCESS_KEY: null, AWS_SESSION_TOKEN: null, AWS_REGION: region }); return { awsKeyId, awsSecretAccessKey, awsSessionToken, awsRegion }; }, /** * @description Finds the URL string in the environment associated with the given key. If * it's found, the function tries to construct a URL object. If the URL is invalid, return null. * If the URL is valid, return the URL object. If the key is not found and a default value is * passed, we try to construct a URL using the default value. If anything goes wrong with the * construction of the URL, return null. * * @param {string} key - A unique key that points to the URL * @param {string} defaultVal - The default URL to be used if the key isn't found * @returns {object} */ getUrl (key, defaultVal) { const urlStr = this.get(key, defaultVal); try { return makeGoodUrl({ url: new URL(urlStr) }); } catch (e) { return null; } }, /** * @description This is the shortcut function for the getUrl function * @returns {object} */ url (key, defaultVal) { return this.getUrl(key, defaultVal); }, /** * @description Fetches the env var with the given key. If no env var * with the specified key exists, the default value is returned if it is * provided else it returns undefined * * @param {(string|string[])} keyObj - A unique key for an item or a list of possible keys * @param {(string|number)} defaultVal - The default value of an item if it doesn't * already exist * */ get (keyObj, defaultVal) { let keys; let value; if (isString(keyObj)) { keys = [keyObj]; } else if (isArray(keyObj)) { keys = keyObj.map(k => k.trim()); } else { throw Error(`Invalid key(s) ${keyObj}`); } keys.some(key => { if (ok(store[key])) { value = store[key]; return true; } return false; }); if (!ok(value) && ok(defaultVal)) { value = defaultVal; } return (isString(value)) ? value.trim() : value; }, /** * @description Gets all items specified in the object. If the item is an * array, the function will perform a standard get with no defaults. If the * item is an object {}, the function will use the values as defaults - * null values will be treated as no default specified * * @param {string[]} items - An array of keys * */ getAll (items) { const objReducer = (obj, getter) => ( Object.keys(obj).reduce((prev, next, index) => { prev[next] = getter(next, obj[next]); return prev; }, {}) ); const arrMapper = (keys, getter) => keys.map(key => getter(key)); if (isArray(items)) { return arrMapper(items, this.get); } else if (isObject(items)) { return objReducer(items, this.get); } else { throw Error(`Invalid arg ${items}`); } }, /** * @description Determines whether or not all of the values given key is * truthy * * @param {(string|string[])} keys - A unique key or array of keys * */ ok: (...keys) => keys.every(key => ok(store[key])), /** * @description This method ensures 1 to many environment variables either * exist, or exist and are of a designated type * * @example * ensure( * // Will ensure 'HOSTNAME' exists * 'HOSTNAME', * // Will ensure 'PORT' both exists and is a number * { 'PORT': { type: 'number' }}, * // Will ensure 'INTERVAL' exists, it's a number and its value is greater * // than or equal to 1000 * { 'INTERVAL': { type: 'number', ok: s => s >= 1000 }} * // ... * ) * */ ensure (...items) { const self = this; const getKit = item => { switch (item) { case 'string': return { validator: isString, getter: self.get.bind(self) }; case 'number': return { validator: isNumber, getter: self.getNumber.bind(self) }; case 'boolean': return { validator: isBoolean, getter: self.getBool.bind(self) }; /* istanbul ignore next */ default: throw Error(`Invalid type "${item}"`); } }; return items.every(item => { if (isString(item)) { if (this.ok(item)) { return true; } else { throw Error(`No environment configuration for var "${item}"`); } } else if (isObject(item)) { const key = Object.keys(item)[0]; const type = item[key].type; const validator = item[key].ok; /* istanbul ignore if */ if (type && !validType(type)) { throw Error(`Invalid expected type "${type}"`); } else { const kit = getKit(type); const val = kit.getter(key); const result = kit.validator(val); if (!result) { throw Error(`Unexpected result for key="${key}". It may not exist or may not be a valid "${type}"`); } if (validator && isFunction(validator)) { const valid = validator(val); if (!valid) { throw Error(`Value ${val} did not pass validator function for key "${key}"`); } } return true; } } else { throw Error(`Invalid key ${item}`); } }); }, /** * An alias for .ensure() */ assert (...items) { return this.ensure(...items); }, /** * @description Fetches the value at the given key and attempts to coerce * it into a boolean * * @param {string} key - A unique key * @param {boolean} defaultVal - The default value * */ getBool (key, defaultVal) { let value; value = store[key]; if (ok(value)) { value = value.toLowerCase().trim(); if (value === 'true') { return true; } else if (value === 'false') { return false; } throw new Error(`${value} is not a boolean`); } else if (defaultVal === true || defaultVal === false) { return defaultVal; } return false; }, /** * @description An alias function for getBool() * * @param {string} key - A unique key * @param {boolean} defaultVal - The default value if none exists * */ bool (key, defaultVal) { return this.getBool(key, defaultVal); }, /** * @description Fetches the value at the given key and attempts to * coerce it into an integer * * @param {string} key - A unique key * @param {number} defaultVal - The default value * */ getNumber (key, defaultVal) { const value = this.get(key, defaultVal); if (value === defaultVal) return value; const num = parseFloat(value); if (!isNaN(num)) return num; }, /** * @description An alias function for getNumber() * */ num (key, defaultVal) { return this.getNumber(key, defaultVal); }, /** * @description Fetches the value at the given key and parses it as a * duration string (e.g. "500ms", "30s", "5m", "2h", "1d", "1w") into * milliseconds. The unit is case-insensitive and whitespace between * the number and unit is allowed. If the env value is invalid, the * default is parsed instead. Numeric defaults are treated as * milliseconds. Returns null if nothing can be resolved. * * @param {string} key - A unique key * @param {(string|number)} defaultVal - A duration string or a number of ms * */ getDuration (key, defaultVal) { const parsed = parseDuration(this.get(key)); if (parsed !== null) return parsed; const parsedDefault = parseDuration(defaultVal); if (parsedDefault !== null) return parsedDefault; return null; }, /** * @description An alias function for getDuration() * */ duration (key, defaultVal) { return this.getDuration(key, defaultVal); }, /** * @description Fetches the value at the given key and parses it into * a Date. Follows JavaScript's Date constructor rules (ISO 8601 is * recommended; strings without a timezone fall back to local time). * Accepts string values or Date instances as the default. Returns * null if the env value is invalid and the default doesn't resolve. * * @param {string} key - A unique key * @param {(string|Date)} defaultVal - A date string or Date instance * */ getDate (key, defaultVal) { const parsed = parseDate(this.get(key)); if (parsed !== null) return parsed; const parsedDefault = parseDate(defaultVal); if (parsedDefault !== null) return parsedDefault; return null; }, /** * @description An alias function for getDate() * */ date (key, defaultVal) { return this.getDate(key, defaultVal); }, /** * @description Fetches the value at the given key and attempts to * coerce it into a list of literal values * * @param {string} key - A unique key * @param {object} options * */ getList (key, opts = { dilim: ',', cast: null }) { const { dilim, cast } = opts; const value = this.get(key, []); if (!isArray(value)) { let ret = value.split(dilim).map(i => i.trim()); if (cast && cast === 'number') { ret = mapNums(ret); } return ret; } else { return value; } }, /** * @description An alias function for getList() * * @param {string} key - A unique key * @param {object} options * */ list (key, opts) { return this.getList(key, opts); } });