@gtkx/utils 0.21.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.
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Cross-process graceful shutdown primitive for long-running Node processes.
3
+ *
4
+ * The helper installs `SIGINT`/`SIGTERM`/`SIGHUP` handlers, routes the first
5
+ * delivered signal through a user-supplied close callback, and escalates either
6
+ * on a second `SIGINT` or after a configurable timeout. On completion it calls
7
+ * `process.exit` with the canonical exit code for the signal.
8
+ */
9
+
10
+ const HANDLED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const satisfies readonly NodeJS.Signals[];
11
+ const DEFAULT_FORCE_KILL_TIMEOUT_MS = 5000;
12
+
13
+ /**
14
+ * Maps the POSIX signal that ended a process into the exit code shells use to
15
+ * report it. `SIGINT` yields `130` (Ctrl-C), every other tracked signal yields
16
+ * `143` (`SIGTERM`). A `null` signal — i.e. a clean exit — yields `0`.
17
+ *
18
+ * @param signal - The signal name, or `null` for a clean exit.
19
+ * @returns The exit code shells expect for `signal`.
20
+ */
21
+ export const exitCodeForSignal = (signal: NodeJS.Signals | null): number => {
22
+ if (!signal) return 0;
23
+ return signal === "SIGINT" ? 130 : 143;
24
+ };
25
+
26
+ /**
27
+ * Caller-supplied behaviour for {@link installGracefulShutdown}.
28
+ */
29
+ export type GracefulShutdownOptions = {
30
+ /**
31
+ * Invoked once on the first delivered signal. Its returned promise is
32
+ * awaited before {@link process.exit} fires; rejection is logged but does
33
+ * not block the exit.
34
+ */
35
+ onSignal: (signal: NodeJS.Signals) => void | Promise<void>;
36
+ /**
37
+ * Invoked when escalation is required: on a second `SIGINT`, or when
38
+ * {@link GracefulShutdownOptions.forceKillAfterMs} elapses before the
39
+ * primary close finishes.
40
+ */
41
+ onForce?: () => void;
42
+ /**
43
+ * Milliseconds to wait for the primary close before invoking `onForce`
44
+ * and exiting. Defaults to {@link DEFAULT_FORCE_KILL_TIMEOUT_MS}. Set to
45
+ * `0` to disable timeout-based escalation.
46
+ */
47
+ forceKillAfterMs?: number;
48
+ /**
49
+ * Overrides the exit code computed from the triggering signal. Use this
50
+ * when the caller wants to propagate a child's own exit code instead.
51
+ */
52
+ exitCode?: (signal: NodeJS.Signals) => number;
53
+ };
54
+
55
+ /**
56
+ * Handle returned by {@link installGracefulShutdown}, used to detach the
57
+ * helper's signal handlers (for example in tests).
58
+ */
59
+ export type GracefulShutdownHandle = {
60
+ /**
61
+ * Removes the installed signal handlers and cancels any pending
62
+ * escalation timer. Idempotent.
63
+ */
64
+ uninstall: () => void;
65
+ };
66
+
67
+ /**
68
+ * Installs the graceful-shutdown primitive on the current process.
69
+ *
70
+ * On the first matching signal: invokes `onSignal`, optionally schedules
71
+ * `onForce` after `forceKillAfterMs`, awaits the close, then exits. A second
72
+ * `SIGINT` (the canonical "force-kill" gesture) invokes `onForce`
73
+ * immediately. The exit code defaults to {@link exitCodeForSignal} but can
74
+ * be overridden by `exitCode`.
75
+ *
76
+ * @param options - Shutdown behaviour.
77
+ * @returns A handle that detaches the installed signal handlers.
78
+ */
79
+ export const installGracefulShutdown = (options: GracefulShutdownOptions): GracefulShutdownHandle => {
80
+ const forceKillMs = options.forceKillAfterMs ?? DEFAULT_FORCE_KILL_TIMEOUT_MS;
81
+
82
+ let firstSignal: NodeJS.Signals | null = null;
83
+ let exited = false;
84
+ let forceTimer: NodeJS.Timeout | null = null;
85
+
86
+ const clearTimer = (): void => {
87
+ if (forceTimer) {
88
+ clearTimeout(forceTimer);
89
+ forceTimer = null;
90
+ }
91
+ };
92
+
93
+ const finish = (signal: NodeJS.Signals): void => {
94
+ if (exited) return;
95
+ exited = true;
96
+ clearTimer();
97
+ const code = options.exitCode ? options.exitCode(signal) : exitCodeForSignal(signal);
98
+ process.exit(code);
99
+ };
100
+
101
+ const handle = (signal: NodeJS.Signals): void => {
102
+ if (firstSignal === null) {
103
+ firstSignal = signal;
104
+ if (options.onForce && forceKillMs > 0) {
105
+ forceTimer = setTimeout(() => {
106
+ options.onForce?.();
107
+ finish(signal);
108
+ }, forceKillMs);
109
+ forceTimer.unref?.();
110
+ }
111
+ Promise.resolve()
112
+ .then(() => options.onSignal(signal))
113
+ .catch((error: unknown) => {
114
+ console.error("Graceful shutdown error:", error);
115
+ })
116
+ .finally(() => finish(signal));
117
+ return;
118
+ }
119
+ if (signal === "SIGINT" && firstSignal === "SIGINT") {
120
+ options.onForce?.();
121
+ finish(signal);
122
+ }
123
+ };
124
+
125
+ const handlers = new Map<NodeJS.Signals, () => void>();
126
+ for (const sig of HANDLED_SIGNALS) {
127
+ const listener = (): void => handle(sig);
128
+ handlers.set(sig, listener);
129
+ process.on(sig, listener);
130
+ }
131
+
132
+ return {
133
+ uninstall: () => {
134
+ for (const [sig, listener] of handlers) {
135
+ process.removeListener(sig, listener);
136
+ }
137
+ handlers.clear();
138
+ clearTimer();
139
+ },
140
+ };
141
+ };
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export type { AnyClass } from "./class.js";
2
+ export { isShallowArrayEqual, isShallowEqual, omit, reverseNumericEnum } from "./collection.js";
3
+ export { errorMessage } from "./error.js";
4
+ export {
5
+ exitCodeForSignal,
6
+ type GracefulShutdownHandle,
7
+ installGracefulShutdown,
8
+ } from "./graceful-shutdown.js";
9
+ export { quote, toIdentifier } from "./source.js";
10
+ export { toCamelCase, toKebabCase, toPascalCase, toUpperFirst } from "./string.js";
package/src/source.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Helpers for shaping values into safe JavaScript/TypeScript source fragments:
3
+ * a reserved-word-safe identifier and a source-safe string literal. Both are
4
+ * pure and runtime-agnostic, intended for code generators that emit TypeScript.
5
+ */
6
+
7
+ /** Reserved words and global identifiers a generated identifier must not collide with. */
8
+ const RESERVED: ReadonlySet<string> = new Set([
9
+ "arguments",
10
+ "await",
11
+ "break",
12
+ "case",
13
+ "catch",
14
+ "class",
15
+ "const",
16
+ "continue",
17
+ "debugger",
18
+ "default",
19
+ "delete",
20
+ "do",
21
+ "else",
22
+ "enum",
23
+ "eval",
24
+ "export",
25
+ "extends",
26
+ "false",
27
+ "finally",
28
+ "for",
29
+ "function",
30
+ "if",
31
+ "import",
32
+ "in",
33
+ "instanceof",
34
+ "interface",
35
+ "let",
36
+ "new",
37
+ "null",
38
+ "package",
39
+ "private",
40
+ "protected",
41
+ "public",
42
+ "return",
43
+ "static",
44
+ "super",
45
+ "switch",
46
+ "this",
47
+ "throw",
48
+ "true",
49
+ "try",
50
+ "typeof",
51
+ "var",
52
+ "void",
53
+ "while",
54
+ "with",
55
+ "yield",
56
+ ]);
57
+
58
+ /**
59
+ * Rewrites a candidate name into a JavaScript identifier safe to use at
60
+ * variable, parameter, or property position.
61
+ *
62
+ * The input is expected to already use valid identifier characters (for
63
+ * example the output of a case-conversion helper); the only transformation
64
+ * applied is appending an underscore when `name` collides with a reserved
65
+ * word or global identifier, so `toIdentifier("class")` is `"class_"` and
66
+ * `toIdentifier("iconName")` is `"iconName"`.
67
+ *
68
+ * @param name - The candidate identifier.
69
+ * @returns A reserved-word-safe identifier.
70
+ */
71
+ export const toIdentifier = (name: string): string => (RESERVED.has(name) ? `${name}_` : name);
72
+
73
+ const UNSAFE_SOURCE_CHARS = /[<>\u2028\u2029]/g;
74
+
75
+ const escapeSourceChar = (char: string): string => {
76
+ switch (char) {
77
+ case "<":
78
+ return "\\u003C";
79
+ case ">":
80
+ return "\\u003E";
81
+ case "\u2028":
82
+ return "\\u2028";
83
+ case "\u2029":
84
+ return "\\u2029";
85
+ default:
86
+ return char;
87
+ }
88
+ };
89
+
90
+ /**
91
+ * Quotes a string for safe embedding as a literal in generated TypeScript
92
+ * source.
93
+ *
94
+ * Builds a double-quoted literal with `JSON.stringify`, then escapes the
95
+ * characters that are valid inside a JSON string yet unsafe inside JavaScript
96
+ * source: the angle brackets that could otherwise break out of an enclosing
97
+ * `</script>` and the U+2028/U+2029 line separators that prematurely terminate
98
+ * a string literal. The escaped form parses back to the original value.
99
+ *
100
+ * @param value - The string to embed.
101
+ * @returns A source-safe double-quoted string literal.
102
+ */
103
+ export const quote = (value: string): string => JSON.stringify(value).replace(UNSAFE_SOURCE_CHARS, escapeSourceChar);
package/src/string.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Pure, runtime-agnostic string-case helpers.
3
+ *
4
+ * The conversions translate between snake_case, kebab-case, camelCase, and
5
+ * PascalCase. They split only on underscores and hyphens and preserve the case
6
+ * of each segment, so they are not a substitute for a full Unicode-aware case
7
+ * transform.
8
+ */
9
+
10
+ /**
11
+ * Uppercases the first character of `value`, leaving the remaining characters
12
+ * untouched.
13
+ *
14
+ * The tail is preserved verbatim rather than lowercased, so
15
+ * `toUpperFirst("fooBar")` is `"FooBar"` and `toUpperFirst("URL")` is `"URL"`. An
16
+ * empty string returns an empty string.
17
+ *
18
+ * @param value - The string to transform.
19
+ * @returns `value` with its first character uppercased.
20
+ */
21
+ export const toUpperFirst = (value: string): string => value.charAt(0).toUpperCase() + value.slice(1);
22
+
23
+ /**
24
+ * Converts a snake_case or kebab-case string to camelCase.
25
+ *
26
+ * The input is split on underscores and hyphens, dropping empty segments from
27
+ * leading, trailing, or repeated separators. The first segment is kept
28
+ * verbatim and every later segment is {@link toUpperFirst}-cased before joining,
29
+ * so `toCamelCase("icon_name")` is `"iconName"` and `toCamelCase("Box")` is
30
+ * `"Box"`. A string with no separators is returned unchanged.
31
+ *
32
+ * @param input - The snake_case or kebab-case identifier.
33
+ * @returns The camelCase form of `input`.
34
+ */
35
+ export const toCamelCase = (input: string): string => {
36
+ const parts = input.split(/[_-]/g).filter((part) => part.length > 0);
37
+ if (parts.length === 0) return input;
38
+ const [first, ...rest] = parts;
39
+ const head = first ?? "";
40
+ return head + rest.map(toUpperFirst).join("");
41
+ };
42
+
43
+ /**
44
+ * Converts a snake_case, kebab-case, or already-PascalCase string to
45
+ * PascalCase.
46
+ *
47
+ * The input is split on underscores and hyphens, dropping empty segments, and
48
+ * every remaining segment is {@link toUpperFirst}-cased before joining, so
49
+ * `toPascalCase("icon_name")` is `"IconName"` and `toPascalCase("Box")` is
50
+ * `"Box"`. An empty string is returned unchanged.
51
+ *
52
+ * @param input - The identifier to transform.
53
+ * @returns The PascalCase form of `input`.
54
+ */
55
+ export const toPascalCase = (input: string): string => {
56
+ if (input.length === 0) return input;
57
+ const parts = input.split(/[_-]/g).filter((part) => part.length > 0);
58
+ if (parts.length === 0) return input;
59
+ return parts.map(toUpperFirst).join("");
60
+ };
61
+
62
+ /**
63
+ * Converts a camelCase or PascalCase string to kebab-case.
64
+ *
65
+ * Each uppercase character is lowercased; every uppercase character other than
66
+ * the first is additionally prefixed with a hyphen, so `toKebabCase("iconName")`
67
+ * is `"icon-name"` and `toKebabCase("Title")` is `"title"`. The leading
68
+ * character is never prefixed with a hyphen.
69
+ *
70
+ * @param input - The camelCase or PascalCase identifier.
71
+ * @returns The kebab-case form of `input`.
72
+ */
73
+ export const toKebabCase = (input: string): string =>
74
+ input.replaceAll(/[A-Z]/g, (char, index: number) => (index === 0 ? char.toLowerCase() : `-${char.toLowerCase()}`));