@nodedaemon/core
Version:
Production-ready Node.js process manager with zero external dependencies
578 lines • 24.2 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ProcessOrchestrator = void 0;
const child_process_1 = require("child_process");
const cluster_1 = __importDefault(require("cluster"));
const os_1 = require("os");
const events_1 = require("events");
const helpers_1 = require("../utils/helpers");
const env_1 = require("../utils/env");
const constants_1 = require("../utils/constants");
class ProcessOrchestrator extends events_1.EventEmitter {
processes = new Map();
childProcesses = new Map();
restartTimers = new Map();
logger;
isShuttingDown = false;
constructor(logger) {
super();
this.logger = logger;
this.setupClusterEventHandlers();
this.setupProcessEventHandlers();
}
setupClusterEventHandlers() {
if (cluster_1.default.isPrimary) {
cluster_1.default.on('exit', (worker, code, signal) => {
this.handleWorkerExit(worker, code, signal);
});
cluster_1.default.on('disconnect', (worker) => {
this.logger.warn(`Cluster worker ${worker.id} disconnected`, { workerId: worker.id });
});
}
}
setupProcessEventHandlers() {
process.on('SIGTERM', () => this.gracefulShutdown());
process.on('SIGINT', () => this.gracefulShutdown());
process.on('SIGHUP', () => this.reloadAllProcesses());
}
async startProcess(config) {
if (this.isShuttingDown) {
throw new Error('Cannot start process during shutdown');
}
(0, helpers_1.validateProcessConfig)(config);
const processId = (0, helpers_1.generateId)();
const processName = config.name || (0, helpers_1.sanitizeProcessName)(config.script);
const instanceCount = this.resolveInstanceCount(config.instances);
const strategy = this.determineStrategy(config);
// Load environment from .env files
let envConfig = {};
const envFilePath = (0, env_1.findEnvFile)(config.script, config.envFile);
if (envFilePath) {
envConfig = (0, env_1.loadEnvFile)(envFilePath);
this.logger.info(`Loaded environment from ${envFilePath}`, { processName });
}
// Merge with provided env vars (provided vars take precedence)
const finalEnv = (0, env_1.mergeEnvConfigs)(envConfig, config.env || {});
const processInfo = {
id: processId,
name: processName,
script: config.script,
status: 'starting',
restarts: 0,
instances: [],
config: { ...constants_1.DEFAULT_CONFIG, ...config, env: finalEnv },
createdAt: Date.now(),
updatedAt: Date.now()
};
this.processes.set(processId, processInfo);
this.logger.info(`Starting process: ${processName}`, { processId, strategy, instances: instanceCount });
try {
if (strategy === 'cluster' && instanceCount > 1) {
await this.startClusterProcess(processInfo, instanceCount);
}
else {
await this.startSingleProcess(processInfo, strategy);
}
processInfo.status = 'running';
processInfo.updatedAt = Date.now();
this.emit('processStarted', processInfo);
return processId;
}
catch (error) {
processInfo.status = 'errored';
processInfo.updatedAt = Date.now();
this.logger.error(`Failed to start process ${processName}`, { error: error.message, processId });
throw error;
}
}
resolveInstanceCount(instances) {
if (instances === 'max') {
return (0, os_1.cpus)().length;
}
return (typeof instances === 'number') ? instances : constants_1.DEFAULT_CONFIG.instances;
}
determineStrategy(config) {
if (config.instances && (config.instances === 'max' || config.instances > 1)) {
return 'cluster';
}
if (config.script.endsWith('.js') || config.script.endsWith('.mjs')) {
return 'fork';
}
return 'spawn';
}
async startClusterProcess(processInfo, instanceCount) {
if (!cluster_1.default.isPrimary) {
throw new Error('Cluster processes can only be started from primary process');
}
const promises = [];
for (let i = 0; i < instanceCount; i++) {
promises.push(this.startClusterInstance(processInfo, i));
}
await Promise.all(promises);
}
startClusterInstance(processInfo, instanceIndex) {
return new Promise((resolve, reject) => {
const instanceId = (0, helpers_1.generateId)();
const instance = {
id: instanceId,
status: 'starting',
restarts: 0
};
processInfo.instances.push(instance);
cluster_1.default.setupPrimary({
exec: processInfo.script,
args: processInfo.config.args || [],
cwd: processInfo.config.cwd
});
const worker = cluster_1.default.fork({ ...process.env, ...processInfo.config.env });
this.childProcesses.set(instanceId, worker);
worker.on('online', () => {
instance.pid = worker.process.pid;
instance.status = 'running';
instance.uptime = Date.now();
this.logger.info(`Cluster instance ${instanceIndex} started`, {
processId: processInfo.id,
instanceId,
pid: worker.process.pid
});
resolve();
});
worker.on('exit', (code, signal) => {
this.handleInstanceExit(processInfo, instance, code, signal);
});
worker.on('error', (error) => {
this.logger.error(`Cluster instance error`, {
processId: processInfo.id,
instanceId,
error: error.message
});
reject(error);
});
setTimeout(() => {
if (instance.status === 'starting') {
reject(new Error(`Cluster instance ${instanceIndex} failed to start within timeout`));
}
}, 30000);
});
}
async startSingleProcess(processInfo, strategy) {
const instanceId = (0, helpers_1.generateId)();
const instance = {
id: instanceId,
status: 'starting',
restarts: 0
};
processInfo.instances.push(instance);
let childProcess;
if (strategy === 'fork') {
childProcess = (0, child_process_1.fork)(processInfo.script, processInfo.config.args || [], {
cwd: processInfo.config.cwd,
env: { ...process.env, ...processInfo.config.env },
silent: false
});
}
else {
const interpreter = processInfo.config.interpreter || 'node';
childProcess = (0, child_process_1.spawn)(interpreter, [processInfo.script, ...(processInfo.config.args || [])], {
cwd: processInfo.config.cwd,
env: { ...process.env, ...processInfo.config.env },
stdio: ['inherit', 'inherit', 'inherit']
});
}
this.childProcesses.set(instanceId, childProcess);
return new Promise((resolve, reject) => {
childProcess.on('spawn', () => {
instance.pid = childProcess.pid;
instance.status = 'running';
instance.uptime = Date.now();
this.logger.info(`Process started`, {
processId: processInfo.id,
instanceId,
pid: childProcess.pid,
strategy
});
resolve();
});
childProcess.on('exit', (code, signal) => {
this.handleInstanceExit(processInfo, instance, code, signal);
});
childProcess.on('error', (error) => {
this.logger.error(`Process error`, {
processId: processInfo.id,
instanceId,
error: error.message
});
reject(error);
});
setTimeout(() => {
if (instance.status === 'starting') {
reject(new Error('Process failed to start within timeout'));
}
}, 30000);
});
}
handleWorkerExit(worker, code, signal) {
const instanceId = this.findInstanceByPid(worker.process.pid);
if (instanceId) {
const processInfo = this.findProcessByInstanceId(instanceId);
const instance = processInfo?.instances.find(i => i.id === instanceId);
if (processInfo && instance) {
this.handleInstanceExit(processInfo, instance, code, signal);
}
}
}
handleInstanceExit(processInfo, instance, code, signal) {
instance.status = code === 0 ? 'stopped' : 'crashed';
this.childProcesses.delete(instance.id);
// Calculate uptime
const uptime = instance.uptime ? Date.now() - instance.uptime : 0;
// Reset restart counter if process ran successfully for minimum uptime
if (uptime >= (processInfo.config.minUptime || constants_1.DEFAULT_CONFIG.minUptime)) {
instance.restarts = 0;
this.logger.info(`Process ran successfully for ${(0, helpers_1.formatUptime)(uptime)}, resetting restart counter`, {
processId: processInfo.id,
instanceId: instance.id
});
}
this.logger.info(`Process instance exited`, {
processId: processInfo.id,
instanceId: instance.id,
pid: instance.pid,
code,
signal,
uptime: (0, helpers_1.formatUptime)(uptime),
restarts: instance.restarts
});
if (processInfo.status !== 'stopping' && !this.isShuttingDown) {
if (instance.restarts < processInfo.config.maxRestarts) {
this.scheduleRestart(processInfo, instance);
}
else {
this.logger.error(`Process instance reached max restarts, stopping permanently`, {
processId: processInfo.id,
instanceId: instance.id,
maxRestarts: processInfo.config.maxRestarts,
totalRestarts: instance.restarts
});
instance.status = 'errored';
processInfo.status = 'errored';
// Emit event for max restarts reached
this.emit('maxRestartsReached', processInfo, instance);
}
}
this.updateProcessStatus(processInfo);
this.emit('instanceExit', processInfo, instance, code, signal);
}
scheduleRestart(processInfo, instance) {
const delay = (0, helpers_1.calculateExponentialBackoff)(instance.restarts, processInfo.config.restartDelay, processInfo.config.maxRestartDelay);
this.logger.warn(`Process crashed too quickly, scheduling restart in ${delay}ms`, {
processId: processInfo.id,
instanceId: instance.id,
attempt: instance.restarts + 1,
maxRestarts: processInfo.config.maxRestarts,
backoffDelay: `${delay}ms`
});
const timer = setTimeout(() => {
this.restartInstance(processInfo, instance);
}, delay);
this.restartTimers.set(instance.id, timer);
}
async restartInstance(processInfo, instance) {
try {
this.restartTimers.delete(instance.id);
instance.restarts++;
instance.lastRestart = Date.now();
instance.status = 'starting';
this.logger.info(`Restarting process instance`, {
processId: processInfo.id,
instanceId: instance.id,
attempt: instance.restarts
});
const strategy = this.determineStrategy(processInfo.config);
if (strategy === 'cluster') {
await this.startClusterInstance(processInfo, 0);
}
else {
await this.startSingleProcess(processInfo, strategy);
}
this.emit('instanceRestarted', processInfo, instance);
}
catch (error) {
this.logger.error(`Failed to restart process instance`, {
processId: processInfo.id,
instanceId: instance.id,
error: error.message
});
instance.status = 'errored';
this.updateProcessStatus(processInfo);
}
}
updateProcessStatus(processInfo) {
const runningInstances = processInfo.instances.filter(i => i.status === 'running');
const stoppedInstances = processInfo.instances.filter(i => i.status === 'stopped');
const erroredInstances = processInfo.instances.filter(i => i.status === 'errored' || i.status === 'crashed');
if (runningInstances.length > 0) {
processInfo.status = 'running';
}
else if (erroredInstances.length > 0) {
processInfo.status = 'errored';
}
else if (stoppedInstances.length === processInfo.instances.length) {
processInfo.status = 'stopped';
}
else {
processInfo.status = 'starting';
}
processInfo.updatedAt = Date.now();
processInfo.restarts = processInfo.instances.reduce((sum, instance) => sum + instance.restarts, 0);
}
async stopProcess(processId, force = false) {
const processInfo = this.processes.get(processId);
if (!processInfo) {
throw new Error(`Process not found: ${processId}`);
}
processInfo.status = 'stopping';
processInfo.updatedAt = Date.now();
this.logger.info(`Stopping process: ${processInfo.name}`, { processId, force });
const stopPromises = processInfo.instances.map(instance => this.stopInstance(processInfo, instance, force));
await Promise.all(stopPromises);
processInfo.status = 'stopped';
processInfo.updatedAt = Date.now();
this.emit('processStopped', processInfo);
}
async stopInstance(processInfo, instance, force) {
const childProcess = this.childProcesses.get(instance.id);
if (!childProcess)
return;
return new Promise((resolve) => {
const cleanup = () => {
this.childProcesses.delete(instance.id);
instance.status = 'stopped';
resolve();
};
if (force) {
childProcess.kill('SIGKILL');
setTimeout(cleanup, 1000);
return;
}
let killed = false;
const killTimer = setTimeout(() => {
if (!killed) {
killed = true;
childProcess.kill('SIGKILL');
setTimeout(cleanup, constants_1.FORCE_KILL_TIMEOUT);
}
}, constants_1.GRACEFUL_SHUTDOWN_TIMEOUT);
childProcess.once('exit', () => {
if (!killed) {
killed = true;
clearTimeout(killTimer);
cleanup();
}
});
childProcess.kill('SIGTERM');
});
}
async restartProcess(processId, graceful = false) {
const processInfo = this.processes.get(processId);
if (!processInfo) {
throw new Error(`Process not found: ${processId}`);
}
const strategy = this.determineStrategy(processInfo.config);
const instanceCount = this.resolveInstanceCount(processInfo.config.instances);
// For cluster mode with multiple instances, do graceful reload
if (graceful && strategy === 'cluster' && instanceCount > 1) {
await this.gracefulReload(processInfo);
}
else {
// Standard restart
await this.stopProcess(processId);
const updatedProcessInfo = this.processes.get(processId);
if (!updatedProcessInfo) {
throw new Error(`Process not found after stop: ${processId}`);
}
updatedProcessInfo.instances = [];
updatedProcessInfo.status = 'starting';
updatedProcessInfo.updatedAt = Date.now();
if (strategy === 'cluster' && instanceCount > 1) {
await this.startClusterProcess(updatedProcessInfo, instanceCount);
}
else {
await this.startSingleProcess(updatedProcessInfo, strategy);
}
updatedProcessInfo.status = 'running';
updatedProcessInfo.updatedAt = Date.now();
this.emit('processRestarted', updatedProcessInfo);
}
}
async gracefulReload(processInfo) {
this.logger.info(`Starting graceful reload for ${processInfo.name}`, { processId: processInfo.id });
const instanceCount = this.resolveInstanceCount(processInfo.config.instances);
const oldInstances = [...processInfo.instances];
const newInstances = [];
processInfo.status = 'reloading';
processInfo.updatedAt = Date.now();
// Start new instances first
for (let i = 0; i < instanceCount; i++) {
const instanceId = (0, helpers_1.generateId)();
const instance = {
id: instanceId,
status: 'starting',
restarts: 0
};
newInstances.push(instance);
processInfo.instances.push(instance);
try {
await this.startClusterInstance(processInfo, i);
// Wait a bit for the new instance to be ready
await new Promise(resolve => setTimeout(resolve, 2000));
}
catch (error) {
this.logger.error(`Failed to start new instance during graceful reload`, {
processId: processInfo.id,
instanceId,
error: error.message
});
}
}
// Now stop old instances one by one
for (const oldInstance of oldInstances) {
await this.stopInstance(processInfo, oldInstance, false);
// Remove old instance from the list
const index = processInfo.instances.findIndex(i => i.id === oldInstance.id);
if (index >= 0) {
processInfo.instances.splice(index, 1);
}
// Wait a bit between stopping instances
await new Promise(resolve => setTimeout(resolve, 1000));
}
processInfo.status = 'running';
processInfo.updatedAt = Date.now();
this.logger.info(`Graceful reload completed for ${processInfo.name}`, {
processId: processInfo.id,
oldInstances: oldInstances.length,
newInstances: newInstances.length
});
this.emit('processReloaded', processInfo);
}
deleteProcess(processId) {
const processInfo = this.processes.get(processId);
if (!processInfo) {
throw new Error(`Process not found: ${processId}`);
}
if (processInfo.status === 'running' || processInfo.status === 'starting') {
throw new Error('Cannot delete running process. Stop it first.');
}
this.processes.delete(processId);
this.logger.info(`Process deleted: ${processInfo.name}`, { processId });
this.emit('processDeleted', processInfo);
}
getProcesses() {
return Array.from(this.processes.values());
}
getProcess(processId) {
return this.processes.get(processId);
}
getProcessByName(name) {
return Array.from(this.processes.values()).find(p => p.name === name);
}
findInstanceByPid(pid) {
for (const processInfo of this.processes.values()) {
for (const instance of processInfo.instances) {
if (instance.pid === pid) {
return instance.id;
}
}
}
return undefined;
}
findProcessByInstanceId(instanceId) {
for (const processInfo of this.processes.values()) {
if (processInfo.instances.some(i => i.id === instanceId)) {
return processInfo;
}
}
return undefined;
}
async reloadAllProcesses() {
const runningProcesses = Array.from(this.processes.values())
.filter(p => p.status === 'running');
this.logger.info(`Reloading ${runningProcesses.length} processes`);
const reloadPromises = runningProcesses.map(async (processInfo) => {
try {
await this.restartProcess(processInfo.id);
}
catch (error) {
this.logger.error(`Failed to reload process ${processInfo.name}`, {
processId: processInfo.id,
error: error.message
});
}
});
await Promise.all(reloadPromises);
}
async gracefulShutdown() {
if (this.isShuttingDown)
return;
this.isShuttingDown = true;
this.logger.info('Starting graceful shutdown');
const runningProcesses = Array.from(this.processes.values())
.filter(p => p.status === 'running' || p.status === 'starting');
if (runningProcesses.length === 0) {
this.logger.info('No running processes to stop');
return;
}
this.logger.info(`Stopping ${runningProcesses.length} processes`);
const stopPromises = runningProcesses.map(async (processInfo) => {
try {
await this.stopProcess(processInfo.id);
}
catch (error) {
this.logger.error(`Failed to stop process ${processInfo.name}`, {
processId: processInfo.id,
error: error.message
});
}
});
await Promise.all(stopPromises);
this.restartTimers.forEach(timer => clearTimeout(timer));
this.restartTimers.clear();
this.logger.info('Graceful shutdown completed');
}
getHealthCheck() {
const results = [];
for (const processInfo of this.processes.values()) {
for (const instance of processInfo.instances) {
if (instance.status === 'running' && instance.pid) {
try {
const memUsage = process.memoryUsage();
const uptime = instance.uptime ? Date.now() - instance.uptime : 0;
results.push({
processId: processInfo.id,
memory: memUsage.rss,
cpu: 0, // TODO: Implement CPU monitoring
uptime,
healthy: instance.status === 'running'
});
}
catch (error) {
results.push({
processId: processInfo.id,
memory: 0,
cpu: 0,
uptime: 0,
healthy: false,
issues: [`Health check failed: ${error.message}`]
});
}
}
}
}
return results;
}
}
exports.ProcessOrchestrator = ProcessOrchestrator;
//# sourceMappingURL=ProcessOrchestrator.js.map