neex
Version:
Neex - Modern Fullstack Framework Built on Express and Next.js. Fast to Start, Easy to Build, Ready to Deploy
670 lines (669 loc) • 28 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ProcessManager = void 0;
// src/process-manager.ts - Enhanced PM2-like process manager
const child_process_1 = require("child_process");
const fs = __importStar(require("fs"));
const fs_1 = require("fs");
const path = __importStar(require("path"));
const events_1 = require("events");
const chalk_1 = __importDefault(require("chalk"));
const figures_1 = __importDefault(require("figures"));
const logger_js_1 = __importDefault(require("./logger.js"));
class ProcessManager extends events_1.EventEmitter {
constructor(configPath) {
super();
this.processes = new Map();
this.logStreams = new Map();
this.isMonitoring = false;
this.isDisposed = false;
const baseDir = path.join(process.cwd(), '.neex');
this.configPath = configPath || path.join(baseDir, 'processes.json');
this.logDir = path.join(baseDir, 'logs');
this.pidDir = path.join(baseDir, 'pids');
this.ensureDirectories();
this.startMonitoring();
}
async ensureDirectories() {
try {
const dirs = [
path.dirname(this.configPath),
this.logDir,
this.pidDir
];
for (const dir of dirs) {
await fs_1.promises.mkdir(dir, { recursive: true });
}
}
catch (error) {
logger_js_1.default.printLine(`Failed to create directories: ${error.message}`, 'error');
}
}
async saveConfig() {
try {
const config = Array.from(this.processes.values()).map(proc => {
var _a, _b;
return ({
...proc.config,
status: proc.status,
created_at: proc.created_at.toISOString(),
started_at: (_a = proc.started_at) === null || _a === void 0 ? void 0 : _a.toISOString(),
restarts: proc.restarts,
last_restart: (_b = proc.last_restart) === null || _b === void 0 ? void 0 : _b.toISOString()
});
});
await fs_1.promises.writeFile(this.configPath, JSON.stringify(config, null, 2));
}
catch (error) {
logger_js_1.default.printLine(`Failed to save config: ${error.message}`, 'error');
}
}
async load() {
try {
const data = await fs_1.promises.readFile(this.configPath, 'utf-8');
const configs = JSON.parse(data);
for (const config of configs) {
if (config.id && !this.processes.has(config.id)) {
const processInfo = {
id: config.id,
name: config.name,
status: 'stopped',
uptime: 0,
restarts: config.restarts || 0,
memory: 0,
cpu: 0,
created_at: config.created_at ? new Date(config.created_at) : new Date(),
started_at: config.started_at ? new Date(config.started_at) : undefined,
last_restart: config.last_restart ? new Date(config.last_restart) : undefined,
config: {
...config,
// Ensure required fields have defaults
autorestart: config.autorestart !== false,
max_restarts: config.max_restarts || 10,
restart_delay: config.restart_delay || 1000,
instances: config.instances || 1
}
};
this.processes.set(config.id, processInfo);
// Check if process is actually running
if (config.pid) {
const isRunning = await this.isProcessRunning(config.pid);
if (isRunning) {
processInfo.status = 'online';
processInfo.pid = config.pid;
processInfo.started_at = processInfo.started_at || new Date();
}
}
}
}
}
catch (error) {
// Config file might not exist yet, that's fine
}
}
async isProcessRunning(pid) {
try {
process.kill(pid, 0);
return true;
}
catch (error) {
return false;
}
}
generateId(name) {
let id = name.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
let counter = 0;
while (this.processes.has(id)) {
counter++;
id = `${name.toLowerCase().replace(/[^a-z0-9-_]/g, '-')}-${counter}`;
}
return id;
}
setupLogStreams(processInfo) {
const { config } = processInfo;
// Close existing streams
this.closeLogStreams(processInfo.id);
// Setup default log paths
if (!config.out_file) {
config.out_file = path.join(this.logDir, `${processInfo.id}-out.log`);
}
if (!config.error_file) {
config.error_file = path.join(this.logDir, `${processInfo.id}-error.log`);
}
try {
const outStream = fs.createWriteStream(config.out_file, { flags: 'a' });
const errStream = fs.createWriteStream(config.error_file, { flags: 'a' });
this.logStreams.set(processInfo.id, { out: outStream, err: errStream });
}
catch (error) {
logger_js_1.default.printLine(`Failed to create log streams for ${processInfo.id}: ${error.message}`, 'error');
}
}
closeLogStreams(id) {
var _a, _b;
const streams = this.logStreams.get(id);
if (streams) {
(_a = streams.out) === null || _a === void 0 ? void 0 : _a.end();
(_b = streams.err) === null || _b === void 0 ? void 0 : _b.end();
this.logStreams.delete(id);
}
}
async writePidFile(processInfo) {
if (!processInfo.pid)
return;
const pidFile = path.join(this.pidDir, `${processInfo.id}.pid`);
try {
await fs_1.promises.writeFile(pidFile, processInfo.pid.toString());
}
catch (error) {
logger_js_1.default.printLine(`Failed to write PID file: ${error.message}`, 'error');
}
}
async removePidFile(id) {
const pidFile = path.join(this.pidDir, `${id}.pid`);
try {
await fs_1.promises.unlink(pidFile);
}
catch (error) {
// PID file might not exist, ignore
}
}
parseCommand(script) {
// Handle different script formats
if (script.includes(' ')) {
const parts = script.split(' ');
return {
command: parts[0],
args: parts.slice(1)
};
}
// Handle file extensions
if (script.endsWith('.js') || script.endsWith('.mjs') || script.endsWith('.cjs')) {
return {
command: 'node',
args: [script]
};
}
if (script.endsWith('.ts') || script.endsWith('.mts') || script.endsWith('.cts')) {
return {
command: 'npx',
args: ['ts-node', script]
};
}
return {
command: script,
args: []
};
}
async startProcess(processInfo) {
const { config } = processInfo;
try {
processInfo.status = 'launching';
processInfo.started_at = new Date();
// Setup logging
this.setupLogStreams(processInfo);
// Parse command
const { command, args } = this.parseCommand(config.script);
const finalArgs = [...args, ...(config.args || [])];
// Setup environment
const env = {
...process.env,
...config.env,
NEEX_PROCESS_ID: processInfo.id,
NEEX_PROCESS_NAME: processInfo.name,
NODE_ENV: process.env.NODE_ENV || 'production'
};
// Spawn process
const childProcess = (0, child_process_1.spawn)(command, finalArgs, {
cwd: config.cwd || process.cwd(),
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true // Detach the process to run independently
});
if (!childProcess.pid) {
throw new Error(`Failed to start process: ${config.script}`);
}
processInfo.process = childProcess;
processInfo.pid = childProcess.pid;
processInfo.status = 'online';
// Allow the parent process to exit independently of the child
childProcess.unref();
// Write PID file
await this.writePidFile(processInfo);
console.log(chalk_1.default.green(`${figures_1.default.tick} Process ${processInfo.name} (${processInfo.id}) started with PID ${processInfo.pid}`));
this.emit('process:start', processInfo);
// Setup logging
const streams = this.logStreams.get(processInfo.id);
if (childProcess.stdout && (streams === null || streams === void 0 ? void 0 : streams.out)) {
childProcess.stdout.pipe(streams.out);
childProcess.stdout.on('data', (data) => {
var _a;
const message = data.toString();
if (config.time) {
const timestamp = new Date().toISOString();
(_a = streams.out) === null || _a === void 0 ? void 0 : _a.write(`[${timestamp}] ${message}`);
}
this.emit('process:log', {
id: processInfo.id,
type: 'stdout',
data: message
});
});
}
if (childProcess.stderr && (streams === null || streams === void 0 ? void 0 : streams.err)) {
childProcess.stderr.pipe(streams.err);
childProcess.stderr.on('data', (data) => {
var _a;
const message = data.toString();
if (config.time) {
const timestamp = new Date().toISOString();
(_a = streams.err) === null || _a === void 0 ? void 0 : _a.write(`[${timestamp}] ${message}`);
}
this.emit('process:log', {
id: processInfo.id,
type: 'stderr',
data: message
});
});
}
// Handle process exit
childProcess.on('exit', async (code, signal) => {
processInfo.exit_code = code || undefined;
processInfo.exit_signal = signal || undefined;
processInfo.status = code === 0 ? 'stopped' : 'errored';
processInfo.process = undefined;
processInfo.pid = undefined;
await this.removePidFile(processInfo.id);
const exitMsg = signal
? `Process ${processInfo.name} killed by signal ${signal}`
: `Process ${processInfo.name} exited with code ${code}`;
console.log(chalk_1.default.yellow(`${figures_1.default.warning} ${exitMsg}`));
this.emit('process:exit', { processInfo, code, signal });
// Auto-restart if enabled and not manually stopped
if (config.autorestart &&
processInfo.status === 'errored' &&
processInfo.restarts < (config.max_restarts || 10)) {
const delay = config.restart_delay || 1000;
console.log(chalk_1.default.blue(`${figures_1.default.info} Restarting ${processInfo.name} in ${delay}ms...`));
setTimeout(async () => {
try {
await this.restart(processInfo.id);
}
catch (error) {
console.error(chalk_1.default.red(`${figures_1.default.cross} Failed to restart ${processInfo.name}: ${error.message}`));
}
}, delay);
}
await this.saveConfig();
});
// Handle process error
childProcess.on('error', (error) => {
processInfo.status = 'errored';
console.error(chalk_1.default.red(`${figures_1.default.cross} Process ${processInfo.name} error: ${error.message}`));
this.emit('process:error', { processInfo, error });
});
await this.saveConfig();
}
catch (error) {
processInfo.status = 'errored';
console.error(chalk_1.default.red(`${figures_1.default.cross} Failed to start ${processInfo.name}: ${error.message}`));
this.emit('process:error', { processInfo, error });
throw error;
}
}
startMonitoring() {
if (this.isMonitoring || this.isDisposed)
return;
this.isMonitoring = true;
this.monitoringInterval = setInterval(async () => {
await this.updateProcessStats();
}, 5000);
}
stopMonitoring() {
if (this.monitoringInterval) {
clearInterval(this.monitoringInterval);
this.monitoringInterval = undefined;
}
this.isMonitoring = false;
}
async updateProcessStats() {
for (const processInfo of this.processes.values()) {
if (processInfo.status === 'online' && processInfo.pid) {
try {
// Check if process is still running
const isRunning = await this.isProcessRunning(processInfo.pid);
if (!isRunning) {
processInfo.status = 'stopped';
processInfo.process = undefined;
processInfo.pid = undefined;
await this.removePidFile(processInfo.id);
continue;
}
// Update uptime
if (processInfo.started_at) {
processInfo.uptime = Math.floor((Date.now() - processInfo.started_at.getTime()) / 1000);
}
// Get memory and CPU usage (Unix/Linux only)
if (process.platform !== 'win32') {
try {
const stats = await this.getProcessStats(processInfo.pid);
processInfo.memory = stats.memory;
processInfo.cpu = stats.cpu;
}
catch (error) {
// Process might have died
}
}
}
catch (error) {
// Process monitoring error
}
}
}
}
async getProcessStats(pid) {
return new Promise((resolve, reject) => {
(0, child_process_1.exec)(`ps -o pid,rss,pcpu -p ${pid}`, (error, stdout) => {
if (error) {
resolve({ memory: 0, cpu: 0 });
return;
}
const lines = stdout.trim().split('\n');
if (lines.length < 2) {
resolve({ memory: 0, cpu: 0 });
return;
}
const stats = lines[1].trim().split(/\s+/);
const memory = parseInt(stats[1]) * 1024; // Convert KB to bytes
const cpu = parseFloat(stats[2]);
resolve({ memory, cpu });
});
});
}
// Public API methods
async start(config) {
const id = this.generateId(config.name);
const processInfo = {
id,
name: config.name,
status: 'stopped',
uptime: 0,
restarts: 0,
memory: 0,
cpu: 0,
created_at: new Date(),
config: { ...config, id }
};
this.processes.set(id, processInfo);
await this.startProcess(processInfo);
return id;
}
async stop(id) {
const processInfo = this.processes.get(id);
if (!processInfo) {
throw new Error(`Process ${id} not found`);
}
if (processInfo.status === 'stopped') {
console.log(chalk_1.default.yellow(`${figures_1.default.info} Process ${id} is already stopped`));
return;
}
processInfo.status = 'stopping';
console.log(chalk_1.default.blue(`${figures_1.default.info} Stopping process ${processInfo.name} (${id})...`));
if (processInfo.process && processInfo.pid) {
try {
// Try graceful shutdown first
processInfo.process.kill('SIGTERM');
// Wait for graceful shutdown
await new Promise(resolve => setTimeout(resolve, 2000));
// Force kill if still running
if (processInfo.process && !processInfo.process.killed) {
processInfo.process.kill('SIGKILL');
}
}
catch (error) {
// Process might already be dead
}
}
processInfo.status = 'stopped';
processInfo.process = undefined;
processInfo.pid = undefined;
await this.removePidFile(id);
this.closeLogStreams(id);
await this.saveConfig();
console.log(chalk_1.default.green(`${figures_1.default.tick} Process ${processInfo.name} (${id}) stopped`));
this.emit('process:stop', processInfo);
}
async restart(id) {
const processInfo = this.processes.get(id);
if (!processInfo) {
throw new Error(`Process ${id} not found`);
}
console.log(chalk_1.default.blue(`${figures_1.default.info} Restarting process ${processInfo.name} (${id})...`));
if (processInfo.status === 'online') {
await this.stop(id);
// Wait a bit before restarting
await new Promise(resolve => setTimeout(resolve, 1000));
}
processInfo.restarts++;
processInfo.last_restart = new Date();
await this.startProcess(processInfo);
}
async delete(id) {
const processInfo = this.processes.get(id);
if (!processInfo) {
throw new Error(`Process ${id} not found`);
}
if (processInfo.status === 'online') {
await this.stop(id);
}
// Remove log files
try {
if (processInfo.config.out_file) {
await fs_1.promises.unlink(processInfo.config.out_file);
}
if (processInfo.config.error_file) {
await fs_1.promises.unlink(processInfo.config.error_file);
}
}
catch (error) {
// Log files might not exist
}
this.closeLogStreams(id);
this.processes.delete(id);
await this.saveConfig();
console.log(chalk_1.default.green(`${figures_1.default.tick} Process ${processInfo.name} (${id}) deleted`));
this.emit('process:delete', processInfo);
}
async list() {
return Array.from(this.processes.values());
}
async logs(id, lines = 15) {
const processInfo = this.processes.get(id);
if (!processInfo) {
throw new Error(`Process ${id} not found`);
}
return this.readLogFiles(processInfo.config, lines);
}
async readLogFiles(config, lines) {
const logs = [];
const readLastLines = async (filePath) => {
try {
const content = await fs_1.promises.readFile(filePath, 'utf-8');
const fileLines = content.split('\n').filter(line => line.trim() !== '');
return fileLines.slice(-lines);
}
catch (error) {
return [];
}
};
if (config.out_file) {
const outLogs = await readLastLines(config.out_file);
logs.push(...outLogs.map(line => `[OUT] ${line}`));
}
if (config.error_file) {
const errLogs = await readLastLines(config.error_file);
logs.push(...errLogs.map(line => `[ERR] ${line}`));
}
return logs.slice(-lines);
}
followLogs(id, onLogEntry) {
const processInfo = this.processes.get(id);
if (!processInfo) {
logger_js_1.default.printLine(`Process ${id} not found for log following.`, 'error');
return undefined;
}
const { config } = processInfo;
const logFilesToWatch = [];
// Resolve log file paths, defaulting if not specified (consistent with setupLogStreams)
const outFile = config.out_file || path.join(this.logDir, `${processInfo.id}-out.log`);
const errFile = config.error_file || path.join(this.logDir, `${processInfo.id}-error.log`);
logFilesToWatch.push({ type: 'OUT', path: outFile });
if (errFile !== outFile) { // Avoid watching the same file path twice
logFilesToWatch.push({ type: 'ERR', path: errFile });
}
const lastReadSizes = {};
const setupWatcherForFile = (filePath, type) => {
try {
const stats = fs.existsSync(filePath) ? fs.statSync(filePath) : { size: 0 };
lastReadSizes[filePath] = stats.size;
}
catch (err) {
lastReadSizes[filePath] = 0;
// logger.printLine(`Could not get initial stats for ${filePath}: ${(err as Error).message}. Assuming size 0.`, 'debug');
}
const listener = (curr, prev) => {
if (curr.mtimeMs <= prev.mtimeMs && curr.size === prev.size) {
return;
}
const newSize = curr.size;
let oldSize = lastReadSizes[filePath];
// Fallback for oldSize, though it should be initialized
if (typeof oldSize !== 'number')
oldSize = 0;
if (newSize > oldSize) {
const stream = fs.createReadStream(filePath, {
start: oldSize,
end: newSize - 1,
encoding: 'utf-8',
});
stream.on('data', (chunk) => {
chunk.split('\n').forEach(line => {
if (line.trim() !== '') {
onLogEntry(`[${type}] ${line}`);
}
});
});
stream.on('error', (streamErr) => {
logger_js_1.default.printLine(`Error reading log chunk for ${filePath}: ${streamErr.message}`, 'error');
});
lastReadSizes[filePath] = newSize;
}
else if (newSize < oldSize) {
// File was truncated or replaced
// logger.printLine(`Log file ${filePath} was truncated. Reading from start.`, 'debug');
lastReadSizes[filePath] = 0;
const stream = fs.createReadStream(filePath, {
start: 0,
end: newSize - 1,
encoding: 'utf-8',
});
stream.on('data', (chunk) => {
chunk.split('\n').forEach(line => {
if (line.trim() !== '') {
onLogEntry(`[${type}] ${line}`);
}
});
});
stream.on('error', (streamErr) => {
logger_js_1.default.printLine(`Error reading truncated log file ${filePath}: ${streamErr.message}`, 'error');
});
lastReadSizes[filePath] = newSize;
}
};
try {
fs.watchFile(filePath, { persistent: true, interval: 500 }, listener);
}
catch (watchError) {
logger_js_1.default.printLine(`Failed to watch log file ${filePath}: ${watchError.message}`, 'error');
}
};
logFilesToWatch.forEach(fileToWatch => {
if (fileToWatch.path) { // Ensure path is defined
setupWatcherForFile(fileToWatch.path, fileToWatch.type);
}
});
const stop = () => {
logFilesToWatch.forEach(fileToWatch => {
if (fileToWatch.path) { // Ensure path is defined before unwatching
try {
fs.unwatchFile(fileToWatch.path);
}
catch (unwatchError) {
// logger.printLine(`Error unwatching ${fileToWatch.path}: ${(unwatchError as Error).message}`, 'debug');
}
}
});
};
return { stop };
}
async save() {
await this.saveConfig();
console.log(chalk_1.default.green(`${figures_1.default.tick} Process configuration saved`));
}
async stopAll() {
const promises = Array.from(this.processes.keys()).map(id => this.stop(id));
await Promise.all(promises);
}
async restartAll() {
const promises = Array.from(this.processes.keys()).map(id => this.restart(id));
await Promise.all(promises);
}
async deleteAll() {
await this.stopAll();
this.processes.clear();
await this.saveConfig();
console.log(chalk_1.default.green(`${figures_1.default.tick} All processes deleted`));
}
getProcess(id) {
return this.processes.get(id);
}
async dispose() {
if (this.isDisposed)
return;
this.isDisposed = true;
this.stopMonitoring();
// Close all log streams
for (const id of this.logStreams.keys()) {
this.closeLogStreams(id);
}
// Stop all processes
await this.stopAll();
}
}
exports.ProcessManager = ProcessManager;