├── .npmrc ├── .gitattributes ├── .gitignore ├── .editorconfig ├── .github └── workflows │ └── main.yml ├── package.json ├── license ├── index.d.ts ├── index.js ├── readme.md └── test.js /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 24 14 | - 20 15 | steps: 16 | - uses: actions/checkout@v5 17 | - uses: actions/setup-node@v6 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parse-duration-ms", 3 | "version": "0.1.0", 4 | "description": "Parse duration strings to milliseconds", 5 | "license": "MIT", 6 | "repository": "sindresorhus/parse-duration-ms", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": { 15 | "types": "./index.d.ts", 16 | "default": "./index.js" 17 | }, 18 | "sideEffects": false, 19 | "engines": { 20 | "node": ">=20" 21 | }, 22 | "scripts": { 23 | "test": "xo && node --test" 24 | }, 25 | "files": [ 26 | "index.js", 27 | "index.d.ts" 28 | ], 29 | "keywords": [ 30 | "parse", 31 | "duration", 32 | "time", 33 | "milliseconds", 34 | "ms", 35 | "convert", 36 | "parser", 37 | "human", 38 | "readable", 39 | "string", 40 | "text", 41 | "hours", 42 | "minutes", 43 | "seconds", 44 | "days" 45 | ], 46 | "devDependencies": { 47 | "xo": "^1.2.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | Parse a duration string to milliseconds. 3 | 4 | @param input - The duration string to parse (e.g., `'1h'`, `'90m'`, `'2 days 5 hours'`). 5 | @returns The duration in milliseconds, or `undefined` if the input is invalid. 6 | @throws {TypeError} If input is not a string. 7 | 8 | Supported units: 9 | - Nanoseconds: `ns`, `nsec`, `nsecs`, `nanosecond`, `nanoseconds` 10 | - Milliseconds: `ms`, `msec`, `msecs`, `millisecond`, `milliseconds` 11 | - Seconds: `s`, `sec`, `secs`, `second`, `seconds` 12 | - Minutes: `m`, `min`, `mins`, `minute`, `minutes` 13 | - Hours: `h`, `hr`, `hrs`, `hour`, `hours` 14 | - Days: `d`, `day`, `days` 15 | - Weeks: `w`, `week`, `weeks` 16 | 17 | @example 18 | ``` 19 | import parseDuration from 'parse-duration-ms'; 20 | 21 | parseDuration('1h'); 22 | //=> 3600000 23 | 24 | parseDuration('90m'); 25 | //=> 5400000 26 | 27 | parseDuration('2 days 5 hours 30 minutes'); 28 | //=> 192600000 29 | 30 | parseDuration('1hr 30min'); 31 | //=> 5400000 32 | 33 | parseDuration('500ms'); 34 | //=> 500 35 | 36 | parseDuration('1.5 hours'); 37 | //=> 5400000 38 | 39 | parseDuration('invalid'); 40 | //=> undefined 41 | ``` 42 | */ 43 | export default function parseDuration(input: string): number | undefined; 44 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const unitDefinitions = [ 2 | {milliseconds: 1e-6, names: ['nanoseconds', 'nanosecond', 'nsecs', 'nsec', 'ns']}, 3 | {milliseconds: 1, names: ['milliseconds', 'millisecond', 'msecs', 'msec', 'ms']}, 4 | {milliseconds: 1000, names: ['seconds', 'second', 'secs', 'sec', 's']}, 5 | {milliseconds: 60_000, names: ['minutes', 'minute', 'mins', 'min', 'm']}, 6 | {milliseconds: 3_600_000, names: ['hours', 'hour', 'hrs', 'hr', 'h']}, 7 | {milliseconds: 86_400_000, names: ['days', 'day', 'd']}, 8 | {milliseconds: 604_800_000, names: ['weeks', 'week', 'w']}, 9 | ]; 10 | 11 | const unitToMilliseconds = {}; 12 | for (const {milliseconds, names} of unitDefinitions) { 13 | for (const name of names) { 14 | unitToMilliseconds[name] = milliseconds; 15 | } 16 | } 17 | 18 | const unitNames = Object.keys(unitToMilliseconds).sort((a, b) => b.length - a.length); 19 | const unitPattern = unitNames.join('|'); 20 | const valuePattern = String.raw`[+-]?(?:\d+\.\d+|\d+|\.\d+)`; 21 | 22 | const validationPattern = new RegExp(String.raw`^\s*(?:${valuePattern}\s*(?:${unitPattern})\s*)+$`); 23 | const extractionPattern = new RegExp(String.raw`(?${valuePattern})\s*(?${unitPattern})`, 'g'); 24 | 25 | export default function parseDuration(input) { 26 | if (typeof input !== 'string') { 27 | throw new TypeError(`Expected a string, got \`${typeof input}\``); 28 | } 29 | 30 | const normalizedInput = input.trim().toLowerCase(); 31 | 32 | if (!normalizedInput || !validationPattern.test(normalizedInput)) { 33 | return undefined; 34 | } 35 | 36 | let totalMilliseconds = 0; 37 | 38 | for (const match of normalizedInput.matchAll(extractionPattern)) { 39 | const {value, unit} = match.groups; 40 | const numericValue = Number.parseFloat(value); 41 | 42 | if (!Number.isFinite(numericValue)) { 43 | return undefined; 44 | } 45 | 46 | totalMilliseconds += numericValue * unitToMilliseconds[unit]; 47 | } 48 | 49 | return totalMilliseconds; 50 | } 51 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # parse-duration-ms 2 | 3 | > Parse duration strings to milliseconds 4 | 5 | Useful for parsing timeout values, cache TTLs, rate limits, and other duration-based configuration in a human-friendly format. 6 | 7 | See [`pretty-ms`](https://github.com/sindresorhus/pretty-ms) for the inverse. 8 | 9 | ## Install 10 | 11 | ```sh 12 | npm install parse-duration-ms 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```js 18 | import parseDuration from 'parse-duration-ms'; 19 | 20 | parseDuration('1h'); 21 | //=> 3600000 22 | 23 | parseDuration('90m'); 24 | //=> 5400000 25 | 26 | parseDuration('2 days 5 hours 30 minutes'); 27 | //=> 192600000 28 | 29 | parseDuration('1hr 30min'); 30 | //=> 5400000 31 | 32 | parseDuration('500ms'); 33 | //=> 500 34 | 35 | parseDuration('1.5 hours'); 36 | //=> 5400000 37 | 38 | parseDuration('1h41m'); 39 | //=> 6060000 40 | 41 | parseDuration('invalid'); 42 | //=> undefined 43 | ``` 44 | 45 | ## API 46 | 47 | ### parseDuration(input) 48 | 49 | Parses a duration string to milliseconds. 50 | 51 | Returns `undefined` if the input is invalid. 52 | 53 | Throws a `TypeError` if the input is not a string. 54 | 55 | #### input 56 | 57 | Type: `string` 58 | 59 | The duration string to parse. 60 | 61 | **Features:** 62 | 63 | - Multiple units: `'1h 30m'`, `'2d 5h 30m'`, `'1 hour 30 minutes'` 64 | - Shortened forms: `'1hr 30min'`, `'90mins'`, `'2w 3d'` 65 | - Decimal values: `'1.5h'`, `'0.5m'`, `'1.5 hours'` 66 | - With or without spaces: `'1h30m'`, `'1h 30m'`, `'1hour'`, `'1 hour'` 67 | - Case insensitive: `'1H'`, `'30M'`, `'1 HOUR'` 68 | - Negative values: `'-1h'`, `'-30m'` 69 | 70 | **Supported units:** 71 | 72 | | Unit | Short | Shorter | Long (singular) | Long (plural) | 73 | |------|-------|---------|-----------------|---------------| 74 | | Nanoseconds | `ns` | `nsec`, `nsecs` | `nanosecond` | `nanoseconds` | 75 | | Milliseconds | `ms` | `msec`, `msecs` | `millisecond` | `milliseconds` | 76 | | Seconds | `s` | `sec`, `secs` | `second` | `seconds` | 77 | | Minutes | `m` | `min`, `mins` | `minute` | `minutes` | 78 | | Hours | `h` | `hr`, `hrs` | `hour` | `hours` | 79 | | Days | `d` | - | `day` | `days` | 80 | | Weeks | `w` | - | `week` | `weeks` | 81 | 82 | ## FAQ 83 | 84 | ### Why no months/years? 85 | 86 | Months and years aren't fixed durations. They vary (28-31 days for months, 365-366 for years). Any approximation would be silently wrong in many cases. Be explicit instead: use `'30d'` for ~1 month or `'365d'` for ~1 year. 87 | 88 | ### What's the difference from `ms`? 89 | 90 | This package parses combined units like `'1h 30m'` and `'2 days 5 hours'`. The [`ms`](https://github.com/vercel/ms) package does bidirectional conversion but doesn't support combined units. 91 | 92 | ### Localization support? 93 | 94 | No. This keeps the package simple and small. 95 | 96 | ### Dates, timestamps, or time zones? 97 | 98 | No. This only parses relative durations (lengths of time), not absolute times. 99 | 100 | ## Related 101 | 102 | - [pretty-ms](https://github.com/sindresorhus/pretty-ms) - Convert milliseconds to a human readable string 103 | - [to-milliseconds](https://github.com/sindresorhus/to-milliseconds) - Convert an object of time properties to milliseconds 104 | - [parse-ms](https://github.com/sindresorhus/parse-ms) - Parse milliseconds into an object 105 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import {test} from 'node:test'; 2 | import assert from 'node:assert/strict'; 3 | import parseDuration from './index.js'; 4 | 5 | test('milliseconds', () => { 6 | assert.equal(parseDuration('100ms'), 100); 7 | assert.equal(parseDuration('500ms'), 500); 8 | assert.equal(parseDuration('1ms'), 1); 9 | }); 10 | 11 | test('seconds', () => { 12 | assert.equal(parseDuration('1s'), 1000); 13 | assert.equal(parseDuration('30s'), 30_000); 14 | assert.equal(parseDuration('60s'), 60_000); 15 | }); 16 | 17 | test('minutes', () => { 18 | assert.equal(parseDuration('1m'), 60_000); 19 | assert.equal(parseDuration('5m'), 300_000); 20 | assert.equal(parseDuration('30m'), 1_800_000); 21 | }); 22 | 23 | test('hours', () => { 24 | assert.equal(parseDuration('1h'), 3_600_000); 25 | assert.equal(parseDuration('2h'), 7_200_000); 26 | assert.equal(parseDuration('24h'), 86_400_000); 27 | }); 28 | 29 | test('days', () => { 30 | assert.equal(parseDuration('1d'), 86_400_000); 31 | assert.equal(parseDuration('2d'), 172_800_000); 32 | assert.equal(parseDuration('7d'), 604_800_000); 33 | }); 34 | 35 | test('weeks', () => { 36 | assert.equal(parseDuration('1w'), 604_800_000); 37 | assert.equal(parseDuration('2w'), 1_209_600_000); 38 | }); 39 | 40 | test('combined units with spaces', () => { 41 | assert.equal(parseDuration('1h 30m'), 5_400_000); 42 | assert.equal(parseDuration('2d 5h'), 190_800_000); 43 | assert.equal(parseDuration('1h 30m 45s'), 5_445_000); 44 | assert.equal(parseDuration('2d 5h 30m'), 192_600_000); 45 | assert.equal(parseDuration('1d 2h 3m 4s'), 93_784_000); 46 | }); 47 | 48 | test('combined units without spaces', () => { 49 | assert.equal(parseDuration('1h30m'), 5_400_000); 50 | assert.equal(parseDuration('2d5h'), 190_800_000); 51 | }); 52 | 53 | test('decimal values', () => { 54 | assert.equal(parseDuration('1.5h'), 5_400_000); 55 | assert.equal(parseDuration('0.5m'), 30_000); 56 | assert.equal(parseDuration('2.5d'), 216_000_000); 57 | assert.equal(parseDuration('.5h'), 1_800_000); 58 | }); 59 | 60 | test('case insensitive', () => { 61 | assert.equal(parseDuration('1H'), 3_600_000); 62 | assert.equal(parseDuration('1M'), 60_000); 63 | assert.equal(parseDuration('1S'), 1000); 64 | assert.equal(parseDuration('1D'), 86_400_000); 65 | assert.equal(parseDuration('100MS'), 100); 66 | }); 67 | 68 | test('negative values', () => { 69 | assert.equal(parseDuration('-1h'), -3_600_000); 70 | assert.equal(parseDuration('-30m'), -1_800_000); 71 | assert.equal(parseDuration('-1d 2h'), -79_200_000); 72 | }); 73 | 74 | test('zero values', () => { 75 | assert.equal(parseDuration('0s'), 0); 76 | assert.equal(parseDuration('0m'), 0); 77 | assert.equal(parseDuration('0h'), 0); 78 | }); 79 | 80 | test('whitespace handling', () => { 81 | assert.equal(parseDuration(' 1h '), 3_600_000); 82 | assert.equal(parseDuration('1h 30m'), 5_400_000); 83 | assert.equal(parseDuration('1h 30m'), 5_400_000); 84 | }); 85 | 86 | test('invalid input returns undefined', () => { 87 | assert.equal(parseDuration(''), undefined); 88 | assert.equal(parseDuration('invalid'), undefined); 89 | assert.equal(parseDuration('123'), undefined); 90 | assert.equal(parseDuration('h'), undefined); 91 | assert.equal(parseDuration('1x'), undefined); 92 | assert.equal(parseDuration('1h 2x'), undefined); 93 | }); 94 | 95 | test('invalid types throw TypeError', () => { 96 | assert.throws(() => parseDuration(null), TypeError); 97 | assert.throws(() => parseDuration(undefined), TypeError); 98 | assert.throws(() => parseDuration({}), TypeError); 99 | assert.throws(() => parseDuration([]), TypeError); 100 | assert.throws(() => parseDuration(true), TypeError); 101 | }); 102 | 103 | test('edge cases', () => { 104 | assert.equal(parseDuration('0.1s'), 100); 105 | assert.equal(parseDuration('0.01h'), 36_000); 106 | assert.equal(parseDuration('1000ms'), 1000); 107 | }); 108 | 109 | test('real-world examples', () => { 110 | assert.equal(parseDuration('90m'), 5_400_000); 111 | assert.equal(parseDuration('1h 41m'), 6_060_000); 112 | assert.equal(parseDuration('2h 30m 15s'), 9_015_000); 113 | }); 114 | 115 | test('long-form units: singular', () => { 116 | assert.equal(parseDuration('1 millisecond'), 1); 117 | assert.equal(parseDuration('1 second'), 1000); 118 | assert.equal(parseDuration('1 minute'), 60_000); 119 | assert.equal(parseDuration('1 hour'), 3_600_000); 120 | assert.equal(parseDuration('1 day'), 86_400_000); 121 | assert.equal(parseDuration('1 week'), 604_800_000); 122 | }); 123 | 124 | test('long-form units: plural', () => { 125 | assert.equal(parseDuration('100 milliseconds'), 100); 126 | assert.equal(parseDuration('30 seconds'), 30_000); 127 | assert.equal(parseDuration('5 minutes'), 300_000); 128 | assert.equal(parseDuration('2 hours'), 7_200_000); 129 | assert.equal(parseDuration('7 days'), 604_800_000); 130 | assert.equal(parseDuration('2 weeks'), 1_209_600_000); 131 | }); 132 | 133 | test('long-form units: combined', () => { 134 | assert.equal(parseDuration('1 hour 30 minutes'), 5_400_000); 135 | assert.equal(parseDuration('2 days 5 hours 30 minutes'), 192_600_000); 136 | assert.equal(parseDuration('1 week 2 days'), 777_600_000); 137 | }); 138 | 139 | test('long-form units: case insensitive', () => { 140 | assert.equal(parseDuration('1 HOUR'), 3_600_000); 141 | assert.equal(parseDuration('30 Minutes'), 1_800_000); 142 | assert.equal(parseDuration('5 SECONDS'), 5000); 143 | }); 144 | 145 | test('long-form units: with decimals', () => { 146 | assert.equal(parseDuration('1.5 hours'), 5_400_000); 147 | assert.equal(parseDuration('2.5 days'), 216_000_000); 148 | assert.equal(parseDuration('0.5 minutes'), 30_000); 149 | }); 150 | 151 | test('mixed short and long-form units', () => { 152 | assert.equal(parseDuration('1h 30 minutes'), 5_400_000); 153 | assert.equal(parseDuration('2 days 5h'), 190_800_000); 154 | assert.equal(parseDuration('90 minutes'), 5_400_000); 155 | }); 156 | 157 | test('edge cases: large values', () => { 158 | assert.equal(parseDuration('90m'), 5_400_000); 159 | assert.equal(parseDuration('120s'), 120_000); 160 | assert.equal(parseDuration('48h'), 172_800_000); 161 | assert.equal(parseDuration('365d'), 31_536_000_000); 162 | }); 163 | 164 | test('edge cases: very small values', () => { 165 | assert.equal(parseDuration('0.001s'), 1); 166 | assert.equal(parseDuration('0.1ms'), 0.1); 167 | assert.equal(parseDuration('1ms'), 1); 168 | }); 169 | 170 | test('edge cases: no space before unit', () => { 171 | assert.equal(parseDuration('1h'), 3_600_000); 172 | assert.equal(parseDuration('30m'), 1_800_000); 173 | assert.equal(parseDuration('1hour'), 3_600_000); 174 | assert.equal(parseDuration('30minutes'), 1_800_000); 175 | }); 176 | 177 | test('edge cases: multiple spaces', () => { 178 | assert.equal(parseDuration('1 hour 30 minutes'), 5_400_000); 179 | assert.equal(parseDuration('2 days 5 hours'), 190_800_000); 180 | }); 181 | 182 | test('nanoseconds', () => { 183 | assert.equal(parseDuration('1ns'), 1e-6); 184 | assert.equal(parseDuration('1000ns'), 1e-3); 185 | assert.equal(parseDuration('1 nanosecond'), 1e-6); 186 | assert.equal(parseDuration('1000 nanoseconds'), 1e-3); 187 | assert.equal(parseDuration('1nsec'), 1e-6); 188 | assert.equal(parseDuration('1000nsecs'), 1e-3); 189 | }); 190 | 191 | test('shortened forms: hours', () => { 192 | assert.equal(parseDuration('1hr'), 3_600_000); 193 | assert.equal(parseDuration('2hrs'), 7_200_000); 194 | assert.equal(parseDuration('1.5hrs'), 5_400_000); 195 | }); 196 | 197 | test('shortened forms: minutes', () => { 198 | assert.equal(parseDuration('1min'), 60_000); 199 | assert.equal(parseDuration('30mins'), 1_800_000); 200 | assert.equal(parseDuration('90mins'), 5_400_000); 201 | }); 202 | 203 | test('shortened forms: seconds', () => { 204 | assert.equal(parseDuration('1sec'), 1000); 205 | assert.equal(parseDuration('30secs'), 30_000); 206 | assert.equal(parseDuration('90secs'), 90_000); 207 | }); 208 | 209 | test('shortened forms: milliseconds', () => { 210 | assert.equal(parseDuration('1msec'), 1); 211 | assert.equal(parseDuration('100msecs'), 100); 212 | assert.equal(parseDuration('1000msecs'), 1000); 213 | }); 214 | 215 | test('mixed shortened forms', () => { 216 | assert.equal(parseDuration('1hr 30mins'), 5_400_000); 217 | assert.equal(parseDuration('2w 3d'), 1_468_800_000); 218 | assert.equal(parseDuration('1d 2hrs 30mins'), 95_400_000); 219 | }); 220 | 221 | test('edge cases: leading zeros', () => { 222 | assert.equal(parseDuration('01h'), 3_600_000); 223 | assert.equal(parseDuration('00.5h'), 1_800_000); 224 | assert.equal(parseDuration('001m'), 60_000); 225 | }); 226 | 227 | test('edge cases: plus sign', () => { 228 | assert.equal(parseDuration('+1h'), 3_600_000); 229 | assert.equal(parseDuration('+30m'), 1_800_000); 230 | assert.equal(parseDuration('+1.5h'), 5_400_000); 231 | }); 232 | 233 | test('edge cases: same unit repeated', () => { 234 | assert.equal(parseDuration('1h 2h'), 10_800_000); 235 | assert.equal(parseDuration('30m 15m 5m'), 3_000_000); 236 | assert.equal(parseDuration('1d 1d'), 172_800_000); 237 | }); 238 | 239 | test('edge cases: mixed case in same string', () => { 240 | assert.equal(parseDuration('1H 30m'), 5_400_000); 241 | assert.equal(parseDuration('2D 5h'), 190_800_000); 242 | assert.equal(parseDuration('1HOUR 30MINUTES'), 5_400_000); 243 | }); 244 | 245 | test('edge cases: tab characters', () => { 246 | assert.equal(parseDuration('1h\t30m'), 5_400_000); 247 | assert.equal(parseDuration('2d\t\t5h'), 190_800_000); 248 | }); 249 | 250 | test('edge cases: newline characters', () => { 251 | assert.equal(parseDuration('1h\n30m'), 5_400_000); 252 | assert.equal(parseDuration('2d\n5h\n30m'), 192_600_000); 253 | }); 254 | 255 | test('edge cases: very precise decimals', () => { 256 | assert.equal(parseDuration('1.123456789h'), 4_044_444.4404); 257 | assert.equal(parseDuration('0.00001s'), 0.01); 258 | }); 259 | 260 | test('edge cases: negative zero', () => { 261 | assert.equal(parseDuration('-0h'), 0); 262 | assert.equal(parseDuration('-0m'), 0); 263 | }); 264 | 265 | test('edge cases: extremely large numbers', () => { 266 | assert.equal(parseDuration('999999999d'), 86_399_999_913_600_000); 267 | assert.equal(parseDuration('1000000h'), 3_600_000_000_000); 268 | }); 269 | 270 | test('edge cases: only whitespace', () => { 271 | assert.equal(parseDuration(' '), undefined); 272 | assert.equal(parseDuration('\t\t'), undefined); 273 | assert.equal(parseDuration('\n\n'), undefined); 274 | }); 275 | 276 | test('edge cases: unit without value', () => { 277 | assert.equal(parseDuration('h'), undefined); 278 | assert.equal(parseDuration('min'), undefined); 279 | assert.equal(parseDuration('seconds'), undefined); 280 | }); 281 | 282 | test('edge cases: value without unit', () => { 283 | assert.equal(parseDuration('123'), undefined); 284 | assert.equal(parseDuration('45.6'), undefined); 285 | assert.equal(parseDuration('.5'), undefined); 286 | }); 287 | 288 | test('edge cases: multiple signs', () => { 289 | assert.equal(parseDuration('++1h'), undefined); 290 | assert.equal(parseDuration('--1h'), undefined); 291 | assert.equal(parseDuration('+-1h'), undefined); 292 | }); 293 | 294 | test('edge cases: scientific notation', () => { 295 | assert.equal(parseDuration('1e10s'), undefined); 296 | assert.equal(parseDuration('1e-5m'), undefined); 297 | assert.equal(parseDuration('5E3h'), undefined); 298 | }); 299 | 300 | test('edge cases: invalid characters mixed in', () => { 301 | assert.equal(parseDuration('1h@30m'), undefined); 302 | assert.equal(parseDuration('2d#5h'), undefined); 303 | assert.equal(parseDuration('1h 30m!'), undefined); 304 | }); 305 | 306 | test('edge cases: trailing/leading invalid units', () => { 307 | assert.equal(parseDuration('x1h'), undefined); 308 | assert.equal(parseDuration('1hs'), undefined); 309 | assert.equal(parseDuration('1hx'), undefined); 310 | }); 311 | 312 | test('edge cases: partial unit names', () => { 313 | assert.equal(parseDuration('1hou'), undefined); 314 | assert.equal(parseDuration('1minu'), undefined); 315 | assert.equal(parseDuration('1se'), undefined); 316 | }); 317 | 318 | test('edge cases: multiple dots', () => { 319 | assert.equal(parseDuration('1.2.3h'), undefined); 320 | assert.equal(parseDuration('..5m'), undefined); 321 | assert.equal(parseDuration('5..m'), undefined); 322 | }); 323 | 324 | test('edge cases: only dot', () => { 325 | assert.equal(parseDuration('.h'), undefined); 326 | assert.equal(parseDuration('.m'), undefined); 327 | }); 328 | 329 | test('ReDoS protection: long digit sequences', () => { 330 | const longNumber = '1'.repeat(1000); 331 | const start = Date.now(); 332 | parseDuration(`${longNumber}h`); 333 | const duration = Date.now() - start; 334 | assert.ok(duration < 100, `Should complete in <100ms, took ${duration}ms`); 335 | }); 336 | 337 | test('ReDoS protection: many invalid patterns', () => { 338 | const manyInvalid = 'x '.repeat(1000); 339 | const start = Date.now(); 340 | assert.equal(parseDuration(manyInvalid), undefined); 341 | const duration = Date.now() - start; 342 | assert.ok(duration < 100, `Should complete in <100ms, took ${duration}ms`); 343 | }); 344 | 345 | test('ReDoS protection: alternating valid and invalid', () => { 346 | const alternating = '1h x '.repeat(500); 347 | const start = Date.now(); 348 | assert.equal(parseDuration(alternating), undefined); 349 | const duration = Date.now() - start; 350 | assert.ok(duration < 100, `Should complete in <100ms, took ${duration}ms`); 351 | }); 352 | 353 | test('ReDoS protection: long string of spaces', () => { 354 | const manySpaces = ' '.repeat(10_000); 355 | const start = Date.now(); 356 | assert.equal(parseDuration(manySpaces), undefined); 357 | const duration = Date.now() - start; 358 | assert.ok(duration < 100, `Should complete in <100ms, took ${duration}ms`); 359 | }); 360 | 361 | test('ReDoS protection: many valid tokens', () => { 362 | const manyValid = '1h '.repeat(1000); 363 | const start = Date.now(); 364 | const result = parseDuration(manyValid); 365 | const duration = Date.now() - start; 366 | assert.equal(result, 3_600_000 * 1000); 367 | assert.ok(duration < 100, `Should complete in <100ms, took ${duration}ms`); 368 | }); 369 | 370 | test('ReDoS protection: almost-matching unit names', () => { 371 | const almostMatches = '1nanos 2millis 3sec 4min'.repeat(100); 372 | const start = Date.now(); 373 | assert.equal(parseDuration(almostMatches), undefined); 374 | const duration = Date.now() - start; 375 | assert.ok(duration < 100, `Should complete in <100ms, took ${duration}ms`); 376 | }); 377 | 378 | test('ReDoS protection: valid then invalid at end', () => { 379 | const validThenInvalid = '1h '.repeat(500) + 'invalid'; 380 | const start = Date.now(); 381 | assert.equal(parseDuration(validThenInvalid), undefined); 382 | const duration = Date.now() - start; 383 | assert.ok(duration < 100, `Should complete in <100ms, took ${duration}ms`); 384 | }); 385 | 386 | test('ReDoS protection: digits with partial unit match', () => { 387 | const partialUnit = '1234567890hou '.repeat(100); 388 | const start = Date.now(); 389 | assert.equal(parseDuration(partialUnit), undefined); 390 | const duration = Date.now() - start; 391 | assert.ok(duration < 100, `Should complete in <100ms, took ${duration}ms`); 392 | }); 393 | 394 | test('ReDoS protection: many spaces between tokens', () => { 395 | let spacedTokens = ''; 396 | for (let index = 0; index < 100; index++) { 397 | spacedTokens += `${index}h` + ' '.repeat(50); 398 | } 399 | 400 | const start = Date.now(); 401 | parseDuration(spacedTokens); 402 | const duration = Date.now() - start; 403 | assert.ok(duration < 100, `Should complete in <100ms, took ${duration}ms`); 404 | }); 405 | --------------------------------------------------------------------------------