Skip to content

Commit

Permalink
Init commit
Browse files Browse the repository at this point in the history
  • Loading branch information
smithamax committed Dec 12, 2019
0 parents commit b43fe8d
Show file tree
Hide file tree
Showing 7 changed files with 345 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist
node_modules
package-lock.json
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# duration

## Examples

Good for more readable durations

```js
const { MINUTE } = require("@loke/duration");

setTimeout(() => console.log("yay"), 30 * MINUTE);
```

Parsing more human durations from configs

```js
const duration = require("@loke/duration");

// config, could be loaded form file etc
const config = {
timeoutDuration: "30s"
};

const timeout = duration.parse(config.timeoutDuration); // 30000
```

Format durations for logging and metrics

```js
const duration = require("@loke/duration");
const { Histogram } = require("prom-client");

async function handler() {
const startTime = process.hrtime();

await slowThing();

const dur = duration.fromHR(process.hrtime(startTime));

console.log(`completed slow thing, duration=${duration.format(dur)}`);
// completed slow thing, duration=156ms
}
```
157 changes: 157 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* duration in milliseconds
*/
export type Duration = number;

export const MICROSECOND: Duration = 0.001;
export const MILLISECOND: Duration = 1;
export const SECOND: Duration = 1000 * MILLISECOND;
export const MINUTE: Duration = 60 * SECOND;
export const HOUR: Duration = 60 * MINUTE;
export const DAY: Duration = 24 * HOUR;

const units = new Map([
["us", MICROSECOND],
["µs", MICROSECOND], // U+00B5 = micro symbol
["μs", MICROSECOND], // U+03BC = Greek letter mu
["ms", MILLISECOND],
["s", SECOND],
["m", MINUTE],
["h", HOUR]
]);

// const stringFormatRe = /(?<num>[0-9]+\.?|[0-9]*(\.[0-9]+))(?<unit>[^\d\.]+)/g;

function matchAll(str: string) {
const stringFormatRe = /(?<num>[0-9]+\.?|[0-9]*(\.[0-9]+))(?<unit>[^\d\.]+)/g;
const matches = [];

while (true) {
const match = stringFormatRe.exec(str);

if (match === null) {
break;
}

matches.push(match);
}

return matches;
}

function toPres(val: number, maxDec: number) {
return val.toFixed(maxDec).replace(/\.?0+$/, "");
}

/**
* parse a duration string and return duration value in milliseconds
* @param str - duration string, eg 1h30m0s
*/
export function parse(str: string): Duration {
if (typeof str === "number") {
return str;
}
if (typeof str !== "string") {
throw new TypeError("can not parse non string input");
}

if (str.trim() === "") {
throw new Error(`invalid duration "${str}"`);
}
if (str === "0") {
return 0;
}

const sign = str.trim().startsWith("-") ? -1 : 1;
let duration = 0;
// str.matchAll only works in node 12
// const parts = str.matchAll(stringFormatRe);
const parts = matchAll(str);

let hasParts = false;
for (const part of parts) {
hasParts = true;
// console.log(part);
const unit = units.get(part.groups!.unit);
if (!unit) {
throw new Error(`unknown unit '${part.groups!.unit}' in ${str}`);
}
duration += parseFloat(part.groups!.num) * unit;
}

if (!hasParts) {
throw new Error(`invalid duration "${str}"`);
}

return sign * duration;
}

/**
* format converts a duration in milliseconds into a string with units
* @param duration - time in milliseconds
*/
export function format(duration: Duration): string {
if (duration === 0) {
return "0s";
}
const neg = duration < 0;
let rem = Math.abs(duration);

if (!Number.isInteger(rem) && rem < 1) {
return ((duration / MICROSECOND) | 0) + "µs";
}
if (rem < SECOND) {
return toPres(duration / MILLISECOND, 3) + "ms";
}

// rem to seconds
rem = rem / SECOND;

// .toFixed(9).replace is a bit of a hack to deal with floating point errors
// 9 covers nanoseconds, which is as low as hrtime goes
let str = toPres(rem % 60, 6) + "s";

// rem to minutes
rem = (rem / 60) | 0;

if (rem > 0) {
str = (rem % 60) + "m" + str;
// rem to hours
rem = (rem / 60) | 0;

if (rem > 0) {
str = rem + "h" + str;
}
}
if (neg) {
str = "-" + str;
}

return str;
}

/**
* converts a hr duration into a more standard millisecond form,
* @param duration - hr duration from process.hrtime
*/
export function fromHR(duration: [number, number]): Duration {
const [s, n] = duration;
return s * 1e3 + n / 1e6;
}

