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
package/src/manager.ts
ADDED
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { cwd as getCwd } from "node:process";
|
|
4
|
+
import { mkdirSync } from "node:fs";
|
|
5
|
+
import { ProcessDefinitionSchema, type ProcessDefinition } from "./lazy-process.ts";
|
|
6
|
+
import type { Logger } from "./logger.ts";
|
|
7
|
+
import { TaskList, type NamedProcessDefinition } from "./task-list.ts";
|
|
8
|
+
import { CronProcess, CronProcessOptionsSchema } from "./cron-process.ts";
|
|
9
|
+
import {
|
|
10
|
+
RestartingProcess,
|
|
11
|
+
RestartingProcessOptionsSchema,
|
|
12
|
+
type RestartingProcessOptions,
|
|
13
|
+
} from "./restarting-process.ts";
|
|
14
|
+
import { EnvManager } from "./env-manager.ts";
|
|
15
|
+
|
|
16
|
+
// Valibot schemas
|
|
17
|
+
|
|
18
|
+
// HTTP server configuration schema
|
|
19
|
+
export const HttpServerConfigSchema = v.object({
|
|
20
|
+
host: v.optional(v.string()),
|
|
21
|
+
port: v.optional(v.number()),
|
|
22
|
+
authToken: v.optional(v.string()),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export type HttpServerConfig = v.InferOutput<typeof HttpServerConfigSchema>;
|
|
26
|
+
|
|
27
|
+
// Cron process entry schema
|
|
28
|
+
export const CronProcessEntrySchema = v.object({
|
|
29
|
+
name: v.string(),
|
|
30
|
+
definition: ProcessDefinitionSchema,
|
|
31
|
+
options: CronProcessOptionsSchema,
|
|
32
|
+
envFile: v.optional(v.string()),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export type CronProcessEntry = v.InferOutput<typeof CronProcessEntrySchema>;
|
|
36
|
+
|
|
37
|
+
// Env reload delay schema - can be number (ms), "immediately"/true, or false for disabled
|
|
38
|
+
export const EnvReloadDelaySchema = v.union([v.number(), v.boolean(), v.literal("immediately")]);
|
|
39
|
+
|
|
40
|
+
export type EnvReloadDelay = v.InferOutput<typeof EnvReloadDelaySchema>;
|
|
41
|
+
|
|
42
|
+
// Restarting process entry schema
|
|
43
|
+
export const RestartingProcessEntrySchema = v.object({
|
|
44
|
+
name: v.string(),
|
|
45
|
+
definition: ProcessDefinitionSchema,
|
|
46
|
+
options: v.optional(RestartingProcessOptionsSchema),
|
|
47
|
+
envFile: v.optional(v.string()),
|
|
48
|
+
envReloadDelay: v.optional(EnvReloadDelaySchema), // Default 5000ms
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export type RestartingProcessEntry = v.InferOutput<typeof RestartingProcessEntrySchema>;
|
|
52
|
+
|
|
53
|
+
// Task entry schema with envFile
|
|
54
|
+
export const TaskEntrySchema = v.object({
|
|
55
|
+
name: v.string(),
|
|
56
|
+
definition: ProcessDefinitionSchema,
|
|
57
|
+
envFile: v.optional(v.string()),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export type TaskEntry = v.InferOutput<typeof TaskEntrySchema>;
|
|
61
|
+
|
|
62
|
+
// Main manager configuration schema
|
|
63
|
+
export const ManagerConfigSchema = v.object({
|
|
64
|
+
http: v.optional(HttpServerConfigSchema),
|
|
65
|
+
cwd: v.optional(v.string()),
|
|
66
|
+
logDir: v.optional(v.string()),
|
|
67
|
+
env: v.optional(v.record(v.string(), v.string())),
|
|
68
|
+
envFiles: v.optional(v.record(v.string(), v.string())),
|
|
69
|
+
tasks: v.optional(v.array(TaskEntrySchema)),
|
|
70
|
+
crons: v.optional(v.array(CronProcessEntrySchema)),
|
|
71
|
+
processes: v.optional(v.array(RestartingProcessEntrySchema)),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export type ManagerConfig = v.InferOutput<typeof ManagerConfigSchema>;
|
|
75
|
+
|
|
76
|
+
// Default restart policy when not specified
|
|
77
|
+
const DEFAULT_RESTART_OPTIONS = {
|
|
78
|
+
restartPolicy: "always" as const,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Hardcoded shutdown configuration
|
|
82
|
+
const SHUTDOWN_TIMEOUT_MS = 15000;
|
|
83
|
+
const SHUTDOWN_SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
|
|
84
|
+
const SHUTDOWN_EXIT_CODE = 0;
|
|
85
|
+
|
|
86
|
+
// Manager state
|
|
87
|
+
export type ManagerState =
|
|
88
|
+
| "idle" // Not started
|
|
89
|
+
| "initializing" // Running task list
|
|
90
|
+
| "running" // All processes running
|
|
91
|
+
| "stopping" // Stopping all processes
|
|
92
|
+
| "stopped"; // Fully stopped
|
|
93
|
+
|
|
94
|
+
export class Manager {
|
|
95
|
+
private config: ManagerConfig;
|
|
96
|
+
private logger: Logger;
|
|
97
|
+
private envManager: EnvManager;
|
|
98
|
+
private envFileKeyByProcess: Map<string, string> = new Map();
|
|
99
|
+
|
|
100
|
+
private _state: ManagerState = "idle";
|
|
101
|
+
private taskList: TaskList | null = null;
|
|
102
|
+
private cronProcesses: Map<string, CronProcess> = new Map();
|
|
103
|
+
private restartingProcesses: Map<string, RestartingProcess> = new Map();
|
|
104
|
+
private logDir: string;
|
|
105
|
+
|
|
106
|
+
// Env reload tracking
|
|
107
|
+
private processEnvReloadConfig: Map<string, EnvReloadDelay> = new Map();
|
|
108
|
+
private envReloadTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
|
109
|
+
private envChangeUnsubscribe: (() => void) | null = null;
|
|
110
|
+
|
|
111
|
+
// Shutdown handling
|
|
112
|
+
private signalHandlers: Map<NodeJS.Signals, () => void> = new Map();
|
|
113
|
+
private shutdownPromise: Promise<void> | null = null;
|
|
114
|
+
private isShuttingDown: boolean = false;
|
|
115
|
+
|
|
116
|
+
constructor(config: ManagerConfig, logger: Logger) {
|
|
117
|
+
this.config = config;
|
|
118
|
+
this.logger = logger;
|
|
119
|
+
this.logDir = config.logDir ?? join(getCwd(), "logs");
|
|
120
|
+
this.ensureLogDirs();
|
|
121
|
+
// Initialize EnvManager with watching enabled
|
|
122
|
+
this.envManager = new EnvManager({
|
|
123
|
+
cwd: config.cwd,
|
|
124
|
+
files: config.envFiles,
|
|
125
|
+
watch: true,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Register env change handler
|
|
129
|
+
this.envChangeUnsubscribe = this.envManager.onChange((changedKeys) => {
|
|
130
|
+
this.handleEnvChange(changedKeys);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Automatically register shutdown handlers
|
|
134
|
+
this.registerShutdownHandlers();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Merge global env with process-specific env and apply cwd inheritance
|
|
139
|
+
* Merge order: .env (global), config.env (global), .env.<processName>, processEnvFile, definition.env
|
|
140
|
+
*/
|
|
141
|
+
private applyDefaults(
|
|
142
|
+
processName: string,
|
|
143
|
+
definition: ProcessDefinition,
|
|
144
|
+
envFile?: string,
|
|
145
|
+
): ProcessDefinition {
|
|
146
|
+
// Start with env from .env file (global)
|
|
147
|
+
const envFromFiles = this.envManager.getEnvVars(processName);
|
|
148
|
+
|
|
149
|
+
// Build the env file map for this specific process if envFile is provided
|
|
150
|
+
let envFromCustomFile: Record<string, string> = {};
|
|
151
|
+
if (envFile) {
|
|
152
|
+
let key = this.envFileKeyByProcess.get(processName);
|
|
153
|
+
if (!key) {
|
|
154
|
+
key = `custom:${processName}`;
|
|
155
|
+
this.envFileKeyByProcess.set(processName, key);
|
|
156
|
+
this.envManager.registerFile(key, envFile);
|
|
157
|
+
}
|
|
158
|
+
envFromCustomFile = this.envManager.getEnvForKey(key);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
...definition,
|
|
163
|
+
cwd: definition.cwd ?? this.config.cwd,
|
|
164
|
+
env: {
|
|
165
|
+
...envFromFiles, // .env (global) + .env.<processName>
|
|
166
|
+
...this.config.env, // Global env from config
|
|
167
|
+
...envFromCustomFile, // Custom env file if specified
|
|
168
|
+
...definition.env, // Process-specific env overrides
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private processLogFile(name: string): string {
|
|
174
|
+
return join(this.logDir, "process", `${name}.log`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private taskLogFile(name: string): string {
|
|
178
|
+
return join(this.logDir, "tasks", `${name}.log`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private cronLogFile(name: string): string {
|
|
182
|
+
return join(this.logDir, "cron", `${name}.log`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private ensureLogDirs(): void {
|
|
186
|
+
mkdirSync(this.logDir, { recursive: true });
|
|
187
|
+
mkdirSync(join(this.logDir, "process"), { recursive: true });
|
|
188
|
+
mkdirSync(join(this.logDir, "tasks"), { recursive: true });
|
|
189
|
+
mkdirSync(join(this.logDir, "cron"), { recursive: true });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Handle env file changes
|
|
194
|
+
*/
|
|
195
|
+
private handleEnvChange(changedKeys: string[]): void {
|
|
196
|
+
if (this._state !== "running") {
|
|
197
|
+
return; // Only handle changes when manager is running
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
this.logger.info(`Env files changed for keys: ${changedKeys.join(", ")}`);
|
|
201
|
+
|
|
202
|
+
// Check which processes are affected
|
|
203
|
+
const affectedProcesses = new Set<string>();
|
|
204
|
+
|
|
205
|
+
for (const key of changedKeys) {
|
|
206
|
+
// Global key affects all processes
|
|
207
|
+
if (key === "global") {
|
|
208
|
+
for (const processName of this.restartingProcesses.keys()) {
|
|
209
|
+
affectedProcesses.add(processName);
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
// Specific key affects matching process
|
|
213
|
+
if (this.restartingProcesses.has(key)) {
|
|
214
|
+
affectedProcesses.add(key);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const [processName, customKey] of this.envFileKeyByProcess.entries()) {
|
|
218
|
+
if (customKey === key && this.restartingProcesses.has(processName)) {
|
|
219
|
+
affectedProcesses.add(processName);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Schedule restarts for affected processes
|
|
226
|
+
for (const processName of affectedProcesses) {
|
|
227
|
+
const reloadDelay = this.processEnvReloadConfig.get(processName);
|
|
228
|
+
|
|
229
|
+
// Skip if reload is disabled for this process
|
|
230
|
+
if (reloadDelay === false) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.scheduleProcessReload(processName, reloadDelay);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Schedule a process reload with debouncing
|
|
240
|
+
*/
|
|
241
|
+
private scheduleProcessReload(processName: string, reloadDelay?: EnvReloadDelay): void {
|
|
242
|
+
// Clear existing timer if any
|
|
243
|
+
const existingTimer = this.envReloadTimers.get(processName);
|
|
244
|
+
if (existingTimer) {
|
|
245
|
+
clearTimeout(existingTimer);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Determine delay in ms
|
|
249
|
+
let delayMs: number;
|
|
250
|
+
if (reloadDelay === false) {
|
|
251
|
+
return; // Should not happen, but guard anyway
|
|
252
|
+
} else if (reloadDelay === true || reloadDelay === "immediately") {
|
|
253
|
+
delayMs = 0;
|
|
254
|
+
} else if (typeof reloadDelay === "number") {
|
|
255
|
+
delayMs = reloadDelay;
|
|
256
|
+
} else {
|
|
257
|
+
delayMs = 5000; // Default 5 seconds
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
this.logger.info(`Scheduling reload for process "${processName}" in ${delayMs}ms`);
|
|
261
|
+
|
|
262
|
+
const timer = setTimeout(async () => {
|
|
263
|
+
await this.reloadProcessEnv(processName);
|
|
264
|
+
this.envReloadTimers.delete(processName);
|
|
265
|
+
}, delayMs);
|
|
266
|
+
|
|
267
|
+
this.envReloadTimers.set(processName, timer);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Reload a process with updated env vars
|
|
272
|
+
*/
|
|
273
|
+
private async reloadProcessEnv(processName: string): Promise<void> {
|
|
274
|
+
const proc = this.restartingProcesses.get(processName);
|
|
275
|
+
if (!proc) {
|
|
276
|
+
this.logger.warn(`Process "${processName}" not found for env reload`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.logger.info(`Reloading process "${processName}" due to env change`);
|
|
281
|
+
|
|
282
|
+
// Get the original config for this process
|
|
283
|
+
const processConfig = this.config.processes?.find((p) => p.name === processName);
|
|
284
|
+
if (!processConfig) {
|
|
285
|
+
this.logger.warn(`Process config for "${processName}" not found`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Rebuild definition with updated env
|
|
290
|
+
const updatedDefinition = this.applyDefaults(
|
|
291
|
+
processName,
|
|
292
|
+
processConfig.definition,
|
|
293
|
+
processConfig.envFile,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// Reload the process (graceful restart)
|
|
297
|
+
await proc.reload(updatedDefinition, true);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
get state(): ManagerState {
|
|
301
|
+
return this._state;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Get all cron processes (read-only access)
|
|
306
|
+
*/
|
|
307
|
+
getCronProcesses(): ReadonlyMap<string, CronProcess> {
|
|
308
|
+
return this.cronProcesses;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Get a specific cron process by name
|
|
313
|
+
*/
|
|
314
|
+
getCronProcess(name: string): CronProcess | undefined {
|
|
315
|
+
return this.cronProcesses.get(name);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Get all restarting processes (read-only access)
|
|
320
|
+
*/
|
|
321
|
+
getRestartingProcesses(): ReadonlyMap<string, RestartingProcess> {
|
|
322
|
+
return this.restartingProcesses;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Get a specific restarting process by name
|
|
327
|
+
*/
|
|
328
|
+
getRestartingProcess(name: string): RestartingProcess | undefined {
|
|
329
|
+
return this.restartingProcesses.get(name);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Get the task list (read-only access)
|
|
334
|
+
*/
|
|
335
|
+
getTaskList(): TaskList | null {
|
|
336
|
+
return this.taskList;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Get a restarting process by name or index
|
|
341
|
+
*/
|
|
342
|
+
getProcessByTarget(target: string | number): RestartingProcess | undefined {
|
|
343
|
+
if (typeof target === "string") {
|
|
344
|
+
return this.restartingProcesses.get(target);
|
|
345
|
+
}
|
|
346
|
+
const entries = Array.from(this.restartingProcesses.values());
|
|
347
|
+
return entries[target];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Get a cron process by name or index
|
|
352
|
+
*/
|
|
353
|
+
getCronByTarget(target: string | number): CronProcess | undefined {
|
|
354
|
+
if (typeof target === "string") {
|
|
355
|
+
return this.cronProcesses.get(target);
|
|
356
|
+
}
|
|
357
|
+
const entries = Array.from(this.cronProcesses.values());
|
|
358
|
+
return entries[target];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Get a task by id or index
|
|
363
|
+
*/
|
|
364
|
+
getTaskByTarget(
|
|
365
|
+
target: string | number,
|
|
366
|
+
): { id: string; state: string; processNames: string[] } | undefined {
|
|
367
|
+
if (!this.taskList) return undefined;
|
|
368
|
+
const tasks = this.taskList.tasks;
|
|
369
|
+
|
|
370
|
+
if (typeof target === "string") {
|
|
371
|
+
const task = tasks.find((t) => t.id === target);
|
|
372
|
+
if (!task) return undefined;
|
|
373
|
+
return {
|
|
374
|
+
id: task.id,
|
|
375
|
+
state: task.state,
|
|
376
|
+
processNames: task.processes.map((p) => p.name),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const task = tasks[target];
|
|
381
|
+
if (!task) return undefined;
|
|
382
|
+
return {
|
|
383
|
+
id: task.id,
|
|
384
|
+
state: task.state,
|
|
385
|
+
processNames: task.processes.map((p) => p.name),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Start a restarting process by target
|
|
391
|
+
*/
|
|
392
|
+
startProcessByTarget(target: string | number): RestartingProcess {
|
|
393
|
+
const proc = this.getProcessByTarget(target);
|
|
394
|
+
if (!proc) {
|
|
395
|
+
throw new Error(`Process not found: ${target}`);
|
|
396
|
+
}
|
|
397
|
+
proc.start();
|
|
398
|
+
return proc;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Stop a restarting process by target
|
|
403
|
+
*/
|
|
404
|
+
async stopProcessByTarget(target: string | number, timeout?: number): Promise<RestartingProcess> {
|
|
405
|
+
const proc = this.getProcessByTarget(target);
|
|
406
|
+
if (!proc) {
|
|
407
|
+
throw new Error(`Process not found: ${target}`);
|
|
408
|
+
}
|
|
409
|
+
await proc.stop(timeout);
|
|
410
|
+
return proc;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Restart a restarting process by target
|
|
415
|
+
*/
|
|
416
|
+
async restartProcessByTarget(
|
|
417
|
+
target: string | number,
|
|
418
|
+
force: boolean = false,
|
|
419
|
+
): Promise<RestartingProcess> {
|
|
420
|
+
const proc = this.getProcessByTarget(target);
|
|
421
|
+
if (!proc) {
|
|
422
|
+
throw new Error(`Process not found: ${target}`);
|
|
423
|
+
}
|
|
424
|
+
await proc.restart(force);
|
|
425
|
+
return proc;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Reload a restarting process with new definition
|
|
430
|
+
*/
|
|
431
|
+
async reloadProcessByTarget(
|
|
432
|
+
target: string | number,
|
|
433
|
+
newDefinition: ProcessDefinition,
|
|
434
|
+
options?: {
|
|
435
|
+
restartImmediately?: boolean;
|
|
436
|
+
updateOptions?: Partial<RestartingProcessOptions>;
|
|
437
|
+
},
|
|
438
|
+
): Promise<RestartingProcess> {
|
|
439
|
+
const proc = this.getProcessByTarget(target);
|
|
440
|
+
if (!proc) {
|
|
441
|
+
throw new Error(`Process not found: ${target}`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Apply global defaults to new definition
|
|
445
|
+
const definitionWithDefaults = this.applyDefaults(proc.name, newDefinition);
|
|
446
|
+
|
|
447
|
+
// Update options if provided
|
|
448
|
+
if (options?.updateOptions) {
|
|
449
|
+
proc.updateOptions(options.updateOptions);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Reload with new definition
|
|
453
|
+
await proc.reload(definitionWithDefaults, options?.restartImmediately ?? true);
|
|
454
|
+
this.logger.info(`Reloaded process: ${proc.name}`);
|
|
455
|
+
return proc;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Remove a restarting process by target
|
|
460
|
+
*/
|
|
461
|
+
async removeProcessByTarget(target: string | number, timeout?: number): Promise<void> {
|
|
462
|
+
const proc = this.getProcessByTarget(target);
|
|
463
|
+
if (!proc) {
|
|
464
|
+
throw new Error(`Process not found: ${target}`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Stop the process first
|
|
468
|
+
await proc.stop(timeout);
|
|
469
|
+
|
|
470
|
+
// Remove from the map
|
|
471
|
+
this.restartingProcesses.delete(proc.name);
|
|
472
|
+
this.logger.info(`Removed process: ${proc.name}`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Add a task to the task list
|
|
477
|
+
* Creates the task list if it doesn't exist and starts it
|
|
478
|
+
*/
|
|
479
|
+
addTask(
|
|
480
|
+
name: string,
|
|
481
|
+
definition: ProcessDefinition,
|
|
482
|
+
): { id: string; state: string; processNames: string[] } {
|
|
483
|
+
if (!this.taskList) {
|
|
484
|
+
const taskListLogger = this.logger.child("tasks", {
|
|
485
|
+
logFile: this.taskLogFile("tasks"),
|
|
486
|
+
});
|
|
487
|
+
this.taskList = new TaskList("runtime", taskListLogger, undefined, (processName) => {
|
|
488
|
+
return this.taskLogFile(processName);
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const namedProcess: NamedProcessDefinition = {
|
|
493
|
+
name,
|
|
494
|
+
process: this.applyDefaults(name, definition),
|
|
495
|
+
};
|
|
496
|
+
const id = this.taskList.addTask(namedProcess);
|
|
497
|
+
|
|
498
|
+
// Start the task list if it's idle so the task runs immediately
|
|
499
|
+
if (this.taskList.state === "idle") {
|
|
500
|
+
this.taskList.start();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
id,
|
|
505
|
+
state: "pending",
|
|
506
|
+
processNames: [name],
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
removeTaskByTarget(target: string | number): {
|
|
511
|
+
id: string;
|
|
512
|
+
state: string;
|
|
513
|
+
processNames: string[];
|
|
514
|
+
} {
|
|
515
|
+
if (!this.taskList) {
|
|
516
|
+
throw new Error(`Task list not initialized`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const removed = this.taskList.removeTaskByTarget(target);
|
|
520
|
+
return {
|
|
521
|
+
id: removed.id,
|
|
522
|
+
state: removed.state,
|
|
523
|
+
processNames: removed.processes.map((p) => p.name),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Add a restarting process at runtime
|
|
529
|
+
*/
|
|
530
|
+
addProcess(
|
|
531
|
+
name: string,
|
|
532
|
+
definition: ProcessDefinition,
|
|
533
|
+
options?: RestartingProcessOptions,
|
|
534
|
+
envReloadDelay?: EnvReloadDelay,
|
|
535
|
+
): RestartingProcess {
|
|
536
|
+
if (this.restartingProcesses.has(name)) {
|
|
537
|
+
throw new Error(`Process "${name}" already exists`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const processLogger = this.logger.child(name, { logFile: this.processLogFile(name) });
|
|
541
|
+
const restartingProcess = new RestartingProcess(
|
|
542
|
+
name,
|
|
543
|
+
this.applyDefaults(name, definition),
|
|
544
|
+
options ?? DEFAULT_RESTART_OPTIONS,
|
|
545
|
+
processLogger,
|
|
546
|
+
);
|
|
547
|
+
this.restartingProcesses.set(name, restartingProcess);
|
|
548
|
+
restartingProcess.start();
|
|
549
|
+
|
|
550
|
+
// Track env reload config for this process
|
|
551
|
+
this.processEnvReloadConfig.set(name, envReloadDelay ?? 5000);
|
|
552
|
+
|
|
553
|
+
this.logger.info(`Added and started restarting process: ${name}`);
|
|
554
|
+
return restartingProcess;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Trigger a cron process by target
|
|
559
|
+
*/
|
|
560
|
+
async triggerCronByTarget(target: string | number): Promise<CronProcess> {
|
|
561
|
+
const cron = this.getCronByTarget(target);
|
|
562
|
+
if (!cron) {
|
|
563
|
+
throw new Error(`Cron not found: ${target}`);
|
|
564
|
+
}
|
|
565
|
+
await cron.trigger();
|
|
566
|
+
return cron;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Start a cron process by target
|
|
571
|
+
*/
|
|
572
|
+
startCronByTarget(target: string | number): CronProcess {
|
|
573
|
+
const cron = this.getCronByTarget(target);
|
|
574
|
+
if (!cron) {
|
|
575
|
+
throw new Error(`Cron not found: ${target}`);
|
|
576
|
+
}
|
|
577
|
+
cron.start();
|
|
578
|
+
return cron;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Stop a cron process by target
|
|
583
|
+
*/
|
|
584
|
+
async stopCronByTarget(target: string | number, timeout?: number): Promise<CronProcess> {
|
|
585
|
+
const cron = this.getCronByTarget(target);
|
|
586
|
+
if (!cron) {
|
|
587
|
+
throw new Error(`Cron not found: ${target}`);
|
|
588
|
+
}
|
|
589
|
+
await cron.stop(timeout);
|
|
590
|
+
return cron;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Start the manager:
|
|
595
|
+
* 1. Run task list (if configured) and wait for completion
|
|
596
|
+
* 2. Create and start all cron/restarting processes
|
|
597
|
+
*/
|
|
598
|
+
async start(): Promise<void> {
|
|
599
|
+
if (this._state !== "idle" && this._state !== "stopped") {
|
|
600
|
+
throw new Error(`Manager is already ${this._state}`);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
this.logger.info(`Starting manager`);
|
|
604
|
+
|
|
605
|
+
// Phase 1: Run initialization tasks
|
|
606
|
+
if (this.config.tasks && this.config.tasks.length > 0) {
|
|
607
|
+
this._state = "initializing";
|
|
608
|
+
this.logger.info(`Running initialization tasks`);
|
|
609
|
+
|
|
610
|
+
const taskListLogger = this.logger.child("tasks");
|
|
611
|
+
// Apply defaults to all tasks
|
|
612
|
+
const tasksWithDefaults = this.config.tasks.map((task) => ({
|
|
613
|
+
name: task.name,
|
|
614
|
+
process: this.applyDefaults(task.name, task.definition, task.envFile),
|
|
615
|
+
}));
|
|
616
|
+
this.taskList = new TaskList("init", taskListLogger, tasksWithDefaults, (processName) => {
|
|
617
|
+
return this.taskLogFile(processName);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
this.taskList.start();
|
|
621
|
+
await this.taskList.waitUntilIdle();
|
|
622
|
+
|
|
623
|
+
// Check if any tasks failed
|
|
624
|
+
const failedTasks = this.taskList.tasks.filter((t) => t.state === "failed");
|
|
625
|
+
if (failedTasks.length > 0) {
|
|
626
|
+
this._state = "stopped";
|
|
627
|
+
const failedNames = failedTasks.map((t) => t.id).join(", ");
|
|
628
|
+
throw new Error(`Initialization failed: tasks [${failedNames}] failed`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
this.logger.info(`Initialization tasks completed`);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Phase 2: Create and start cron processes
|
|
635
|
+
if (this.config.crons) {
|
|
636
|
+
for (const entry of this.config.crons) {
|
|
637
|
+
const processLogger = this.logger.child(entry.name, {
|
|
638
|
+
logFile: this.cronLogFile(entry.name),
|
|
639
|
+
});
|
|
640
|
+
const cronProcess = new CronProcess(
|
|
641
|
+
entry.name,
|
|
642
|
+
this.applyDefaults(entry.name, entry.definition, entry.envFile),
|
|
643
|
+
entry.options,
|
|
644
|
+
processLogger,
|
|
645
|
+
);
|
|
646
|
+
this.cronProcesses.set(entry.name, cronProcess);
|
|
647
|
+
cronProcess.start();
|
|
648
|
+
this.logger.info(`Started cron process: ${entry.name}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Phase 3: Create and start restarting processes
|
|
653
|
+
if (this.config.processes) {
|
|
654
|
+
for (const entry of this.config.processes) {
|
|
655
|
+
const processLogger = this.logger.child(entry.name, {
|
|
656
|
+
logFile: this.processLogFile(entry.name),
|
|
657
|
+
});
|
|
658
|
+
const restartingProcess = new RestartingProcess(
|
|
659
|
+
entry.name,
|
|
660
|
+
this.applyDefaults(entry.name, entry.definition, entry.envFile),
|
|
661
|
+
entry.options ?? DEFAULT_RESTART_OPTIONS,
|
|
662
|
+
processLogger,
|
|
663
|
+
);
|
|
664
|
+
this.restartingProcesses.set(entry.name, restartingProcess);
|
|
665
|
+
restartingProcess.start();
|
|
666
|
+
|
|
667
|
+
// Track env reload config for this process
|
|
668
|
+
this.processEnvReloadConfig.set(
|
|
669
|
+
entry.name,
|
|
670
|
+
entry.envReloadDelay ?? 5000, // Default to 5000ms
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
this.logger.info(`Started restarting process: ${entry.name}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
this._state = "running";
|
|
678
|
+
this.logger.info(
|
|
679
|
+
`Manager started with ${this.cronProcesses.size} cron process(es) and ${this.restartingProcesses.size} restarting process(es)`,
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Stop all managed processes
|
|
685
|
+
*/
|
|
686
|
+
async stop(timeout?: number): Promise<void> {
|
|
687
|
+
if (this._state === "idle" || this._state === "stopped") {
|
|
688
|
+
this._state = "stopped";
|
|
689
|
+
this.unregisterShutdownHandlers();
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
this._state = "stopping";
|
|
694
|
+
this.logger.info(`Stopping manager`);
|
|
695
|
+
|
|
696
|
+
// Clear all env reload timers
|
|
697
|
+
for (const timer of this.envReloadTimers.values()) {
|
|
698
|
+
clearTimeout(timer);
|
|
699
|
+
}
|
|
700
|
+
this.envReloadTimers.clear();
|
|
701
|
+
|
|
702
|
+
// Unsubscribe from env changes
|
|
703
|
+
if (this.envChangeUnsubscribe) {
|
|
704
|
+
this.envChangeUnsubscribe();
|
|
705
|
+
this.envChangeUnsubscribe = null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Dispose env manager
|
|
709
|
+
this.envManager.dispose();
|
|
710
|
+
|
|
711
|
+
// Stop task list if still running
|
|
712
|
+
if (this.taskList) {
|
|
713
|
+
await this.taskList.stop(timeout);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Stop all cron processes in parallel
|
|
717
|
+
const cronStopPromises = Array.from(this.cronProcesses.values()).map((p) => p.stop(timeout));
|
|
718
|
+
|
|
719
|
+
// Stop all restarting processes in parallel
|
|
720
|
+
const restartingStopPromises = Array.from(this.restartingProcesses.values()).map((p) =>
|
|
721
|
+
p.stop(timeout),
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
await Promise.all([...cronStopPromises, ...restartingStopPromises]);
|
|
725
|
+
|
|
726
|
+
this._state = "stopped";
|
|
727
|
+
this.logger.info(`Manager stopped`);
|
|
728
|
+
|
|
729
|
+
this.unregisterShutdownHandlers();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Register signal handlers for graceful shutdown
|
|
734
|
+
* Called automatically by constructor
|
|
735
|
+
*/
|
|
736
|
+
private registerShutdownHandlers(): void {
|
|
737
|
+
if (this.signalHandlers.size > 0) {
|
|
738
|
+
this.logger.warn(`Shutdown handlers already registered`);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
for (const signal of SHUTDOWN_SIGNALS) {
|
|
743
|
+
const handler = () => this.handleSignal(signal);
|
|
744
|
+
this.signalHandlers.set(signal, handler);
|
|
745
|
+
process.on(signal, handler);
|
|
746
|
+
this.logger.debug(`Registered handler for ${signal}`);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
this.logger.info(`Shutdown handlers registered for signals: ${SHUTDOWN_SIGNALS.join(", ")}`);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Unregister signal handlers for graceful shutdown
|
|
754
|
+
*/
|
|
755
|
+
private unregisterShutdownHandlers(): void {
|
|
756
|
+
if (this.signalHandlers.size === 0) {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
for (const [signal, handler] of this.signalHandlers.entries()) {
|
|
761
|
+
process.off(signal, handler);
|
|
762
|
+
this.logger.debug(`Unregistered handler for ${signal}`);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
this.signalHandlers.clear();
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Wait for shutdown to complete (useful for keeping process alive)
|
|
770
|
+
* Resolves when the manager has fully stopped
|
|
771
|
+
*/
|
|
772
|
+
async waitForShutdown(): Promise<void> {
|
|
773
|
+
// If already stopped, return immediately
|
|
774
|
+
if (this._state === "stopped") {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// If shutdown is in progress, wait for it
|
|
779
|
+
if (this.shutdownPromise) {
|
|
780
|
+
await this.shutdownPromise;
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Wait for state to become stopped
|
|
785
|
+
return new Promise((resolve) => {
|
|
786
|
+
const checkInterval = setInterval(() => {
|
|
787
|
+
if (this._state === "stopped") {
|
|
788
|
+
clearInterval(checkInterval);
|
|
789
|
+
resolve();
|
|
790
|
+
}
|
|
791
|
+
}, 100);
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Trigger graceful shutdown programmatically
|
|
797
|
+
*/
|
|
798
|
+
async shutdown(): Promise<void> {
|
|
799
|
+
if (this.isShuttingDown) {
|
|
800
|
+
this.logger.warn(`Shutdown already in progress`);
|
|
801
|
+
if (this.shutdownPromise) {
|
|
802
|
+
await this.shutdownPromise;
|
|
803
|
+
}
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
this.isShuttingDown = true;
|
|
808
|
+
this.logger.info(`Initiating graceful shutdown (timeout: ${SHUTDOWN_TIMEOUT_MS}ms)`);
|
|
809
|
+
|
|
810
|
+
this.shutdownPromise = this.performShutdown();
|
|
811
|
+
await this.shutdownPromise;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private handleSignal(signal: NodeJS.Signals): void {
|
|
815
|
+
this.logger.info(`Received ${signal}, initiating graceful shutdown...`);
|
|
816
|
+
|
|
817
|
+
// Prevent handling multiple signals
|
|
818
|
+
if (this.isShuttingDown) {
|
|
819
|
+
this.logger.warn(`Shutdown already in progress, ignoring ${signal}`);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
this.isShuttingDown = true;
|
|
824
|
+
this.shutdownPromise = this.performShutdown();
|
|
825
|
+
|
|
826
|
+
this.shutdownPromise
|
|
827
|
+
.then(() => {
|
|
828
|
+
this.logger.info(`Exiting with code ${SHUTDOWN_EXIT_CODE}`);
|
|
829
|
+
process.exit(SHUTDOWN_EXIT_CODE);
|
|
830
|
+
})
|
|
831
|
+
.catch((err) => {
|
|
832
|
+
this.logger.error(`Shutdown error:`, err);
|
|
833
|
+
process.exit(1);
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
private async performShutdown(): Promise<void> {
|
|
838
|
+
// Create a timeout promise
|
|
839
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
840
|
+
setTimeout(() => {
|
|
841
|
+
reject(new Error(`Shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms`));
|
|
842
|
+
}, SHUTDOWN_TIMEOUT_MS);
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
try {
|
|
846
|
+
// Race between graceful stop and timeout
|
|
847
|
+
await Promise.race([this.stop(SHUTDOWN_TIMEOUT_MS), timeoutPromise]);
|
|
848
|
+
this.logger.info(`Graceful shutdown completed`);
|
|
849
|
+
} catch (err) {
|
|
850
|
+
this.logger.error(`Shutdown error:`, err);
|
|
851
|
+
// Force stop on timeout
|
|
852
|
+
this._state = "stopped";
|
|
853
|
+
throw err;
|
|
854
|
+
} finally {
|
|
855
|
+
this.isShuttingDown = false;
|
|
856
|
+
this.shutdownPromise = null;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|