@nodedaemon/core
Version:
Production-ready Node.js process manager with zero external dependencies
817 lines • 33.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.NodeDaemonCore = void 0;
const net_1 = require("net");
const fs_1 = require("fs");
const util_1 = require("util");
const events_1 = require("events");
const ProcessOrchestrator_1 = require("../core/ProcessOrchestrator");
const FileWatcher_1 = require("../core/FileWatcher");
const LogManager_1 = require("../core/LogManager");
const StateManager_1 = require("../core/StateManager");
const HealthMonitor_1 = require("../core/HealthMonitor");
const WebUIServer_1 = require("../core/WebUIServer");
const helpers_1 = require("../utils/helpers");
const constants_1 = require("../utils/constants");
const unlinkAsync = (0, util_1.promisify)(fs_1.unlink);
class NodeDaemonCore extends events_1.EventEmitter {
server;
logger;
stateManager;
processOrchestrator;
fileWatcher;
healthMonitor;
webUIServer;
clients = new Set();
isShuttingDown = false;
healthCheckTimer = null;
watchedProcesses = new Map(); // processId -> watch paths
webUIConfig = null;
constructor() {
super();
// Initialize core components
this.logger = new LogManager_1.LogManager();
this.stateManager = new StateManager_1.StateManager(this.logger);
this.processOrchestrator = new ProcessOrchestrator_1.ProcessOrchestrator(this.logger);
this.fileWatcher = new FileWatcher_1.FileWatcher();
this.healthMonitor = new HealthMonitor_1.HealthMonitor(this.logger);
this.webUIServer = new WebUIServer_1.WebUIServer();
// Create IPC server
this.server = (0, net_1.createServer)();
this.setupEventHandlers();
this.setupFileWatchHandlers();
this.setupProcessHandlers();
this.setupHealthHandlers();
this.setupWebUIHandlers();
this.setupSignalHandlers();
}
setupEventHandlers() {
this.server.on('connection', (socket) => this.handleClientConnection(socket));
this.server.on('error', (error) => this.handleServerError(error));
this.server.on('close', () => this.logger.info('IPC server closed'));
// Logger error handling is built into LogManager
// this.logger.on('error', (error) => {
// console.error('Logger error:', error);
// });
}
setupFileWatchHandlers() {
this.fileWatcher.on('fileChange', (event) => {
this.logger.debug(`File change detected: ${event.type} ${event.path}`);
this.handleFileChange(event);
});
this.fileWatcher.on('error', (error) => {
this.logger.error('File watcher error', { error: error.message });
});
}
setupProcessHandlers() {
this.processOrchestrator.on('processStarted', (processInfo) => {
this.stateManager.setProcess(processInfo.id, processInfo);
this.healthMonitor.addProcess(processInfo);
this.logger.info(`Process started: ${processInfo.name}`, { processId: processInfo.id });
});
this.processOrchestrator.on('processStopped', (processInfo) => {
this.stateManager.updateProcess(processInfo.id, processInfo);
this.healthMonitor.removeProcess(processInfo.id);
this.stopWatchingProcess(processInfo.id);
this.logger.info(`Process stopped: ${processInfo.name}`, { processId: processInfo.id });
});
this.processOrchestrator.on('processRestarted', (processInfo) => {
this.stateManager.updateProcess(processInfo.id, processInfo);
this.healthMonitor.updateProcess(processInfo);
this.logger.info(`Process restarted: ${processInfo.name}`, { processId: processInfo.id });
});
this.processOrchestrator.on('instanceExit', (processInfo, instance, code, signal) => {
this.stateManager.updateProcess(processInfo.id, processInfo);
this.logger.info(`Process instance exited`, {
processId: processInfo.id,
instanceId: instance.id,
code,
signal
});
});
this.processOrchestrator.on('maxRestartsReached', (processInfo, instance) => {
this.logger.error(`Process ${processInfo.name} will not be restarted anymore`, {
processId: processInfo.id,
instanceId: instance.id,
restarts: instance.restarts
});
});
}
setupHealthHandlers() {
this.healthMonitor.on('healthIssues', async (unhealthyProcesses) => {
for (const result of unhealthyProcesses) {
const processInfo = this.processOrchestrator.getProcess(result.processId);
if (!processInfo)
continue;
const config = processInfo.config;
// Check for high memory usage
if (config.autoRestartOnHighMemory && result.issues) {
const memoryIssue = result.issues.find(issue => issue.includes('High memory usage'));
if (memoryIssue) {
const threshold = config.memoryThreshold ? (0, helpers_1.parseMemoryString)(config.memoryThreshold) : (0, helpers_1.parseMemoryString)(constants_1.DEFAULT_CONFIG.memoryThreshold);
if (result.memory > threshold) {
this.logger.warn(`Auto-restarting process due to high memory usage`, {
processId: processInfo.id,
processName: processInfo.name,
memory: (0, helpers_1.formatMemory)(result.memory),
threshold: config.memoryThreshold || constants_1.DEFAULT_CONFIG.memoryThreshold
});
await this.processOrchestrator.restartProcess(processInfo.id);
}
}
}
// Check for high CPU usage
if (config.autoRestartOnHighCpu && result.issues) {
const cpuIssue = result.issues.find(issue => issue.includes('High CPU usage'));
if (cpuIssue) {
const threshold = config.cpuThreshold || constants_1.DEFAULT_CONFIG.cpuThreshold;
if (result.cpu > threshold) {
this.logger.warn(`Auto-restarting process due to high CPU usage`, {
processId: processInfo.id,
processName: processInfo.name,
cpu: `${result.cpu.toFixed(1)}%`,
threshold: `${threshold}%`
});
await this.processOrchestrator.restartProcess(processInfo.id);
}
}
}
}
});
this.healthMonitor.on('systemMetrics', (metrics) => {
this.logger.debug('System metrics update', metrics);
});
// Update process instance metrics when health monitor emits them
this.healthMonitor.on('processMetrics', (processId, metrics) => {
const processInfo = this.processOrchestrator.getProcess(processId);
if (processInfo) {
// Find the instance by PID
for (const instance of processInfo.instances) {
if (instance.pid) {
// Update the instance metrics
instance.memory = metrics.memory;
instance.cpu = metrics.cpu;
}
}
}
});
}
setupWebUIHandlers() {
// API handlers
this.webUIServer.on('api:list', (callback) => {
const processes = this.processOrchestrator.getProcesses();
// Transform processes to include aggregated data for frontend
const transformedProcesses = processes.map(p => {
const mainInstance = p.instances[0];
const totalMemory = p.instances.reduce((sum, i) => sum + (i.memory || 0), 0);
const totalCpu = p.instances.reduce((sum, i) => sum + (i.cpu || 0), 0);
const uptime = mainInstance && mainInstance.uptime ?
Math.floor((Date.now() - mainInstance.uptime) / 1000) : 0;
return {
...p,
memory: totalMemory,
cpu: totalCpu,
uptime: uptime
};
});
callback(transformedProcesses);
});
this.webUIServer.on('api:status', (callback) => {
const status = {
version: '1.0.2',
uptime: process.uptime(),
pid: process.pid,
processCount: this.processOrchestrator.getProcesses().length,
memory: process.memoryUsage()
};
callback(status);
});
this.webUIServer.on('api:start', async (processId, callback) => {
try {
await this.processOrchestrator.startProcess(processId);
callback({ success: true });
}
catch (error) {
callback({ error: error.message });
}
});
this.webUIServer.on('api:stop', async (processId, callback) => {
try {
await this.processOrchestrator.stopProcess(processId);
callback({ success: true });
}
catch (error) {
callback({ error: error.message });
}
});
this.webUIServer.on('api:restart', async (processId, callback) => {
try {
await this.processOrchestrator.restartProcess(processId);
callback({ success: true });
}
catch (error) {
callback({ error: error.message });
}
});
this.webUIServer.on('api:reload', async (processId, callback) => {
try {
const processInfo = this.processOrchestrator.getProcess(processId);
if (!processInfo) {
throw new Error('Process not found');
}
if (!processInfo.config.instances ||
(typeof processInfo.config.instances === 'number' && processInfo.config.instances <= 1)) {
throw new Error('Reload is only available for cluster mode');
}
await this.processOrchestrator.gracefulReload(processInfo);
callback({ success: true });
}
catch (error) {
callback({ error: error.message });
}
});
// WebSocket handlers
this.webUIServer.on('ws:list', (data, callback) => {
const processes = this.processOrchestrator.getProcesses();
// Transform processes to include aggregated data for frontend
const transformedProcesses = processes.map(p => {
const mainInstance = p.instances[0];
const totalMemory = p.instances.reduce((sum, i) => sum + (i.memory || 0), 0);
const totalCpu = p.instances.reduce((sum, i) => sum + (i.cpu || 0), 0);
const uptime = mainInstance && mainInstance.uptime ?
Math.floor((Date.now() - mainInstance.uptime) / 1000) : 0;
return {
...p,
memory: totalMemory,
cpu: totalCpu,
uptime: uptime
};
});
callback(transformedProcesses);
});
// Process event forwarding
this.processOrchestrator.on('processStarted', (processInfo) => {
this.webUIServer.broadcastProcessUpdate(processInfo);
});
this.processOrchestrator.on('processStopped', (processInfo) => {
this.webUIServer.broadcastProcessUpdate(processInfo);
});
this.processOrchestrator.on('processRestarted', (processInfo) => {
this.webUIServer.broadcastProcessUpdate(processInfo);
});
// Log forwarding
this.logger.on('log', (logEntry) => {
this.webUIServer.broadcastLog(logEntry);
});
// Health metrics forwarding
this.healthMonitor.on('processMetrics', (processId, metrics) => {
this.webUIServer.broadcastMetric(processId, metrics);
});
}
setupSignalHandlers() {
process.on('SIGTERM', () => this.gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => this.gracefulShutdown('SIGINT'));
process.on('SIGHUP', () => this.reload());
process.on('uncaughtException', (error) => {
this.logger.error('Uncaught exception', { error: error.message, stack: error.stack });
this.gracefulShutdown('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
this.logger.error('Unhandled rejection', { reason });
});
}
handleFileChange(event) {
this.logger.info(`handleFileChange called`, {
event,
watchedProcesses: Array.from(this.watchedProcesses.entries())
});
// Find processes that should be restarted due to file changes
const processesToRestart = new Set();
const path = require('path');
for (const [processId, watchPaths] of this.watchedProcesses.entries()) {
for (const watchPath of watchPaths) {
// Resolve watch path to absolute path for comparison
const absoluteWatchPath = path.resolve(watchPath);
if (event.path.startsWith(absoluteWatchPath)) {
this.logger.info(`File change matches watch path`, {
processId,
watchPath,
absoluteWatchPath,
eventPath: event.path
});
processesToRestart.add(processId);
break;
}
}
}
this.logger.info(`Processes to restart`, {
count: processesToRestart.size,
processIds: Array.from(processesToRestart)
});
// Restart affected processes
for (const processId of processesToRestart) {
const processInfo = this.processOrchestrator.getProcess(processId);
if (processInfo && processInfo.status === 'running') {
this.logger.info(`Restarting process due to file change: ${processInfo.name}`, {
processId,
changedFile: event.path
});
this.processOrchestrator.restartProcess(processId).catch(error => {
this.logger.error(`Failed to restart process ${processInfo.name}`, {
processId,
error: error.message
});
});
}
}
}
handleClientConnection(socket) {
this.clients.add(socket);
this.logger.debug('Client connected');
socket.on('data', (data) => this.handleClientMessage(socket, data));
socket.on('error', (error) => {
this.logger.error('Client socket error', { error: error.message });
this.clients.delete(socket);
});
socket.on('close', () => {
this.logger.debug('Client disconnected');
this.clients.delete(socket);
});
}
handleClientMessage(socket, data) {
try {
const message = JSON.parse(data.toString());
this.processIPCMessage(socket, message);
}
catch (error) {
this.sendError(socket, '', 'Invalid JSON message');
}
}
async processIPCMessage(socket, message) {
const { id, type, data } = message;
try {
let responseData = null;
switch (type) {
case 'ping':
responseData = { status: 'ok', timestamp: Date.now() };
break;
case 'start':
responseData = await this.handleStart(data);
break;
case 'stop':
responseData = await this.handleStop(data);
break;
case 'restart':
responseData = await this.handleRestart(data);
break;
case 'list':
responseData = this.handleList();
break;
case 'status':
responseData = this.handleStatus(data);
break;
case 'logs':
responseData = this.handleLogs(data);
break;
case 'shutdown':
responseData = await this.handleShutdown();
break;
case 'webui':
responseData = await this.handleWebUI(data);
break;
default:
throw new Error(`Unknown command: ${type}`);
}
this.sendResponse(socket, id, true, responseData);
}
catch (error) {
this.sendError(socket, id, error.message);
}
}
async handleStart(data) {
const processId = await this.processOrchestrator.startProcess(data);
// Setup file watching if enabled
if (data.watch) {
this.setupFileWatching(processId, data);
}
return { processId };
}
setupFileWatching(processId, config) {
if (config.watch === true) {
// Watch the script directory
const watchPaths = [require('path').dirname(config.script)];
this.logger.info(`Setting up file watching for process ${processId}`, {
processId,
watchPaths,
scriptPath: config.script
});
this.watchedProcesses.set(processId, watchPaths);
this.fileWatcher.watch(watchPaths, { recursive: true });
}
else if (Array.isArray(config.watch)) {
// Watch specific paths
this.logger.info(`Setting up file watching for process ${processId}`, {
processId,
watchPaths: config.watch
});
this.watchedProcesses.set(processId, config.watch);
this.fileWatcher.watch(config.watch, { recursive: true });
}
}
stopWatchingProcess(processId) {
this.watchedProcesses.delete(processId);
// If no more processes are being watched, stop file watching
if (this.watchedProcesses.size === 0) {
this.fileWatcher.unwatch();
}
}
async handleStop(data) {
const { processId, name, force = false } = data;
let targetProcess;
if (processId) {
targetProcess = this.processOrchestrator.getProcess(processId);
}
else if (name) {
targetProcess = this.processOrchestrator.getProcessByName(name);
}
else {
throw new Error('Either processId or name must be provided');
}
if (!targetProcess) {
throw new Error('Process not found');
}
await this.processOrchestrator.stopProcess(targetProcess.id, force);
return { success: true };
}
async handleRestart(data) {
const { processId, name, graceful } = data;
let targetProcess;
if (processId) {
targetProcess = this.processOrchestrator.getProcess(processId);
}
else if (name) {
targetProcess = this.processOrchestrator.getProcessByName(name);
}
else {
throw new Error('Either processId or name must be provided');
}
if (!targetProcess) {
throw new Error('Process not found');
}
await this.processOrchestrator.restartProcess(targetProcess.id, graceful || false);
return { success: true };
}
handleList() {
const processes = this.processOrchestrator.getProcesses();
const stats = this.stateManager.getStats();
return {
processes: processes.map(p => ({
id: p.id,
name: p.name,
script: p.script,
status: p.status,
instances: p.instances.length,
restarts: p.restarts,
uptime: p.instances[0]?.uptime ? Date.now() - p.instances[0].uptime : 0,
memory: p.instances.reduce((sum, i) => sum + (i.memory || 0), 0),
cpu: p.instances.reduce((sum, i) => sum + (i.cpu || 0), 0)
})),
stats
};
}
handleStatus(data) {
if (!data || (!data.processId && !data.name)) {
// Return daemon status
return {
daemon: {
pid: process.pid,
uptime: Date.now() - this.stateManager.getState().startedAt,
version: this.stateManager.getState().version,
...this.stateManager.getStats()
},
health: this.processOrchestrator.getHealthCheck()
};
}
const { processId, name } = data;
let targetProcess;
if (processId) {
targetProcess = this.processOrchestrator.getProcess(processId);
}
else if (name) {
targetProcess = this.processOrchestrator.getProcessByName(name);
}
if (!targetProcess) {
throw new Error('Process not found');
}
return targetProcess;
}
handleLogs(data) {
const { processId, name, lines = 100 } = data;
let targetProcessId = processId;
if (!targetProcessId && name) {
const process = this.processOrchestrator.getProcessByName(name);
targetProcessId = process?.id;
}
const logs = this.logger.getRecentLogs(lines, targetProcessId);
return { logs };
}
async handleShutdown() {
setImmediate(() => {
this.gracefulShutdown('api');
});
return { success: true };
}
async handleWebUI(data) {
if (!data || !data.action) {
throw new Error('WebUI action required');
}
switch (data.action) {
case 'set':
if (!data.config) {
throw new Error('WebUI config required');
}
await this.setWebUIConfig(data.config);
return this.getWebUIConfig();
case 'status':
return this.getWebUIConfig();
default:
throw new Error(`Unknown webui action: ${data.action}`);
}
}
sendResponse(socket, id, success, data) {
const response = {
id,
success,
data,
timestamp: Date.now()
};
const responseData = JSON.stringify(response) + '\n';
socket.write(responseData);
}
sendError(socket, id, error) {
this.sendResponse(socket, id, false, { error });
}
handleServerError(error) {
if (error.code === 'EADDRINUSE') {
this.logger.error('IPC socket already in use - another daemon may be running');
process.exit(1);
}
else {
this.logger.error('IPC server error', { error: error.message });
}
}
async start() {
try {
// Ensure daemon directory exists
(0, helpers_1.ensureDir)(constants_1.NODEDAEMON_DIR);
// Clean up existing socket file on Unix systems
if (process.platform !== 'win32') {
try {
await unlinkAsync(constants_1.IPC_SOCKET_PATH);
}
catch {
// Socket file doesn't exist, ignore
}
}
// Start IPC server
await new Promise((resolve, reject) => {
this.server.listen(constants_1.IPC_SOCKET_PATH, () => {
this.logger.info('NodeDaemon started', {
pid: process.pid,
socketPath: constants_1.IPC_SOCKET_PATH
});
resolve();
});
this.server.on('error', reject);
});
// Set proper permissions on Unix socket
if (process.platform !== 'win32') {
const fs = require('fs');
fs.chmodSync(constants_1.IPC_SOCKET_PATH, 0o600);
}
// Start health check timer
this.startHealthCheck();
// Start health monitoring
this.healthMonitor.startMonitoring();
// Restore any previously running processes
await this.restoreProcesses();
// Start Web UI if configured
await this.startWebUI();
this.emit('started');
}
catch (error) {
this.logger.error('Failed to start daemon', { error: error.message });
throw error;
}
}
startHealthCheck() {
this.healthCheckTimer = setInterval(() => {
if (!this.isShuttingDown) {
this.performHealthCheck();
}
}, constants_1.HEALTH_CHECK_INTERVAL);
}
performHealthCheck() {
try {
const healthResults = this.processOrchestrator.getHealthCheck();
healthResults.forEach(result => {
if (!result.healthy && result.issues) {
this.logger.warn('Process health check failed', {
processId: result.processId,
issues: result.issues
});
}
});
// Log system stats periodically
const stats = this.stateManager.getStats();
this.logger.debug('Health check completed', { stats });
}
catch (error) {
this.logger.error('Health check failed', { error: error.message });
}
}
async restoreProcesses() {
try {
const processes = this.stateManager.getProcessesByStatus('running');
if (processes.length === 0) {
this.logger.info('No processes to restore');
return;
}
this.logger.info(`Restoring ${processes.length} processes`);
const restorePromises = processes.map(async (processInfo) => {
try {
// Reset instances since we're starting fresh
processInfo.instances = [];
await this.processOrchestrator.startProcess(processInfo.config);
if (processInfo.config.watch) {
this.setupFileWatching(processInfo.id, processInfo.config);
}
}
catch (error) {
this.logger.error(`Failed to restore process ${processInfo.name}`, {
processId: processInfo.id,
error: error.message
});
}
});
await Promise.all(restorePromises);
}
catch (error) {
this.logger.error('Failed to restore processes', { error: error.message });
}
}
async reload() {
this.logger.info('Reloading daemon configuration');
try {
await this.processOrchestrator.reloadAllProcesses();
this.logger.info('Daemon reload completed');
}
catch (error) {
this.logger.error('Daemon reload failed', { error: error.message });
}
}
async startWebUI() {
this.logger.debug('startWebUI called', { currentConfig: this.webUIConfig });
// Don't reload config from state if we already have it
// This was causing the issue - we were overwriting the config we just set!
if (!this.webUIConfig) {
// Load web UI config from state or use defaults
const savedState = this.stateManager.getState();
if (savedState.webUIConfig) {
this.webUIConfig = savedState.webUIConfig;
}
else {
// Use defaults from constants
this.webUIConfig = {
enabled: false,
port: 8080,
host: '127.0.0.1'
};
}
}
this.logger.debug('WebUI config after check', { config: this.webUIConfig });
if (this.webUIConfig && this.webUIConfig.enabled) {
try {
// Update config if server already exists
if (this.webUIServer.isRunning()) {
await this.webUIServer.stop();
}
// Update config and restart
this.webUIServer = new WebUIServer_1.WebUIServer(this.webUIConfig);
// Re-setup handlers (they were already setup in constructor)
this.setupWebUIHandlers();
await this.webUIServer.start();
this.logger.info('Web UI started', {
port: this.webUIConfig.port,
host: this.webUIConfig.host
});
}
catch (error) {
this.logger.error('Failed to start Web UI', {
error: error.message,
stack: error.stack
});
}
}
}
async setWebUIConfig(config) {
const wasEnabled = this.webUIConfig?.enabled;
// Ensure we have a base config
if (!this.webUIConfig) {
this.webUIConfig = {
enabled: false,
port: 8080,
host: '127.0.0.1'
};
}
this.webUIConfig = { ...this.webUIConfig, ...config };
// Save config to state
const state = this.stateManager.getState();
state.webUIConfig = this.webUIConfig;
this.stateManager.forceSave();
// Handle enable/disable
this.logger.debug('WebUI config change', {
wasEnabled,
isEnabled: this.webUIConfig.enabled,
config: this.webUIConfig
});
if (!wasEnabled && this.webUIConfig.enabled) {
// Start Web UI
this.logger.info('Starting Web UI...');
await this.startWebUI();
}
else if (wasEnabled && !this.webUIConfig.enabled) {
// Stop Web UI
this.logger.info('Stopping Web UI...');
await this.webUIServer.stop();
}
else if (wasEnabled && this.webUIConfig.enabled) {
// Restart Web UI with new config
this.logger.info('Restarting Web UI...');
await this.webUIServer.stop();
await this.startWebUI();
}
}
getWebUIConfig() {
return this.webUIConfig;
}
async gracefulShutdown(reason = 'unknown') {
if (this.isShuttingDown)
return;
this.isShuttingDown = true;
this.logger.info(`Starting graceful shutdown (reason: ${reason})`);
try {
// Stop health checks
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
// Close IPC server
this.server.close();
// Disconnect all clients
this.clients.forEach(client => {
client.end();
});
this.clients.clear();
// Stop file watcher
this.fileWatcher.unwatch();
// Stop health monitoring
this.healthMonitor.stopMonitoring();
// Stop Web UI
await this.webUIServer.stop();
// Stop all processes
await this.processOrchestrator.gracefulShutdown();
// Save final state
this.stateManager.forceSave();
// Shutdown components
await this.logger.shutdown();
this.stateManager.shutdown();
this.logger.info('Graceful shutdown completed');
// Clean up socket file on Unix systems
if (process.platform !== 'win32') {
try {
await unlinkAsync(constants_1.IPC_SOCKET_PATH);
}
catch {
// Ignore cleanup errors
}
}
this.emit('shutdown');
}
catch (error) {
console.error('Error during graceful shutdown:', error);
process.exit(1);
}
process.exit(0);
}
getStats() {
return {
daemon: this.stateManager.getStats(),
processes: this.processOrchestrator.getProcesses().length,
clients: this.clients.size,
uptime: Date.now() - this.stateManager.getState().startedAt
};
}
}
exports.NodeDaemonCore = NodeDaemonCore;
//# sourceMappingURL=NodeDaemonCore.js.map