@fuzdev/fuz_util 0.42.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/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/array.d.ts +15 -0
- package/dist/array.d.ts.map +1 -0
- package/dist/array.js +25 -0
- package/dist/async.d.ts +62 -0
- package/dist/async.d.ts.map +1 -0
- package/dist/async.js +147 -0
- package/dist/colors.d.ts +41 -0
- package/dist/colors.d.ts.map +1 -0
- package/dist/colors.js +106 -0
- package/dist/counter.d.ts +7 -0
- package/dist/counter.d.ts.map +1 -0
- package/dist/counter.js +7 -0
- package/dist/deep_equal.d.ts +18 -0
- package/dist/deep_equal.d.ts.map +1 -0
- package/dist/deep_equal.js +152 -0
- package/dist/dom.d.ts +35 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +95 -0
- package/dist/error.d.ts +15 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +18 -0
- package/dist/fetch.d.ts +81 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +162 -0
- package/dist/fs.d.ts +34 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +73 -0
- package/dist/function.d.ts +27 -0
- package/dist/function.d.ts.map +1 -0
- package/dist/function.js +21 -0
- package/dist/git.d.ts +132 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +288 -0
- package/dist/id.d.ts +18 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +18 -0
- package/dist/iterator.d.ts +5 -0
- package/dist/iterator.d.ts.map +1 -0
- package/dist/iterator.js +9 -0
- package/dist/json.d.ts +30 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +44 -0
- package/dist/library_json.d.ts +42 -0
- package/dist/library_json.d.ts.map +1 -0
- package/dist/library_json.js +76 -0
- package/dist/log.d.ts +188 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +393 -0
- package/dist/map.d.ts +12 -0
- package/dist/map.d.ts.map +1 -0
- package/dist/map.js +14 -0
- package/dist/maths.d.ts +85 -0
- package/dist/maths.d.ts.map +1 -0
- package/dist/maths.js +87 -0
- package/dist/object.d.ts +46 -0
- package/dist/object.d.ts.map +1 -0
- package/dist/object.js +89 -0
- package/dist/package_json.d.ts +90 -0
- package/dist/package_json.d.ts.map +1 -0
- package/dist/package_json.js +112 -0
- package/dist/path.d.ts +63 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +83 -0
- package/dist/print.d.ts +52 -0
- package/dist/print.d.ts.map +1 -0
- package/dist/print.js +89 -0
- package/dist/process.d.ts +77 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +148 -0
- package/dist/random.d.ts +25 -0
- package/dist/random.d.ts.map +1 -0
- package/dist/random.js +35 -0
- package/dist/random_alea.d.ts +23 -0
- package/dist/random_alea.d.ts.map +1 -0
- package/dist/random_alea.js +95 -0
- package/dist/regexp.d.ts +12 -0
- package/dist/regexp.d.ts.map +1 -0
- package/dist/regexp.js +16 -0
- package/dist/result.d.ts +64 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +48 -0
- package/dist/source_json.d.ts +375 -0
- package/dist/source_json.d.ts.map +1 -0
- package/dist/source_json.js +189 -0
- package/dist/string.d.ts +51 -0
- package/dist/string.d.ts.map +1 -0
- package/dist/string.js +92 -0
- package/dist/throttle.d.ts +26 -0
- package/dist/throttle.d.ts.map +1 -0
- package/dist/throttle.js +53 -0
- package/dist/timings.d.ts +33 -0
- package/dist/timings.d.ts.map +1 -0
- package/dist/timings.js +75 -0
- package/dist/types.d.ts +77 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/url.d.ts +10 -0
- package/dist/url.d.ts.map +1 -0
- package/dist/url.js +8 -0
- package/package.json +125 -0
- package/src/lib/array.ts +30 -0
- package/src/lib/async.ts +182 -0
- package/src/lib/colors.ts +132 -0
- package/src/lib/counter.ts +11 -0
- package/src/lib/deep_equal.ts +155 -0
- package/src/lib/dom.ts +108 -0
- package/src/lib/error.ts +22 -0
- package/src/lib/fetch.ts +231 -0
- package/src/lib/fs.ts +128 -0
- package/src/lib/function.ts +32 -0
- package/src/lib/git.ts +390 -0
- package/src/lib/id.ts +30 -0
- package/src/lib/iterator.ts +8 -0
- package/src/lib/json.ts +61 -0
- package/src/lib/library_json.ts +122 -0
- package/src/lib/log.ts +469 -0
- package/src/lib/map.ts +18 -0
- package/src/lib/maths.ts +91 -0
- package/src/lib/object.ts +110 -0
- package/src/lib/package_json.ts +135 -0
- package/src/lib/path.ts +137 -0
- package/src/lib/print.ts +111 -0
- package/src/lib/process.ts +207 -0
- package/src/lib/random.ts +48 -0
- package/src/lib/random_alea.ts +107 -0
- package/src/lib/regexp.ts +17 -0
- package/src/lib/result.ts +67 -0
- package/src/lib/source_json.ts +209 -0
- package/src/lib/string.ts +99 -0
- package/src/lib/throttle.ts +70 -0
- package/src/lib/timings.ts +93 -0
- package/src/lib/types.ts +99 -0
- package/src/lib/url.ts +14 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {z} from 'zod';
|
|
2
|
+
|
|
3
|
+
import {count_graphemes} from './string.js';
|
|
4
|
+
import {transform_empty_object_to_undefined} from './object.js';
|
|
5
|
+
import {Url} from './url.js';
|
|
6
|
+
|
|
7
|
+
export const PackageJsonRepository = z.union([
|
|
8
|
+
z.string(),
|
|
9
|
+
z.looseObject({
|
|
10
|
+
type: z.string(),
|
|
11
|
+
url: Url,
|
|
12
|
+
directory: z.string().optional(),
|
|
13
|
+
}),
|
|
14
|
+
]);
|
|
15
|
+
export type PackageJsonRepository = z.infer<typeof PackageJsonRepository>;
|
|
16
|
+
|
|
17
|
+
export const PackageJsonAuthor = z.union([
|
|
18
|
+
z.string(),
|
|
19
|
+
z.looseObject({
|
|
20
|
+
name: z.string(),
|
|
21
|
+
email: z.email().optional(),
|
|
22
|
+
url: Url.optional(),
|
|
23
|
+
}),
|
|
24
|
+
]);
|
|
25
|
+
export type PackageJsonAuthor = z.infer<typeof PackageJsonAuthor>;
|
|
26
|
+
|
|
27
|
+
export const PackageJsonFunding = z.union([
|
|
28
|
+
z.string(),
|
|
29
|
+
z.looseObject({
|
|
30
|
+
type: z.string(),
|
|
31
|
+
url: Url,
|
|
32
|
+
}),
|
|
33
|
+
]);
|
|
34
|
+
export type PackageJsonFunding = z.infer<typeof PackageJsonFunding>;
|
|
35
|
+
|
|
36
|
+
// The base export value schema that can be a string, null, or nested conditions
|
|
37
|
+
const export_value_schema: z.ZodType = z.lazy(() =>
|
|
38
|
+
z.union([
|
|
39
|
+
z.string(),
|
|
40
|
+
z.null(),
|
|
41
|
+
z.record(
|
|
42
|
+
z.string(),
|
|
43
|
+
z.lazy(() => export_value_schema),
|
|
44
|
+
),
|
|
45
|
+
]),
|
|
46
|
+
);
|
|
47
|
+
export const ExportValue = export_value_schema;
|
|
48
|
+
export type ExportValue = z.infer<typeof ExportValue>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Package exports can be:
|
|
52
|
+
* 1. A string (shorthand for main export)
|
|
53
|
+
* 2. null (to block exports)
|
|
54
|
+
* 3. A record of export conditions/paths
|
|
55
|
+
*/
|
|
56
|
+
export const PackageJsonExports = z.union([
|
|
57
|
+
z.string(),
|
|
58
|
+
z.null(),
|
|
59
|
+
z.record(z.string(), export_value_schema),
|
|
60
|
+
]);
|
|
61
|
+
export type PackageJsonExports = z.infer<typeof PackageJsonExports>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @see https://docs.npmjs.com/cli/v10/configuring-npm/package-json
|
|
65
|
+
*/
|
|
66
|
+
export const PackageJson = z.looseObject({
|
|
67
|
+
// according to the npm docs, `name` and `version` are the only required properties
|
|
68
|
+
name: z.string(),
|
|
69
|
+
version: z.string(),
|
|
70
|
+
private: z
|
|
71
|
+
.boolean()
|
|
72
|
+
.meta({description: 'disallow publishing to the configured registry'})
|
|
73
|
+
.optional(),
|
|
74
|
+
public: z
|
|
75
|
+
.boolean()
|
|
76
|
+
.meta({
|
|
77
|
+
description:
|
|
78
|
+
'a Gro extension that enables publishing `.well-known/package.json` and `.well-known/src`',
|
|
79
|
+
})
|
|
80
|
+
.optional(),
|
|
81
|
+
description: z.string().optional(),
|
|
82
|
+
motto: z
|
|
83
|
+
.string()
|
|
84
|
+
.meta({description: "a Gro extension that's a short phrase that represents this project"})
|
|
85
|
+
.optional(),
|
|
86
|
+
glyph: z
|
|
87
|
+
.string()
|
|
88
|
+
.meta({
|
|
89
|
+
description: "a Gro extension that's a single unicode character that represents this project",
|
|
90
|
+
})
|
|
91
|
+
.refine((v) => count_graphemes(v) === 1, 'must be a single unicode character')
|
|
92
|
+
.optional(),
|
|
93
|
+
logo: z
|
|
94
|
+
.string()
|
|
95
|
+
.meta({
|
|
96
|
+
description:
|
|
97
|
+
"a Gro extension that's a link relative to the `homepage` to an image that represents this project",
|
|
98
|
+
})
|
|
99
|
+
.optional(),
|
|
100
|
+
logo_alt: z
|
|
101
|
+
.string()
|
|
102
|
+
.meta({description: "a Gro extension that's the alt text for the `logo`"})
|
|
103
|
+
.optional(),
|
|
104
|
+
license: z.string().optional(),
|
|
105
|
+
scripts: z.record(z.string(), z.string()).optional(),
|
|
106
|
+
homepage: Url.optional(),
|
|
107
|
+
author: z.union([z.string(), PackageJsonAuthor.optional()]),
|
|
108
|
+
repository: z.union([z.string(), Url, PackageJsonRepository]).optional(),
|
|
109
|
+
contributors: z.array(z.union([z.string(), PackageJsonAuthor])).optional(),
|
|
110
|
+
bugs: z
|
|
111
|
+
.union([z.string(), z.looseObject({url: Url.optional(), email: z.email().optional()})])
|
|
112
|
+
.optional(),
|
|
113
|
+
funding: z
|
|
114
|
+
.union([Url, PackageJsonFunding, z.array(z.union([Url, PackageJsonFunding]))])
|
|
115
|
+
.optional(),
|
|
116
|
+
keywords: z.array(z.string()).optional(),
|
|
117
|
+
|
|
118
|
+
type: z.string().optional(),
|
|
119
|
+
engines: z.record(z.string(), z.string()).optional(),
|
|
120
|
+
os: z.array(z.string()).optional(),
|
|
121
|
+
cpu: z.array(z.string()).optional(),
|
|
122
|
+
|
|
123
|
+
dependencies: z.record(z.string(), z.string()).optional(),
|
|
124
|
+
devDependencies: z.record(z.string(), z.string()).optional(),
|
|
125
|
+
peerDependencies: z.record(z.string(), z.string()).optional(),
|
|
126
|
+
peerDependenciesMeta: z.record(z.string(), z.looseObject({optional: z.boolean()})).optional(),
|
|
127
|
+
optionalDependencies: z.record(z.string(), z.string()).optional(),
|
|
128
|
+
|
|
129
|
+
bin: z.record(z.string(), z.string()).optional(),
|
|
130
|
+
sideEffects: z.array(z.string()).optional(),
|
|
131
|
+
files: z.array(z.string()).optional(),
|
|
132
|
+
main: z.string().optional(),
|
|
133
|
+
exports: PackageJsonExports.transform(transform_empty_object_to_undefined).optional(),
|
|
134
|
+
});
|
|
135
|
+
export type PackageJson = z.infer<typeof PackageJson>;
|
package/src/lib/path.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type {Flavored} from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An absolute path on the filesystem. Named "id" to be consistent with Rollup.
|
|
5
|
+
*/
|
|
6
|
+
export type PathId = Flavored<string, 'PathId'>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Basic information about a filesystem path.
|
|
10
|
+
*/
|
|
11
|
+
export interface PathInfo {
|
|
12
|
+
id: PathId;
|
|
13
|
+
is_directory: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A resolved path with both the original path string and its absolute id.
|
|
18
|
+
*/
|
|
19
|
+
export interface ResolvedPath extends PathInfo {
|
|
20
|
+
path: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A filter function for paths, can distinguish between files and directories.
|
|
25
|
+
*/
|
|
26
|
+
export type PathFilter = (path: string, is_directory: boolean) => boolean;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A filter function for file paths only.
|
|
30
|
+
*/
|
|
31
|
+
export type FileFilter = (path: string) => boolean;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Converts a URL to a file path string, or returns the string as-is.
|
|
35
|
+
*/
|
|
36
|
+
export const to_file_path = (path_or_url: string | URL): string =>
|
|
37
|
+
typeof path_or_url === 'string' ? path_or_url : decodeURIComponent(path_or_url.pathname);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @example parse_path_parts('./foo/bar/baz.ts') => ['foo', 'foo/bar', 'foo/bar/baz.ts']
|
|
41
|
+
*/
|
|
42
|
+
export const parse_path_parts = (path: string): Array<string> => {
|
|
43
|
+
const segments = parse_path_segments(path);
|
|
44
|
+
let current_path = path.startsWith('/') ? '/' : '';
|
|
45
|
+
return segments.map((segment) => {
|
|
46
|
+
if (current_path && current_path !== '/') {
|
|
47
|
+
current_path += '/';
|
|
48
|
+
}
|
|
49
|
+
current_path += segment;
|
|
50
|
+
return current_path;
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Gets the individual parts of a path, ignoring dots and separators.
|
|
56
|
+
* @example parse_path_segments('/foo/bar/baz.ts') => ['foo', 'bar', 'baz.ts']
|
|
57
|
+
*/
|
|
58
|
+
export const parse_path_segments = (path: string): Array<string> =>
|
|
59
|
+
path.split('/').filter((s) => s && s !== '.' && s !== '..');
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A piece of a parsed path, either a path segment or separator.
|
|
63
|
+
*/
|
|
64
|
+
export type PathPiece =
|
|
65
|
+
| {
|
|
66
|
+
type: 'piece';
|
|
67
|
+
path: PathId;
|
|
68
|
+
name: string;
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
type: 'separator';
|
|
72
|
+
path: PathId;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Treats all paths as absolute, so the first piece is always a `'/'` with type `'separator'`.
|
|
77
|
+
* @todo maybe rethink this API, it's a bit weird, but fits the usage in `ui/Breadcrumbs.svelte`
|
|
78
|
+
*/
|
|
79
|
+
export const parse_path_pieces = (raw_path: string): Array<PathPiece> => {
|
|
80
|
+
const pieces: Array<PathPiece> = [];
|
|
81
|
+
const path_segments = parse_path_segments(raw_path);
|
|
82
|
+
if (path_segments.length) {
|
|
83
|
+
pieces.push({type: 'separator', path: '/'});
|
|
84
|
+
}
|
|
85
|
+
let path = '';
|
|
86
|
+
for (let i = 0; i < path_segments.length; i++) {
|
|
87
|
+
const path_segment = path_segments[i]!;
|
|
88
|
+
path += '/' + path_segment;
|
|
89
|
+
pieces.push({type: 'piece', name: path_segment, path});
|
|
90
|
+
if (i !== path_segments.length - 1) {
|
|
91
|
+
pieces.push({type: 'separator', path});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return pieces;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Converts a string into a URL-compatible slug.
|
|
99
|
+
* @param str the string to convert
|
|
100
|
+
* @param map_special_characters if `true`, characters like `ñ` are converted to their ASCII equivalents, runs around 5x faster when disabled
|
|
101
|
+
* @mutates special_char_mappers calls `get_special_char_mappers()` which lazily initializes the module-level array if `map_special_characters` is true
|
|
102
|
+
*/
|
|
103
|
+
export const slugify = (str: string, map_special_characters = true): string => {
|
|
104
|
+
let s = str.toLowerCase();
|
|
105
|
+
if (map_special_characters) {
|
|
106
|
+
for (const mapper of get_special_char_mappers()) {
|
|
107
|
+
s = mapper(s);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return s
|
|
111
|
+
.replace(/[^\s\w-]/g, '')
|
|
112
|
+
.split(/[\s-]+/g) // collapse whitespace
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
.join('-'); // remove all `''`
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @see https://stackoverflow.com/questions/1053902/how-to-convert-a-title-to-a-url-slug-in-jquery/5782563#5782563
|
|
119
|
+
*/
|
|
120
|
+
const special_char_from = 'áäâàãåÆþčçćďđéěëèêẽĕȇğíìîïıňñóöòôõøðřŕšşßťúůüùûýÿž';
|
|
121
|
+
const special_char_to = 'aaaaaaabcccddeeeeeeeegiiiiinnooooooorrssstuuuuuyyz';
|
|
122
|
+
let special_char_mappers: Array<(s: string) => string> | undefined;
|
|
123
|
+
/**
|
|
124
|
+
* Lazily constructs `special_char_mappers` which
|
|
125
|
+
* converts special characters to their ASCII equivalents.
|
|
126
|
+
* @mutates special_char_mappers pushes mapper functions into module-level array on first call
|
|
127
|
+
*/
|
|
128
|
+
const get_special_char_mappers = (): Array<(s: string) => string> => {
|
|
129
|
+
if (special_char_mappers) return special_char_mappers;
|
|
130
|
+
special_char_mappers = [];
|
|
131
|
+
for (let i = 0, j = special_char_from.length; i < j; i++) {
|
|
132
|
+
special_char_mappers.push((s) =>
|
|
133
|
+
s.replaceAll(special_char_from.charAt(i), special_char_to.charAt(i)),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return special_char_mappers;
|
|
137
|
+
};
|
package/src/lib/print.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type {styleText} from 'node:util';
|
|
2
|
+
|
|
3
|
+
import type {Timings} from './timings.js';
|
|
4
|
+
import type {Logger} from './log.js';
|
|
5
|
+
|
|
6
|
+
export let st: typeof styleText = (_, v) => v;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Configures the module-level styling function for colored output.
|
|
10
|
+
* @mutates st assigns the module-level `st` variable
|
|
11
|
+
*/
|
|
12
|
+
export const configure_print_colors = (
|
|
13
|
+
s: typeof styleText | null | undefined,
|
|
14
|
+
): typeof styleText => {
|
|
15
|
+
st = s ?? ((_, v) => v);
|
|
16
|
+
return st;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Formats a key-value pair for printing.
|
|
21
|
+
*/
|
|
22
|
+
export const print_key_value = (key: string, value: string | number): string =>
|
|
23
|
+
st('gray', `${key}(`) + value + st('gray', ')');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Formats a `number` of milliseconds for printing.
|
|
27
|
+
*/
|
|
28
|
+
export const print_ms = (ms: number, decimals?: number, separator?: string): string => {
|
|
29
|
+
const decimal_count = decimals ?? (ms >= 10 ? 0 : ms < 0.1 ? 2 : 1);
|
|
30
|
+
const rounded = ms.toFixed(decimal_count);
|
|
31
|
+
return st('white', print_number_with_separators(rounded, separator)) + st('gray', 'ms');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Formats a `number` with separators for improved readability.
|
|
36
|
+
*/
|
|
37
|
+
export const print_number_with_separators = (v: string, separator = ','): string => {
|
|
38
|
+
if (!separator) return v;
|
|
39
|
+
const decimal_index = v.indexOf('.');
|
|
40
|
+
const start_index = (decimal_index === -1 ? v.length : decimal_index) - 1;
|
|
41
|
+
let s = decimal_index === -1 ? '' : v.slice(start_index + 1);
|
|
42
|
+
let count = 0;
|
|
43
|
+
for (let i = start_index; i >= 0; i--) {
|
|
44
|
+
count++;
|
|
45
|
+
if (count > 3 && count % 3 === 1) s = separator + s;
|
|
46
|
+
s = v[i] + s;
|
|
47
|
+
}
|
|
48
|
+
return s;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Formats a `string` for printing.
|
|
53
|
+
*/
|
|
54
|
+
export const print_string = (value: string): string => st('green', `'${value}'`);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Formats a `number` for printing.
|
|
58
|
+
*/
|
|
59
|
+
export const print_number = (value: number): string => st('cyan', value + '');
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Formats a `boolean` for printing.
|
|
63
|
+
*/
|
|
64
|
+
export const print_boolean = (value: boolean): string => st('yellow', value + '');
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Formats any value for printing.
|
|
68
|
+
*/
|
|
69
|
+
export const print_value = (value: unknown): string => {
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
return st('blue', '[') + value.map(print_value).join(st('blue', ', ')) + st('blue', ']');
|
|
72
|
+
}
|
|
73
|
+
switch (typeof value) {
|
|
74
|
+
case 'string':
|
|
75
|
+
return print_string(value);
|
|
76
|
+
case 'number':
|
|
77
|
+
return print_number(value);
|
|
78
|
+
case 'boolean':
|
|
79
|
+
return print_boolean(value);
|
|
80
|
+
case 'object':
|
|
81
|
+
return value === null ? st('blue', 'null') : st('magenta', JSON.stringify(value));
|
|
82
|
+
default:
|
|
83
|
+
return st('blue', value + '');
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Formats an error for printing.
|
|
89
|
+
* Because throwing errors and rejecting promises isn't typesafe,
|
|
90
|
+
* don't assume the arg is an `Error` and try to return something useful.
|
|
91
|
+
*/
|
|
92
|
+
export const print_error = (err: Error): string =>
|
|
93
|
+
st(
|
|
94
|
+
'yellow',
|
|
95
|
+
err.stack ?? ((err.message && `Error: ${err.message}`) || `Unknown error: ${err as any}`),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Formats a timing entry with `key` for printing.
|
|
100
|
+
*/
|
|
101
|
+
export const print_timing = (key: string | number, timing: number | undefined): string =>
|
|
102
|
+
`${timing === undefined ? '...' : print_ms(timing, undefined)} ${st('gray', '←')} ${st('gray', key + '')}`;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Prints all timings in a `Timings` object.
|
|
106
|
+
*/
|
|
107
|
+
export const print_timings = (timings: Timings, log: Logger): void => {
|
|
108
|
+
for (const [key, timing] of timings.entries()) {
|
|
109
|
+
log.debug(print_timing(key, timing));
|
|
110
|
+
}
|
|
111
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import {
|
|
2
|
+
spawn as spawn_child_process,
|
|
3
|
+
type SpawnOptions,
|
|
4
|
+
type ChildProcess,
|
|
5
|
+
} from 'node:child_process';
|
|
6
|
+
import {styleText as st} from 'node:util';
|
|
7
|
+
|
|
8
|
+
import {Logger} from './log.js';
|
|
9
|
+
import {print_error, print_key_value} from './print.js';
|
|
10
|
+
import type {Result} from './result.js';
|
|
11
|
+
|
|
12
|
+
const log = new Logger('process');
|
|
13
|
+
|
|
14
|
+
export interface SpawnedProcess {
|
|
15
|
+
child: ChildProcess;
|
|
16
|
+
closed: Promise<SpawnResult>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Spawned {
|
|
20
|
+
child: ChildProcess;
|
|
21
|
+
signal: NodeJS.Signals | null;
|
|
22
|
+
code: number | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// TODO are `code` and `signal` more related than that?
|
|
26
|
+
// e.g. should this be a union type where one is always `null`?
|
|
27
|
+
export type SpawnResult = Result<Spawned, Spawned>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A convenient promise wrapper around `spawn_process`
|
|
31
|
+
* intended for commands that have an end, not long running-processes like watchers.
|
|
32
|
+
* Any more advanced usage should use `spawn_process` directly for access to the `child` process.
|
|
33
|
+
*/
|
|
34
|
+
export const spawn = (...args: Parameters<typeof spawn_process>): Promise<SpawnResult> =>
|
|
35
|
+
spawn_process(...args).closed;
|
|
36
|
+
|
|
37
|
+
export interface SpawnedOut {
|
|
38
|
+
result: SpawnResult;
|
|
39
|
+
stdout: string | null;
|
|
40
|
+
stderr: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Similar to `spawn` but buffers and returns `stdout` and `stderr` as strings.
|
|
45
|
+
*/
|
|
46
|
+
export const spawn_out = async (
|
|
47
|
+
command: string,
|
|
48
|
+
args: ReadonlyArray<string> = [],
|
|
49
|
+
options?: SpawnOptions,
|
|
50
|
+
): Promise<SpawnedOut> => {
|
|
51
|
+
const {child, closed} = spawn_process(command, args, {...options, stdio: 'pipe'});
|
|
52
|
+
let stdout: string | null = null;
|
|
53
|
+
child.stdout!.on('data', (data: Buffer) => {
|
|
54
|
+
stdout = (stdout ?? '') + data.toString();
|
|
55
|
+
});
|
|
56
|
+
let stderr: string | null = null;
|
|
57
|
+
child.stderr!.on('data', (data: Buffer) => {
|
|
58
|
+
stderr = (stderr ?? '') + data.toString();
|
|
59
|
+
});
|
|
60
|
+
const result = await closed;
|
|
61
|
+
return {result, stdout, stderr};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Wraps the normal Node `childProcess.spawn` with graceful child shutdown behavior.
|
|
66
|
+
* Also returns a convenient `closed` promise.
|
|
67
|
+
* If you only need `closed`, prefer the shorthand function `spawn`.
|
|
68
|
+
* @mutates global_spawn calls `register_global_spawn()` which adds to the module-level Set
|
|
69
|
+
*/
|
|
70
|
+
export const spawn_process = (
|
|
71
|
+
command: string,
|
|
72
|
+
args: ReadonlyArray<string> = [],
|
|
73
|
+
options?: SpawnOptions,
|
|
74
|
+
): SpawnedProcess => {
|
|
75
|
+
let resolve: (v: SpawnResult) => void;
|
|
76
|
+
const closed: Promise<SpawnResult> = new Promise((r) => (resolve = r));
|
|
77
|
+
const child = spawn_child_process(command, args, {stdio: 'inherit', ...options});
|
|
78
|
+
const unregister = register_global_spawn(child);
|
|
79
|
+
child.once('close', (code, signal) => {
|
|
80
|
+
unregister();
|
|
81
|
+
resolve(code ? {ok: false, child, code, signal} : {ok: true, child, code, signal});
|
|
82
|
+
});
|
|
83
|
+
return {closed, child};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const print_child_process = (child: ChildProcess): string =>
|
|
87
|
+
`${st('gray', 'pid(')}${child.pid}${st('gray', ')')} ← ${st('green', child.spawnargs.join(' '))}`;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* We register spawned processes gloabally so we can gracefully exit child processes.
|
|
91
|
+
* Otherwise, errors can cause zombie processes, sometimes blocking ports even!
|
|
92
|
+
*/
|
|
93
|
+
export const global_spawn: Set<ChildProcess> = new Set();
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Returns a function that unregisters the `child`.
|
|
97
|
+
* @param child the child process to register
|
|
98
|
+
* @returns cleanup function that removes the child from `global_spawn`
|
|
99
|
+
* @mutates global_spawn adds child to the module-level Set, and the returned function removes it
|
|
100
|
+
*/
|
|
101
|
+
export const register_global_spawn = (child: ChildProcess): (() => void) => {
|
|
102
|
+
if (global_spawn.has(child)) {
|
|
103
|
+
log.error(st('red', 'already registered global spawn:'), print_child_process(child));
|
|
104
|
+
}
|
|
105
|
+
global_spawn.add(child);
|
|
106
|
+
return () => {
|
|
107
|
+
if (!global_spawn.has(child)) {
|
|
108
|
+
log.error(st('red', 'spawn not registered:'), print_child_process(child));
|
|
109
|
+
}
|
|
110
|
+
global_spawn.delete(child);
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Kills a child process and returns a `SpawnResult`.
|
|
116
|
+
*/
|
|
117
|
+
export const despawn = (child: ChildProcess): Promise<SpawnResult> => {
|
|
118
|
+
let resolve: (v: SpawnResult) => void;
|
|
119
|
+
const closed: Promise<SpawnResult> = new Promise((r) => (resolve = r));
|
|
120
|
+
log.debug('despawning', print_child_process(child));
|
|
121
|
+
child.once('close', (code, signal) => {
|
|
122
|
+
resolve(code ? {ok: false, child, code, signal} : {ok: true, child, code, signal});
|
|
123
|
+
});
|
|
124
|
+
child.kill();
|
|
125
|
+
return closed;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Kills all globally registered child processes.
|
|
130
|
+
* @mutates global_spawn indirectly removes processes through `despawn()` calls
|
|
131
|
+
*/
|
|
132
|
+
export const despawn_all = (): Promise<Array<SpawnResult>> =>
|
|
133
|
+
Promise.all(Array.from(global_spawn, (child) => despawn(child)));
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Attaches the `'uncoughtException'` event to despawn all processes,
|
|
137
|
+
* and enables custom error logging with `to_error_label`.
|
|
138
|
+
* @param to_error_label - Customize the error label or return `null` for the default `origin`.
|
|
139
|
+
* @param map_error_text - Customize the error text. Return `''` to silence, or `null` for the default `print_error(err)`.
|
|
140
|
+
*/
|
|
141
|
+
export const attach_process_error_handlers = (
|
|
142
|
+
to_error_label?: (err: Error, origin: NodeJS.UncaughtExceptionOrigin) => string | null,
|
|
143
|
+
map_error_text?: (err: Error, origin: NodeJS.UncaughtExceptionOrigin) => string | null,
|
|
144
|
+
handle_error: (err: Error, origin: NodeJS.UncaughtExceptionOrigin) => void = () =>
|
|
145
|
+
process.exit(1),
|
|
146
|
+
): void => {
|
|
147
|
+
process.on('uncaughtException', async (err, origin): Promise<void> => {
|
|
148
|
+
const label = to_error_label?.(err, origin) ?? origin;
|
|
149
|
+
if (label) {
|
|
150
|
+
const error_text = map_error_text?.(err, origin) ?? print_error(err);
|
|
151
|
+
if (error_text) {
|
|
152
|
+
new Logger(label).error(error_text);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
await despawn_all();
|
|
156
|
+
handle_error(err, origin);
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Formats a `SpawnResult` for printing.
|
|
162
|
+
*/
|
|
163
|
+
export const print_spawn_result = (result: SpawnResult): string => {
|
|
164
|
+
if (result.ok) return 'ok';
|
|
165
|
+
let text = result.code === null ? '' : print_key_value('code', result.code);
|
|
166
|
+
if (result.signal !== null) text += (text ? ' ' : '') + print_key_value('signal', result.signal);
|
|
167
|
+
return text;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// TODO might want to expand this API for some use cases - assumes always running
|
|
171
|
+
export interface RestartableProcess {
|
|
172
|
+
restart: () => void;
|
|
173
|
+
kill: () => Promise<void>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Like `spawn_process` but with `restart` and `kill`,
|
|
178
|
+
* handling many concurrent `restart` calls gracefully.
|
|
179
|
+
*/
|
|
180
|
+
export const spawn_restartable_process = (
|
|
181
|
+
command: string,
|
|
182
|
+
args: ReadonlyArray<string> = [],
|
|
183
|
+
options?: SpawnOptions,
|
|
184
|
+
): RestartableProcess => {
|
|
185
|
+
let spawned: SpawnedProcess | null = null;
|
|
186
|
+
let restarting: Promise<any> | null = null;
|
|
187
|
+
const close = async (): Promise<void> => {
|
|
188
|
+
if (!spawned) return;
|
|
189
|
+
restarting = spawned.closed;
|
|
190
|
+
spawned.child.kill();
|
|
191
|
+
spawned = null;
|
|
192
|
+
await restarting;
|
|
193
|
+
restarting = null;
|
|
194
|
+
};
|
|
195
|
+
const restart = async (): Promise<void> => {
|
|
196
|
+
if (restarting) return restarting;
|
|
197
|
+
if (spawned) await close();
|
|
198
|
+
spawned = spawn_process(command, args, {stdio: 'inherit', ...options});
|
|
199
|
+
};
|
|
200
|
+
const kill = async (): Promise<void> => {
|
|
201
|
+
if (restarting) await restarting;
|
|
202
|
+
await close();
|
|
203
|
+
};
|
|
204
|
+
// Start immediately -- it sychronously starts the process so there's no need to await.
|
|
205
|
+
void restart();
|
|
206
|
+
return {restart, kill};
|
|
207
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type {ArrayElement} from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generates a random `number` between `min` and `max`.
|
|
5
|
+
*/
|
|
6
|
+
export const random_float = (min: number, max: number, random = Math.random): number =>
|
|
7
|
+
random() * (max - min) + min;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns a random integer between `min` and `max` inclusive.
|
|
11
|
+
* Node's `randomInt` is similar but exclusive of the max value, and makes `min` optional -
|
|
12
|
+
* https://nodejs.org/docs/latest-v20.x/api/crypto.html#cryptorandomintmin-max-callback
|
|
13
|
+
*/
|
|
14
|
+
export const random_int = (min: number, max: number, random = Math.random): number =>
|
|
15
|
+
Math.floor(random() * (max - min + 1)) + min;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generates a random `boolean`.
|
|
19
|
+
*/
|
|
20
|
+
export const random_boolean = (random = Math.random): boolean => random() > 0.5;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Selects a random item from an array.
|
|
24
|
+
*/
|
|
25
|
+
export const random_item = <T extends ReadonlyArray<any>>(
|
|
26
|
+
arr: T,
|
|
27
|
+
random = Math.random,
|
|
28
|
+
): ArrayElement<T> => arr[random_int(0, arr.length - 1, random)];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Mutates `array` with random ordering.
|
|
32
|
+
* @mutates array randomly reorders elements in place using Fisher-Yates shuffle
|
|
33
|
+
*/
|
|
34
|
+
export const shuffle: <T extends Array<any>>(array: T, random?: typeof random_int) => T = (
|
|
35
|
+
array,
|
|
36
|
+
random = random_int,
|
|
37
|
+
) => {
|
|
38
|
+
const {length} = array;
|
|
39
|
+
const max = length - 1;
|
|
40
|
+
for (let i = 0; i < length; i++) {
|
|
41
|
+
const index = random(0, max);
|
|
42
|
+
if (i === index) continue;
|
|
43
|
+
const item = array[index];
|
|
44
|
+
array[index] = array[i];
|
|
45
|
+
array[i] = item;
|
|
46
|
+
}
|
|
47
|
+
return array;
|
|
48
|
+
};
|