From cb5db30cf672457fca96efc5c2594e7171c7361f Mon Sep 17 00:00:00 2001 From: recursivefunk Date: Tue, 21 Apr 2026 21:00:00 -0400 Subject: [PATCH] Add getDuration for parsing time strings to milliseconds Introduces getDuration(key, defaultVal) and the duration() alias, which parse values like "500ms", "30s", "5m", "2h", "1d", "1w" into milliseconds. Units are case-insensitive, decimals are supported, and whitespace between the number and unit is allowed. When the env value is missing or unparseable the default is parsed (numeric defaults are treated as ms). Returns null when neither resolves. Also: - TypeScript declarations for getDuration and duration. - Test fixtures and tape tests covering every unit, decimals, case insensitivity, whitespace, invalid values, default fallback, and the alias. - README: new example in the Type Conversion section. - Bumps minor to 7.7.0 (additive API). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 12 ++++++++++++ package.json | 2 +- src/index.d.ts | 18 ++++++++++++++++++ src/index.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ test/test.env | 10 ++++++++++ test/test.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 131 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0cf7242..5b43196 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,18 @@ env.list('ALLOWED_ORIGINS'); // Shorthand for getList() // Get a numeric list env.list('VALUES', { cast: 'number' }); // [1, 2, 3] (converts from '1,2,3') + +// Get a duration in milliseconds +// CACHE_TTL=5m +env.getDuration('CACHE_TTL'); // 300000 +env.duration('CACHE_TTL'); // Shorthand for getDuration() + +// With a default — strings are parsed, numbers are treated as ms +env.getDuration('CACHE_TTL', '30s'); // 30000 if CACHE_TTL is not set +env.getDuration('CACHE_TTL', 1000); // 1000 if CACHE_TTL is not set + +// Supported units: ms, s, m, h, d, w (case-insensitive, decimals allowed) +// Returns null if the value and default are both unparseable. ``` #### URLs and IPs diff --git a/package.json b/package.json index 803e143..04e2b48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "good-env", - "version": "7.6.3", + "version": "7.7.0", "description": "Better environment variable handling for Twelve-Factor node apps", "main": "src/index.js", "scripts": { diff --git a/src/index.d.ts b/src/index.d.ts index a4760c1..7652a84 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -125,6 +125,24 @@ declare module "good-env" { * */ export const num: (key: any, defaultVal?: any) => any; + /** + * @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 number of ms + * + */ + export const getDuration: (key: string, defaultVal?: string | number) => number | null; + /** + * @description An alias function for getDuration() + * + */ + export const duration: (key: string, defaultVal?: string | number) => number | null; /** * @description Fetches the value at the given key and attempts to * coherse it into a list of literal values diff --git a/src/index.js b/src/index.js index 950ad2b..c2300c6 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,22 @@ 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 store = { ...process.env }; module.exports = Object @@ -339,6 +355,34 @@ module.exports = Object 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 attempts to * coerce it into a list of literal values diff --git a/test/test.env b/test/test.env index 07eca01..46be7d5 100644 --- a/test/test.env +++ b/test/test.env @@ -20,3 +20,13 @@ AWS_REGION=us-east-1 AWS_SESSION_TOKEN=session VALID_IP=192.168.1.60 INVALID_IP=nope +DURATION_MS=500ms +DURATION_S=30s +DURATION_M=5m +DURATION_H=2h +DURATION_D=1d +DURATION_W=1w +DURATION_FLOAT=1.5h +DURATION_UPPER=10S +DURATION_SPACED=5 m +DURATION_INVALID=nope diff --git a/test/test.js b/test/test.js index 43ec26e..e66f864 100644 --- a/test/test.js +++ b/test/test.js @@ -279,6 +279,52 @@ test('returns undefined for exequalsting non-number', (t) => { t.end(); }); +test('getDuration parses every supported unit', (t) => { + t.equals(env.getDuration('DURATION_MS'), 500); + t.equals(env.getDuration('DURATION_S'), 30 * 1000); + t.equals(env.getDuration('DURATION_M'), 5 * 60 * 1000); + t.equals(env.getDuration('DURATION_H'), 2 * 60 * 60 * 1000); + t.equals(env.getDuration('DURATION_D'), 24 * 60 * 60 * 1000); + t.equals(env.getDuration('DURATION_W'), 7 * 24 * 60 * 60 * 1000); + t.end(); +}); + +test('getDuration parses decimals, is case-insensitive, allows whitespace', (t) => { + t.equals(env.getDuration('DURATION_FLOAT'), 1.5 * 60 * 60 * 1000); + t.equals(env.getDuration('DURATION_UPPER'), 10 * 1000); + t.equals(env.getDuration('DURATION_SPACED'), 5 * 60 * 1000); + t.end(); +}); + +test('getDuration returns null for invalid env values', (t) => { + t.equals(env.getDuration('DURATION_INVALID'), null); + t.end(); +}); + +test('getDuration falls back to default when env value is invalid', (t) => { + t.equals(env.getDuration('DURATION_INVALID', '2s'), 2000); + t.end(); +}); + +test('getDuration resolves defaults for missing keys', (t) => { + t.equals(env.getDuration('DURATION_NOPE'), null); + t.equals(env.getDuration('DURATION_NOPE', '1m'), 60 * 1000); + t.equals(env.getDuration('DURATION_NOPE', 1234), 1234); + t.end(); +}); + +test('getDuration returns null for unparseable defaults', (t) => { + t.equals(env.getDuration('DURATION_NOPE', 'garbage'), null); + t.equals(env.getDuration('DURATION_NOPE', NaN), null); + t.equals(env.getDuration('DURATION_NOPE', {}), null); + t.end(); +}); + +test('duration() is an alias for getDuration()', (t) => { + t.equals(env.duration('DURATION_M'), 5 * 60 * 1000); + t.end(); +}); + test('returns a list of values', (t) => { let result = env.getList('MY_LIST'); t.equals(result.length, 3);