/**
* converts from milliseconds into seconds, useful for prometheus histograms & gauges
* @param duration - duration in milliseconds
*/
export function toSeconds(duration: Duration): number {
return duration / SECOND;
}

/**
* converts a hr duration into seconds, useful for prometheus histograms & gauges
* @param duration - hr duration from process.hrtime
*/
export function fromHRToSeconds(duration: [number, number]): Duration {
const [s, n] = duration;
return s + n / 1e9;
}
26 changes: 26 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@loke/duration",
"version": "0.0.1",
"description": "",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"test": "rm -rf ./dist && tsc && node ./dist/test.js",
"prepare": "npm run build",
"build": "rm -rf ./dist && tsc",
"lint": "tslint -p ."
},
"author": "Dominic Smith",
"license": "MIT",
"devDependencies": {
"@types/node": "^10.12.21",
"prettier": "^1.16.4",
"tslint": "^5.12.1",
"tslint-config-prettier": "^1.18.0",
"typescript": "^3.7.2"
},
"dependencies": {}
}
91 changes: 91 additions & 0 deletions test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import assert from "assert";
import * as m from ".";
import { Duration } from ".";

// parse and format
{
// duration, canonical form, ...other forms
const cases: [Duration, string, ...string[]][] = [
[-60000, "-1m0s"],
[-1000, "-1s", "-1000ms"],
[-1, "-1ms", "-0.001s", "-.001s"],
[0, "0s", "0ms", "0h0s"],
[1, "1ms", "0.001s", ".001s", "+.001s"],
[1000, "1s", "1000ms", "1.s", "+1.s"],
[60000, "1m0s"],
[3600000, "1h0m0s"],
[63949, "1m3.949s"],
[63001, "1m3.001s"],

// from go
[2200 * m.MICROSECOND, "2.2ms"],
[3300 * m.MILLISECOND, "3.3s"],
[4 * m.MINUTE + 5 * m.SECOND, "4m5s"],
[4 * m.MINUTE + 5001 * m.MILLISECOND, "4m5.001s"],
[5 * m.HOUR + 6 * m.MINUTE + 7001 * m.MILLISECOND, "5h6m7.001s"],
// [8 * m.MINUTE + 0.000000000001, "8m0.000000001s"],
// [Number.MAX_SAFE_INTEGER, ""],
// [Number.MIN_SAFE_INTEGER, ""],

// non int
[63949.234, "1m3.949234s"],
[0.001, "1µs", "1\u03bcs", "1us", "0.000001s"],
[0.1, "100µs"],
[-0.001, "-1µs", "-1\u03bcs", "-1us", "-0.000001s"],
[-0.1, "-100µs"]
];

for (const c of cases) {
const [dur, canonical, ...others] = c;

const formatOut = m.format(dur);
assert.strictEqual(
formatOut,
canonical,
`format error: expected m.format(${dur}) => ${canonical}, got ${formatOut}`
);

for (const str of [canonical, ...others]) {
const parseOut = m.parse(str);
assert.strictEqual(
parseOut,
dur,
`parse error: expected m.parse(${str}) => ${dur}, got ${parseOut}`
);
}
}
}

// invalid strings
{
const cases = ["", "3", "-", "s", ".", "-.", ".s", "+.s"];

for (const input of cases) {
assert.throws(() => m.parse(input), `expected error for ${input}`);
}
}

// fromHR
{
const cases: [[number, number], Duration][] = [
[[2, 977111090], 2977.11109],
[[123, 977111090], 123977.11109]
];

for (const [hr, dur] of cases) {
assert.strictEqual(m.fromHR(hr), dur);
}
}

// toSeconds
{
const cases: [Duration, number][] = [
[1000, 1],
[2977, 2.977],
[123977, 123.977]
];

for (const [dur, sec] of cases) {
assert.strictEqual(m.toSeconds(dur), sec);
}
}
11 changes: 11 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
}
}
15 changes: 15 additions & 0 deletions tslint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"defaultSeverity": "error",
"extends": ["tslint:recommended", "tslint-config-prettier"],
"jsRules": {},
"rules": {
"no-switch-case-fall-through": true,
"member-access": [true, "no-public"],
"interface-name": [true, "never-prefix"],
"array-type": false,
"no-bitwise": false,
"no-any": true
},
"exclude": ["node_modules"],
"rulesDirectory": []
}

0 comments on commit b43fe8d

Please sign in to comment.