pidnap 0.0.0-dev.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,228 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import readline from "node:readline";
3
+ import { PassThrough } from "node:stream";
4
+ import * as v from "valibot";
5
+ import type { Logger } from "./logger.ts";
6
+
7
+ export const ProcessDefinitionSchema = v.object({
8
+ command: v.string(),
9
+ args: v.optional(v.array(v.string())),
10
+ cwd: v.optional(v.string()),
11
+ env: v.optional(v.record(v.string(), v.string())),
12
+ });
13
+
14
+ export type ProcessDefinition = v.InferOutput<typeof ProcessDefinitionSchema>;
15
+
16
+ export const ProcessStateSchema = v.picklist([
17
+ "idle",
18
+ "starting",
19
+ "running",
20
+ "stopping",
21
+ "stopped",
22
+ "error",
23
+ ]);
24
+
25
+ export type ProcessState = v.InferOutput<typeof ProcessStateSchema>;
26
+
27
+ /**
28
+ * Kill a process. Tries to kill the process group first (if available),
29
+ * then falls back to killing just the process.
30
+ */
31
+ function killProcess(child: ChildProcess, signal: NodeJS.Signals): boolean {
32
+ try {
33
+ return child.kill(signal);
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ export class LazyProcess {
40
+ readonly name: string;
41
+ private definition: ProcessDefinition;
42
+ private logger: Logger;
43
+ private childProcess: ChildProcess | null = null;
44
+ private _state: ProcessState = "idle";
45
+ private processExit = Promise.withResolvers<void>();
46
+ public exitCode: number | null = null;
47
+
48
+ constructor(name: string, definition: ProcessDefinition, logger: Logger) {
49
+ this.name = name;
50
+ this.definition = definition;
51
+ this.logger = logger;
52
+ }
53
+
54
+ get state(): ProcessState {
55
+ return this._state;
56
+ }
57
+
58
+ start(): void {
59
+ if (this._state === "running" || this._state === "starting") {
60
+ throw new Error(`Process "${this.name}" is already ${this._state}`);
61
+ }
62
+
63
+ if (this._state === "stopping") {
64
+ throw new Error(`Process "${this.name}" is currently stopping`);
65
+ }
66
+
67
+ this._state = "starting";
68
+ this.logger.info(`Starting process: ${this.definition.command}`);
69
+
70
+ try {
71
+ const env = this.definition.env ? { ...process.env, ...this.definition.env } : process.env;
72
+
73
+ this.childProcess = spawn(this.definition.command, this.definition.args ?? [], {
74
+ cwd: this.definition.cwd,
75
+ env,
76
+ stdio: ["ignore", "pipe", "pipe"],
77
+ });
78
+
79
+ this._state = "running";
80
+
81
+ // Combine stdout and stderr into a single stream for unified logging
82
+ const combined = new PassThrough();
83
+ let streamCount = 0;
84
+
85
+ if (this.childProcess.stdout) {
86
+ streamCount++;
87
+ this.childProcess.stdout.pipe(combined, { end: false });
88
+ this.childProcess.stdout.on("end", () => {
89
+ if (--streamCount === 0) combined.end();
90
+ });
91
+ }
92
+
93
+ if (this.childProcess.stderr) {
94
+ streamCount++;
95
+ this.childProcess.stderr.pipe(combined, { end: false });
96
+ this.childProcess.stderr.on("end", () => {
97
+ if (--streamCount === 0) combined.end();
98
+ });
99
+ }
100
+
101
+ // Use readline to handle line-by-line output properly
102
+ const rl = readline.createInterface({ input: combined });
103
+ rl.on("line", (line) => {
104
+ this.logger.info(line);
105
+ });
106
+
107
+ // Handle process exit
108
+ this.childProcess.on("exit", (code, signal) => {
109
+ this.exitCode = code;
110
+
111
+ if (this._state === "running") {
112
+ if (code === 0) {
113
+ this._state = "stopped";
114
+ this.logger.info(`Process exited with code ${code}`);
115
+ } else if (signal) {
116
+ this._state = "stopped";
117
+ this.logger.info(`Process killed with signal ${signal}`);
118
+ } else {
119
+ this._state = "error";
120
+ this.logger.error(`Process exited with code ${code}`);
121
+ }
122
+ }
123
+
124
+ this.processExit.resolve();
125
+ });
126
+
127
+ // Handle spawn errors
128
+ this.childProcess.on("error", (err) => {
129
+ if (this._state !== "stopping" && this._state !== "stopped") {
130
+ this._state = "error";
131
+ this.logger.error(`Process error:`, err);
132
+ }
133
+ this.processExit.resolve();
134
+ });
135
+ } catch (err) {
136
+ this._state = "error";
137
+ this.logger.error(`Failed to start process:`, err);
138
+ throw err;
139
+ }
140
+ }
141
+
142
+ async stop(timeout?: number): Promise<void> {
143
+ if (this._state === "idle" || this._state === "stopped" || this._state === "error") {
144
+ return;
145
+ }
146
+
147
+ if (this._state === "stopping") {
148
+ // Already stopping, wait for completion
149
+ await this.processExit.promise;
150
+ return;
151
+ }
152
+
153
+ if (!this.childProcess) {
154
+ this._state = "stopped";
155
+ return;
156
+ }
157
+
158
+ this._state = "stopping";
159
+ this.logger.info(`Stopping process with SIGTERM`);
160
+
161
+ // Send SIGTERM for graceful shutdown (to entire process group)
162
+ killProcess(this.childProcess, "SIGTERM");
163
+
164
+ const timeoutMs = timeout ?? 5000;
165
+
166
+ // Wait for process to exit or timeout
167
+ const timeoutPromise = new Promise<"timeout">((resolve) =>
168
+ setTimeout(() => resolve("timeout"), timeoutMs),
169
+ );
170
+
171
+ const result = await Promise.race([
172
+ this.processExit.promise.then(() => "exited" as const),
173
+ timeoutPromise,
174
+ ]);
175
+
176
+ if (result === "timeout" && this.childProcess) {
177
+ this.logger.warn(`Process did not exit within ${timeoutMs}ms, sending SIGKILL`);
178
+ killProcess(this.childProcess, "SIGKILL");
179
+
180
+ // Give SIGKILL a short timeout
181
+ const killTimeout = new Promise<void>((resolve) => setTimeout(resolve, 1000));
182
+ await Promise.race([this.processExit.promise, killTimeout]);
183
+ }
184
+
185
+ this._state = "stopped";
186
+ this.cleanup();
187
+ this.logger.info(`Process stopped`);
188
+ }
189
+
190
+ async reset(): Promise<void> {
191
+ if (this.childProcess) {
192
+ // Kill the process group if running
193
+ killProcess(this.childProcess, "SIGKILL");
194
+ await this.processExit.promise;
195
+ this.cleanup();
196
+ }
197
+
198
+ this._state = "idle";
199
+ // Create a fresh promise for the next process lifecycle
200
+ this.processExit = Promise.withResolvers<void>();
201
+ this.logger.info(`Process reset to idle`);
202
+ }
203
+
204
+ updateDefinition(definition: ProcessDefinition): void {
205
+ this.definition = definition;
206
+ }
207
+
208
+ async waitForExit(): Promise<ProcessState> {
209
+ if (!this.childProcess) {
210
+ return this._state;
211
+ }
212
+
213
+ await this.processExit.promise;
214
+ return this._state;
215
+ }
216
+
217
+ private cleanup(): void {
218
+ if (this.childProcess) {
219
+ // Remove all listeners to prevent memory leaks
220
+ this.childProcess.stdout?.removeAllListeners();
221
+ this.childProcess.stderr?.removeAllListeners();
222
+ this.childProcess.removeAllListeners();
223
+ this.childProcess = null;
224
+ }
225
+
226
+ this.exitCode = null;
227
+ }
228
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import { format } from "node:util";
3
+
4
+ const colors = {
5
+ reset: "\x1b[0m",
6
+ gray: "\x1b[90m",
7
+ white: "\x1b[37m",
8
+ green: "\x1b[32m",
9
+ yellow: "\x1b[33m",
10
+ red: "\x1b[31m",
11
+ bold: "\x1b[1m",
12
+ } as const;
13
+
14
+ const levelColors = {
15
+ debug: colors.gray,
16
+ info: colors.green,
17
+ warn: colors.yellow,
18
+ error: colors.red,
19
+ } as const;
20
+
21
+ const formatTime = (date: Date) =>
22
+ Intl.DateTimeFormat("en-US", {
23
+ hour: "2-digit",
24
+ minute: "2-digit",
25
+ second: "2-digit",
26
+ fractionalSecondDigits: 3,
27
+ hourCycle: "h23",
28
+ }).format(date);
29
+
30
+ const formatPrefixNoColor = (level: string, name: string, time: Date) => {
31
+ const levelFormatted = level.toUpperCase().padStart(5);
32
+ const timestamp = formatTime(time);
33
+ return `[${timestamp}] ${levelFormatted} (${name})`;
34
+ };
35
+
36
+ const formatPrefixWithColor = (level: string, name: string, time: Date) => {
37
+ const levelFormatted = level.toUpperCase().padStart(5);
38
+ const timestamp = formatTime(time);
39
+ const levelTint = levelColors[level as keyof typeof levelColors] ?? "";
40
+ return `${colors.gray}[${timestamp}]${colors.reset} ${levelTint}${levelFormatted}${colors.reset} (${name})`;
41
+ };
42
+
43
+ type LoggerConfig = {
44
+ name: string;
45
+ stdout: boolean;
46
+ logFile?: string;
47
+ };
48
+
49
+ type LoggerInput = {
50
+ name: string;
51
+ stdout?: boolean;
52
+ logFile?: string;
53
+ };
54
+
55
+ const writeLogFile = (logFile: string | undefined, line: string) => {
56
+ if (!logFile) return;
57
+ appendFileSync(logFile, `${line}\n`);
58
+ };
59
+
60
+ const logLine = (config: LoggerConfig, level: "debug" | "info" | "warn" | "error", args: any[]) => {
61
+ const message = args.length > 0 ? format(...args) : "";
62
+ const time = new Date();
63
+ const plainPrefix = formatPrefixNoColor(level, config.name, time);
64
+ const plainLine = `${plainPrefix} ${message}`;
65
+
66
+ writeLogFile(config.logFile, plainLine);
67
+
68
+ if (!config.stdout) return;
69
+ const coloredPrefix = formatPrefixWithColor(level, config.name, time);
70
+ const coloredLine = `${coloredPrefix} ${message}`;
71
+
72
+ switch (level) {
73
+ case "error":
74
+ console.error(coloredLine);
75
+ break;
76
+ case "warn":
77
+ console.warn(coloredLine);
78
+ break;
79
+ case "info":
80
+ console.info(coloredLine);
81
+ break;
82
+ default:
83
+ console.debug(coloredLine);
84
+ break;
85
+ }
86
+ };
87
+
88
+ export const logger = (input: LoggerInput) => {
89
+ const config: LoggerConfig = {
90
+ stdout: true,
91
+ ...input,
92
+ };
93
+
94
+ return {
95
+ info: (...args: any[]) => logLine(config, "info", args),
96
+ error: (...args: any[]) => logLine(config, "error", args),
97
+ warn: (...args: any[]) => logLine(config, "warn", args),
98
+ debug: (...args: any[]) => logLine(config, "debug", args),
99
+ child: (suffix: string, overrides: Partial<Omit<LoggerConfig, "name">> = {}) =>
100
+ logger({
101
+ ...config,
102
+ ...overrides,
103
+ name: `${config.name}:${suffix}`,
104
+ }),
105
+ };
106
+ };
107
+
108
+ export type Logger = ReturnType<typeof logger>;