@brianbuie/node-kit 0.12.4 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -44
- package/dist/index.d.mts +67 -51
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +98 -67
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -2
- package/prettier.config.ts +1 -3
- package/src/Cache.ts +2 -2
- package/src/Dir.test.ts +28 -8
- package/src/Dir.ts +27 -24
- package/src/Fetcher.ts +4 -4
- package/src/File.test.ts +3 -3
- package/src/Format.ts +29 -6
- package/src/Log.test.ts +0 -13
- package/src/Log.ts +54 -29
- package/src/TypeWriter.ts +1 -1
- package/src/index.ts +1 -1
- package/src/snapshot.ts +4 -4
- package/src/timeout.ts +1 -1
- package/tsconfig.json +1 -0
package/src/Dir.ts
CHANGED
|
@@ -3,20 +3,28 @@ import * as path from 'node:path';
|
|
|
3
3
|
import sanitizeFilename from 'sanitize-filename';
|
|
4
4
|
import { File } from './File.ts';
|
|
5
5
|
|
|
6
|
+
export type DirOptions = {
|
|
7
|
+
temp?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
6
10
|
/**
|
|
7
11
|
* Reference to a specific directory with methods to create and list files.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
12
|
+
* @param inputPath
|
|
13
|
+
* The path of the directory, created on file system the first time `.path` is read or any methods are used
|
|
14
|
+
* @param options
|
|
15
|
+
* include `{ temp: true }` to enable the `.clear()` method
|
|
10
16
|
*/
|
|
11
17
|
export class Dir {
|
|
12
18
|
#inputPath;
|
|
13
19
|
#resolved?: string;
|
|
20
|
+
isTemp;
|
|
14
21
|
|
|
15
22
|
/**
|
|
16
23
|
* @param path can be relative to workspace or absolute
|
|
17
24
|
*/
|
|
18
|
-
constructor(inputPath =
|
|
25
|
+
constructor(inputPath: string, options: DirOptions = {}) {
|
|
19
26
|
this.#inputPath = inputPath;
|
|
27
|
+
this.isTemp = Boolean(options.temp);
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
/**
|
|
@@ -32,23 +40,26 @@ export class Dir {
|
|
|
32
40
|
|
|
33
41
|
/**
|
|
34
42
|
* Create a new Dir inside the current Dir
|
|
35
|
-
* @param subPath
|
|
43
|
+
* @param subPath
|
|
44
|
+
* joined with parent Dir's path to make new Dir
|
|
45
|
+
* @param options
|
|
46
|
+
* include `{ temp: true }` to enable the `.clear()` method. If current Dir is temporary, child directories will also be temporary.
|
|
36
47
|
* @example
|
|
37
48
|
* const folder = new Dir('example');
|
|
38
49
|
* // folder.path = '/path/to/cwd/example'
|
|
39
50
|
* const child = folder.dir('path/to/dir');
|
|
40
51
|
* // child.path = '/path/to/cwd/example/path/to/dir'
|
|
41
52
|
*/
|
|
42
|
-
dir(subPath: string) {
|
|
43
|
-
return new Dir(path.join(this.path, subPath));
|
|
53
|
+
dir(subPath: string, options: DirOptions = { temp: this.isTemp }) {
|
|
54
|
+
return new (this.constructor as typeof Dir)(path.join(this.path, subPath), options) as this;
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
/**
|
|
47
|
-
* Creates a new
|
|
58
|
+
* Creates a new temp directory inside current Dir
|
|
48
59
|
* @param subPath joined with parent Dir's path to make new TempDir
|
|
49
60
|
*/
|
|
50
61
|
tempDir(subPath: string) {
|
|
51
|
-
return
|
|
62
|
+
return this.dir(subPath, { temp: true });
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
sanitize(filename: string) {
|
|
@@ -72,29 +83,21 @@ export class Dir {
|
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
get files() {
|
|
75
|
-
return fs.readdirSync(this.path).map(
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Extends Dir class with method to `clear()` contents.
|
|
81
|
-
* Default path: `./.temp`
|
|
82
|
-
*/
|
|
83
|
-
export class TempDir extends Dir {
|
|
84
|
-
constructor(inputPath = `./.temp`) {
|
|
85
|
-
super(inputPath);
|
|
86
|
+
return fs.readdirSync(this.path).map(filename => this.file(filename));
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
/**
|
|
89
|
-
* > ⚠️ Warning! This deletes the directory!
|
|
90
|
-
*/
|
|
91
89
|
clear() {
|
|
90
|
+
if (!this.isTemp) throw new Error('Dir is not temporary');
|
|
92
91
|
fs.rmSync(this.path, { recursive: true, force: true });
|
|
93
92
|
fs.mkdirSync(this.path, { recursive: true });
|
|
94
93
|
}
|
|
95
94
|
}
|
|
96
95
|
|
|
97
96
|
/**
|
|
98
|
-
*
|
|
97
|
+
* Current working directory
|
|
98
|
+
*/
|
|
99
|
+
export const cwd = new Dir('./');
|
|
100
|
+
/**
|
|
101
|
+
* ./.temp in current working directory
|
|
99
102
|
*/
|
|
100
|
-
export const temp =
|
|
103
|
+
export const temp = cwd.tempDir('.temp');
|
package/src/Fetcher.ts
CHANGED
|
@@ -43,7 +43,7 @@ export class Fetcher {
|
|
|
43
43
|
Object.entries(mergedOptions.query || {}).forEach(([key, val]) => {
|
|
44
44
|
if (val === undefined) return;
|
|
45
45
|
if (Array.isArray(val)) {
|
|
46
|
-
val.forEach(
|
|
46
|
+
val.forEach(v => {
|
|
47
47
|
params.push([key, `${v}`]);
|
|
48
48
|
});
|
|
49
49
|
} else {
|
|
@@ -98,15 +98,15 @@ export class Fetcher {
|
|
|
98
98
|
attempt++;
|
|
99
99
|
const [req] = this.buildRequest(route, opts);
|
|
100
100
|
const res = await fetch(req)
|
|
101
|
-
.then(
|
|
101
|
+
.then(r => {
|
|
102
102
|
if (!r.ok) throw new Error(r.statusText);
|
|
103
103
|
return r;
|
|
104
104
|
})
|
|
105
|
-
.catch(async
|
|
105
|
+
.catch(async error => {
|
|
106
106
|
if (attempt < maxAttempts) {
|
|
107
107
|
const wait = attempt * 3000;
|
|
108
108
|
console.warn(`${req.method} ${req.url} (attempt ${attempt} of ${maxAttempts})`, error);
|
|
109
|
-
await new Promise(
|
|
109
|
+
await new Promise(resolve => setTimeout(resolve, wait));
|
|
110
110
|
} else {
|
|
111
111
|
throw new Error(error);
|
|
112
112
|
}
|
package/src/File.test.ts
CHANGED
|
@@ -17,7 +17,7 @@ const thing = {
|
|
|
17
17
|
describe('File', () => {
|
|
18
18
|
it('Handles request body as stream input', async () => {
|
|
19
19
|
const img = testDir.file('image.jpg');
|
|
20
|
-
await fetch('https://testingbot.com/free-online-tools/random-avatar/300').then(
|
|
20
|
+
await fetch('https://testingbot.com/free-online-tools/random-avatar/300').then(res => {
|
|
21
21
|
if (!res.body) throw new Error('No response body');
|
|
22
22
|
return img.write(res.body);
|
|
23
23
|
});
|
|
@@ -74,7 +74,7 @@ describe('FileTypeNdjson', () => {
|
|
|
74
74
|
assert(file.lines().length === 2);
|
|
75
75
|
file.append(thing);
|
|
76
76
|
assert(file.lines().length === 3);
|
|
77
|
-
file.lines().forEach(
|
|
77
|
+
file.lines().forEach(line => {
|
|
78
78
|
assert.deepStrictEqual(line, thing);
|
|
79
79
|
});
|
|
80
80
|
});
|
|
@@ -93,7 +93,7 @@ describe('FileTypeCsv', () => {
|
|
|
93
93
|
const things = [thing, thing, thing];
|
|
94
94
|
const file = await testDir.file('csv-data').csv(things);
|
|
95
95
|
const parsed = await file.read();
|
|
96
|
-
parsed.forEach(
|
|
96
|
+
parsed.forEach(row => {
|
|
97
97
|
assert.deepEqual(row, thing);
|
|
98
98
|
});
|
|
99
99
|
});
|
package/src/Format.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { format, formatISO, type DateArg } from 'date-fns';
|
|
1
|
+
import { format, formatISO, type DateArg, type Duration } from 'date-fns';
|
|
2
|
+
import formatDuration from 'format-duration';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Helpers for formatting dates, times, and numbers as strings
|
|
@@ -6,12 +7,25 @@ import { format, formatISO, type DateArg } from 'date-fns';
|
|
|
6
7
|
export class Format {
|
|
7
8
|
/**
|
|
8
9
|
* date-fns format() with some shortcuts
|
|
9
|
-
* @param formatStr
|
|
10
|
-
*
|
|
10
|
+
* @param formatStr the format to use
|
|
11
|
+
* @param date the date to format, default `new Date()`
|
|
12
|
+
* @example
|
|
13
|
+
* Format.date('iso') // '2026-04-08T13:56:45Z'
|
|
14
|
+
* Format.date('ymd') // '20260408'
|
|
15
|
+
* Format.date('ymd-hm') // '20260408-1356'
|
|
16
|
+
* Format.date('ymd-hms') // '20260408-135645'
|
|
17
|
+
* Format.date('h:m:s') // '13:56:45'
|
|
18
|
+
* @see more format options https://date-fns.org/v4.1.0/docs/format
|
|
11
19
|
*/
|
|
12
|
-
static date(
|
|
20
|
+
static date(
|
|
21
|
+
formatStr: 'iso' | 'ymd' | 'ymd-hm' | 'ymd-hms' | 'h:m:s' | string = 'iso',
|
|
22
|
+
d: DateArg<Date> = new Date(),
|
|
23
|
+
) {
|
|
13
24
|
if (formatStr === 'iso') return formatISO(d);
|
|
14
|
-
if (formatStr === 'ymd') return format(d, '
|
|
25
|
+
if (formatStr === 'ymd') return format(d, 'yyyyMMdd');
|
|
26
|
+
if (formatStr === 'ymd-hm') return format(d, 'yyyyMMdd-HHmm');
|
|
27
|
+
if (formatStr === 'ymd-hms') return format(d, 'yyyyMMdd-HHmmss');
|
|
28
|
+
if (formatStr === 'h:m:s') return format(d, 'HH:mm:ss');
|
|
15
29
|
return format(d, formatStr);
|
|
16
30
|
}
|
|
17
31
|
|
|
@@ -22,10 +36,19 @@ export class Format {
|
|
|
22
36
|
return new Intl.NumberFormat('en-US', { maximumFractionDigits: places }).format(n);
|
|
23
37
|
}
|
|
24
38
|
|
|
39
|
+
static plural(amount: number, singular: string, multiple?: string) {
|
|
40
|
+
return amount === 1 ? `${amount} ${singular}` : `${amount} ${multiple || singular + 's'}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
25
43
|
/**
|
|
26
44
|
* Make millisecond durations actually readable (eg "123ms", "3.56s", "1m 34s", "3h 24m", "2d 4h")
|
|
45
|
+
* @param ms milliseconds
|
|
46
|
+
* @param style 'digital' to output as 'HH:MM:SS'
|
|
47
|
+
* @see details on 'digital' format https://github.com/ungoldman/format-duration
|
|
48
|
+
* @see waiting on `Intl.DurationFormat({ style: 'digital' })` types https://github.com/microsoft/TypeScript/issues/60608
|
|
27
49
|
*/
|
|
28
|
-
static ms(ms: number) {
|
|
50
|
+
static ms(ms: number, style?: 'digital') {
|
|
51
|
+
if (style === 'digital') return formatDuration(ms, { leading: true });
|
|
29
52
|
if (ms < 1000) return `${this.round(ms)}ms`;
|
|
30
53
|
const s = ms / 1000;
|
|
31
54
|
if (s < 60) return `${this.round(s, 2)}s`;
|
package/src/Log.test.ts
CHANGED
|
@@ -3,19 +3,6 @@ import assert from 'node:assert';
|
|
|
3
3
|
import { Log } from './Log.ts';
|
|
4
4
|
|
|
5
5
|
describe('Log', () => {
|
|
6
|
-
it('Throws error', () => {
|
|
7
|
-
try {
|
|
8
|
-
Log.error('Test error');
|
|
9
|
-
} catch (e) {
|
|
10
|
-
return;
|
|
11
|
-
}
|
|
12
|
-
assert(false, 'Did not throw error');
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it('Recognizes this is a test', () => {
|
|
16
|
-
assert(Log.isTest);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
6
|
it('Uses first argument as message when string', () => {
|
|
20
7
|
const result = Log.prepare('test', { something: 'else' });
|
|
21
8
|
assert(result.message === 'test');
|
package/src/Log.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { inspect } from 'node:util';
|
|
|
2
2
|
import { isObjectLike } from 'lodash-es';
|
|
3
3
|
import chalk, { type ChalkInstance } from 'chalk';
|
|
4
4
|
import { snapshot } from './snapshot.ts';
|
|
5
|
+
import { Format } from './Format.ts';
|
|
5
6
|
|
|
7
|
+
// https://docs.cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity
|
|
6
8
|
type Severity = 'DEFAULT' | 'DEBUG' | 'INFO' | 'NOTICE' | 'WARNING' | 'ERROR' | 'CRITICAL' | 'ALERT' | 'EMERGENCY';
|
|
7
9
|
|
|
8
10
|
type Options = {
|
|
@@ -13,89 +15,112 @@ type Options = {
|
|
|
13
15
|
type Entry = {
|
|
14
16
|
message?: string;
|
|
15
17
|
severity: Severity;
|
|
18
|
+
stack?: string;
|
|
16
19
|
details?: unknown[];
|
|
17
20
|
};
|
|
18
21
|
|
|
19
22
|
export class Log {
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
static getStack() {
|
|
24
|
+
const details = { stack: '' };
|
|
25
|
+
// replaces details.stack with current stack trace, excluding this Log.getStack call
|
|
26
|
+
Error.captureStackTrace(details, Log.getStack);
|
|
27
|
+
// remove 'Error' on first line
|
|
28
|
+
return details.stack
|
|
29
|
+
.split('\n')
|
|
30
|
+
.map(l => l.trim())
|
|
31
|
+
.filter(l => l !== 'Error');
|
|
32
|
+
}
|
|
22
33
|
|
|
23
34
|
/**
|
|
24
35
|
* Gcloud parses JSON in stdout
|
|
25
36
|
*/
|
|
26
37
|
static #toGcloud(entry: Entry) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
console.log(JSON.stringify(snapshot(entry)));
|
|
31
|
-
}
|
|
38
|
+
const details = entry.details?.length === 1 ? entry.details[0] : entry.details;
|
|
39
|
+
const output = { ...entry, details, stack: entry.stack || this.getStack() };
|
|
40
|
+
console.log(JSON.stringify(snapshot(output)));
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
/**
|
|
35
44
|
* Includes colors and better inspection for logging during dev
|
|
36
45
|
*/
|
|
37
46
|
static #toConsole(entry: Entry, color: ChalkInstance) {
|
|
38
|
-
if (entry.message) console.log(color(
|
|
39
|
-
entry.details?.forEach(
|
|
47
|
+
if (entry.message) console.log(color(`${Format.date('h:m:s')} [${entry.severity}] ${entry.message}`));
|
|
48
|
+
entry.details?.forEach(detail => {
|
|
40
49
|
console.log(inspect(detail, { depth: 10, breakLength: 100, compact: true, colors: true }));
|
|
41
50
|
});
|
|
42
51
|
}
|
|
43
52
|
|
|
44
|
-
static #log(
|
|
53
|
+
static #log({ severity, color }: Options, ...input: unknown[]) {
|
|
45
54
|
const { message, details } = this.prepare(...input);
|
|
55
|
+
const entry: Entry = { message, severity, details };
|
|
46
56
|
// https://cloud.google.com/run/docs/container-contract#env-vars
|
|
47
57
|
const isGcloud = process.env.K_SERVICE !== undefined || process.env.CLOUD_RUN_JOB !== undefined;
|
|
48
58
|
if (isGcloud) {
|
|
49
|
-
this.#toGcloud(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// Hide output while testing this package
|
|
53
|
-
if (!this.isTest) {
|
|
54
|
-
this.#toConsole({ message, severity: options.severity, details }, options.color);
|
|
59
|
+
this.#toGcloud(entry);
|
|
60
|
+
} else {
|
|
61
|
+
this.#toConsole(entry, color);
|
|
55
62
|
}
|
|
56
|
-
return
|
|
63
|
+
return entry;
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
/**
|
|
60
67
|
* Handle first argument being a string or an object with a 'message' prop
|
|
61
68
|
*/
|
|
62
69
|
static prepare(...input: unknown[]): { message?: string; details: unknown[] } {
|
|
63
|
-
let [
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
let [firstArg, ...rest] = input;
|
|
71
|
+
// First argument is a string, use that as the message
|
|
72
|
+
if (typeof firstArg === 'string') {
|
|
73
|
+
return { message: firstArg, details: rest };
|
|
66
74
|
}
|
|
75
|
+
// First argument is an object with a `message` property
|
|
67
76
|
// @ts-ignore
|
|
68
|
-
if (isObjectLike(
|
|
69
|
-
const { message, ...firstDetails } =
|
|
77
|
+
if (isObjectLike(firstArg) && typeof firstArg['message'] === 'string') {
|
|
78
|
+
const { message, ...firstDetails } = firstArg as { message: string };
|
|
70
79
|
return { message, details: [firstDetails, ...rest] };
|
|
71
80
|
}
|
|
81
|
+
// No message found, log all args as details
|
|
72
82
|
return { details: input };
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
/**
|
|
76
|
-
*
|
|
86
|
+
* Events that require action or attention immediately
|
|
87
|
+
*/
|
|
88
|
+
static alert(...input: unknown[]) {
|
|
89
|
+
return this.#log({ severity: 'ALERT', color: chalk.bgRed }, ...input);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Events that cause problems
|
|
77
94
|
*/
|
|
78
95
|
static error(...input: unknown[]) {
|
|
79
|
-
|
|
80
|
-
throw new Error(message);
|
|
96
|
+
return this.#log({ severity: 'ERROR', color: chalk.red }, ...input);
|
|
81
97
|
}
|
|
82
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Events that might cause problems
|
|
101
|
+
*/
|
|
83
102
|
static warn(...input: unknown[]) {
|
|
84
103
|
return this.#log({ severity: 'WARNING', color: chalk.yellow }, ...input);
|
|
85
104
|
}
|
|
86
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Normal but significant events, such as start up, shut down, or a configuration change
|
|
108
|
+
*/
|
|
87
109
|
static notice(...input: unknown[]) {
|
|
88
110
|
return this.#log({ severity: 'NOTICE', color: chalk.cyan }, ...input);
|
|
89
111
|
}
|
|
90
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Routine information, such as ongoing status or performance
|
|
115
|
+
*/
|
|
91
116
|
static info(...input: unknown[]) {
|
|
92
117
|
return this.#log({ severity: 'INFO', color: chalk.white }, ...input);
|
|
93
118
|
}
|
|
94
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Debug or trace information
|
|
122
|
+
*/
|
|
95
123
|
static debug(...input: unknown[]) {
|
|
96
|
-
|
|
97
|
-
if (debugging || process.env.NODE_ENV !== 'production') {
|
|
98
|
-
return this.#log({ severity: 'DEBUG', color: chalk.gray }, ...input);
|
|
99
|
-
}
|
|
124
|
+
return this.#log({ severity: 'DEBUG', color: chalk.gray }, ...input);
|
|
100
125
|
}
|
|
101
126
|
}
|
package/src/TypeWriter.ts
CHANGED
|
@@ -25,7 +25,7 @@ export class TypeWriter {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
async addMember(name: string, _samples: any[]) {
|
|
28
|
-
const samples = _samples.map(
|
|
28
|
+
const samples = _samples.map(s => (typeof s === 'string' ? s : JSON.stringify(s)));
|
|
29
29
|
await this.input.addSource({ name, samples });
|
|
30
30
|
}
|
|
31
31
|
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { Dir,
|
|
1
|
+
export { Dir, type DirOptions, temp, cwd } from './Dir.ts';
|
|
2
2
|
export { Cache } from './Cache.ts';
|
|
3
3
|
export { Fetcher, type Route, type Query, type FetchOptions } from './Fetcher.ts';
|
|
4
4
|
export { File, FileType, FileTypeJson, FileTypeNdjson, FileTypeCsv } from './File.ts';
|
package/src/snapshot.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { isObjectLike } from 'lodash-es';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Allows special objects (Error, Headers, Set) to be included in JSON.stringify output
|
|
5
|
-
*
|
|
4
|
+
* Allows special objects (Error, Headers, Set) to be included in JSON.stringify output.
|
|
5
|
+
* Functions are removed
|
|
6
6
|
*/
|
|
7
7
|
export function snapshot(i: unknown, max = 50, depth = 0): any {
|
|
8
8
|
if (Array.isArray(i)) {
|
|
9
9
|
if (depth === max) return [];
|
|
10
|
-
return i.map(
|
|
10
|
+
return i.map(c => snapshot(c, max, depth + 1));
|
|
11
11
|
}
|
|
12
12
|
if (typeof i === 'function') return undefined;
|
|
13
13
|
if (!isObjectLike(i)) return i;
|
|
@@ -32,7 +32,7 @@ export function snapshot(i: unknown, max = 50, depth = 0): any {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// Get Non-enumberable, own properties
|
|
35
|
-
Object.getOwnPropertyNames(obj).forEach(
|
|
35
|
+
Object.getOwnPropertyNames(obj).forEach(key => {
|
|
36
36
|
output[key] = snapshot(obj[key], max, depth + 1);
|
|
37
37
|
});
|
|
38
38
|
|
package/src/timeout.ts
CHANGED