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.
- package/README.md +45 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +1166 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/client.d.mts +169 -0
- package/dist/client.d.mts.map +1 -0
- package/dist/client.mjs +15 -0
- package/dist/client.mjs.map +1 -0
- package/dist/index.d.mts +230 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +10 -0
- package/dist/index.mjs.map +1 -0
- package/dist/logger-crc5neL8.mjs +966 -0
- package/dist/logger-crc5neL8.mjs.map +1 -0
- package/dist/task-list-CIdbB3wM.d.mts +230 -0
- package/dist/task-list-CIdbB3wM.d.mts.map +1 -0
- package/package.json +50 -0
- package/src/api/client.ts +13 -0
- package/src/api/contract.ts +117 -0
- package/src/api/server.ts +180 -0
- package/src/cli.ts +462 -0
- package/src/cron-process.ts +255 -0
- package/src/env-manager.ts +237 -0
- package/src/index.ts +12 -0
- package/src/lazy-process.ts +228 -0
- package/src/logger.ts +108 -0
- package/src/manager.ts +859 -0
- package/src/restarting-process.ts +397 -0
- package/src/task-list.ts +236 -0
|
@@ -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>;
|