UNPKG

node-server-orchestrator

Version:

CLI tool for orchestrating Node.js development servers (backend, frontend, databases, etc.)

374 lines (322 loc) 11.3 kB
import { spawn, ChildProcess } from 'child_process'; import * as http from 'http'; import * as path from 'path'; import * as fs from 'fs/promises'; import * as os from 'os'; import { ServerConfig, ServerInfo, ProjectConfig, StartServerResult, StopServerResult, ServerStatus } from './types'; export class ProjectServerManager { private servers: Map<string, ServerInfo> = new Map(); private projectConfigs: Map<string, ServerConfig> = new Map(); private readonly ALLOWED_COMMANDS = ['npm', 'node', 'yarn', 'pnpm', 'bun']; private readonly MAX_STARTUP_TIMEOUT = 60000; // 60 seconds max private readonly MAX_PORT = 65535; constructor() { this.loadDefaultConfigs(); } private validateServerId(serverId: string): boolean { // Only allow alphanumeric, hyphens, and underscores return /^[a-zA-Z0-9_-]+$/.test(serverId); } private validateServerConfig(config: ServerConfig): { valid: boolean; error?: string } { // Validate command if (!Array.isArray(config.command) || config.command.length === 0) { return { valid: false, error: 'Command must be a non-empty array' }; } // Validate command executable const executable = config.command[0]; if (!this.ALLOWED_COMMANDS.includes(executable)) { return { valid: false, error: `Command executable '${executable}' is not in allowed list: ${this.ALLOWED_COMMANDS.join(', ')}` }; } // Validate port if (!Number.isInteger(config.port) || config.port < 1 || config.port > this.MAX_PORT) { return { valid: false, error: `Port must be between 1 and ${this.MAX_PORT}` }; } // Validate timeout if (config.startupTimeout > this.MAX_STARTUP_TIMEOUT) { return { valid: false, error: `Startup timeout cannot exceed ${this.MAX_STARTUP_TIMEOUT}ms` }; } // Validate working directory if (!path.isAbsolute(config.cwd)) { return { valid: false, error: 'Working directory must be an absolute path' }; } // Validate health path if (!config.healthPath.startsWith('/')) { return { valid: false, error: 'Health path must start with /' }; } return { valid: true }; } private sanitizeConfigPath(configPath: string): string { // Prevent directory traversal const normalized = path.normalize(configPath); if (normalized.includes('..')) { throw new Error('Invalid config path: directory traversal not allowed'); } return normalized; } private loadDefaultConfigs(): void { // Default configuration that can be overridden by config file this.projectConfigs.set('example-backend', { name: 'Example Backend', type: 'backend', command: ['npm', 'run', 'dev'], cwd: process.cwd(), port: 3000, healthPath: '/health', startupTimeout: 10000, description: 'Example backend development server' }); this.projectConfigs.set('example-frontend', { name: 'Example Frontend', type: 'frontend', command: ['npm', 'start'], cwd: path.join(process.cwd(), 'frontend'), port: 3001, healthPath: '/', startupTimeout: 15000, description: 'Example frontend development server' }); } async loadConfigFromFile(configPath?: string): Promise<void> { const configFile = configPath ? this.sanitizeConfigPath(configPath) : path.join( os.homedir(), '.config', 'project-server-mcp', 'config.json' ); try { const configContent = await fs.readFile(configFile, 'utf-8'); const config: ProjectConfig = JSON.parse(configContent); // Clear default configs and load from file this.projectConfigs.clear(); for (const [serverId, serverConfig] of Object.entries(config.projects)) { // Validate server ID if (!this.validateServerId(serverId)) { console.warn(`⚠️ Skipping invalid server ID: ${serverId}`); continue; } // Validate server configuration const validation = this.validateServerConfig(serverConfig); if (!validation.valid) { console.warn(`⚠️ Skipping invalid configuration for ${serverId}: ${validation.error}`); continue; } this.projectConfigs.set(serverId, serverConfig); } console.log(`✅ Loaded configuration from ${configFile}`); } catch (error: any) { if (error.code === 'ENOENT') { console.log(`ℹ️ No configuration file found at ${configFile}, using defaults`); } else { console.error(`❌ Error loading configuration: ${error.message}`); } } } private async checkPortInUse(port: number, healthPath: string = '/'): Promise<boolean> { return new Promise((resolve) => { const req = http.request({ hostname: 'localhost', port: port, path: healthPath, method: 'GET', timeout: 1000 }, (res) => { resolve(res.statusCode === 200 || res.statusCode === 304); }); req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); req.end(); }); } private async waitForServerStart(serverId: string, config: ServerConfig): Promise<boolean> { const startTime = Date.now(); const serverInfo = this.servers.get(serverId); while (Date.now() - startTime < config.startupTimeout) { const isRunning = await this.checkPortInUse(config.port, config.healthPath); if (isRunning) { return true; } // Check if process is still alive if (serverInfo) { try { process.kill(serverInfo.pid, 0); } catch (err) { // Process died return false; } } await new Promise(resolve => setTimeout(resolve, 500)); } return false; } async startServer(serverId: string): Promise<StartServerResult> { const config = this.projectConfigs.get(serverId); if (!config) { return { success: false, message: `Unknown server: ${serverId}` }; } // Check if server is already running const isRunning = await this.checkPortInUse(config.port, config.healthPath); if (isRunning) { return { success: false, message: `${config.name} is already running on port ${config.port}`, alreadyRunning: true }; } // Start server - use shell: false to prevent command injection const serverProcess: ChildProcess = spawn(config.command[0], config.command.slice(1), { cwd: config.cwd, detached: true, stdio: 'ignore', shell: false // Security: prevent command injection }); this.servers.set(serverId, { pid: serverProcess.pid!, process: serverProcess, config: config, startedAt: new Date() }); // Wait for startup confirmation const started = await this.waitForServerStart(serverId, config); if (started) { serverProcess.unref(); return { success: true, message: `${config.name} started successfully`, pid: serverProcess.pid!, port: config.port }; } else { // Clean up failed process try { process.kill(serverProcess.pid!, 'SIGTERM'); } catch (err) { // Process already dead } this.servers.delete(serverId); return { success: false, message: `${config.name} failed to start within ${config.startupTimeout/1000} seconds`, startupFailed: true }; } } async stopServer(serverId: string): Promise<StopServerResult> { const serverInfo = this.servers.get(serverId); const config = this.projectConfigs.get(serverId); if (!serverInfo && !config) { return { success: false, message: `Unknown server: ${serverId}` }; } if (!serverInfo) { return { success: false, message: `${config?.name || serverId} is not managed by this process` }; } try { process.kill(serverInfo.pid, 'SIGTERM'); this.servers.delete(serverId); return { success: true, message: `${serverInfo.config.name} stopped successfully` }; } catch (err: any) { return { success: false, message: `Failed to stop ${serverInfo.config.name}: ${err.message}` }; } } async getServerStatus(serverId: string): Promise<ServerStatus> { const config = this.projectConfigs.get(serverId); if (!config) { return { status: 'unknown', message: `Unknown server: ${serverId}` }; } const isRunning = await this.checkPortInUse(config.port, config.healthPath); const serverInfo = this.servers.get(serverId); if (isRunning && serverInfo) { const uptime = Date.now() - serverInfo.startedAt.getTime(); return { status: 'running', pid: serverInfo.pid, port: config.port, startedAt: serverInfo.startedAt, uptime: uptime }; } else if (isRunning) { return { status: 'running_external', port: config.port, message: 'Server is running but not managed by this process' }; } else { return { status: 'stopped', port: config.port }; } } async startAllServers(): Promise<Record<string, StartServerResult>> { const results: Record<string, StartServerResult> = {}; for (const [serverId] of this.projectConfigs) { results[serverId] = await this.startServer(serverId); } return results; } async stopAllServers(): Promise<Record<string, StopServerResult>> { const results: Record<string, StopServerResult> = {}; for (const [serverId] of this.servers) { results[serverId] = await this.stopServer(serverId); } return results; } listServers(): string[] { return Array.from(this.projectConfigs.keys()); } getServerConfig(serverId: string): ServerConfig | undefined { return this.projectConfigs.get(serverId); } getAllServerConfigs(): Map<string, ServerConfig> { return new Map(this.projectConfigs); } private formatUptime(ms: number): string { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); if (hours > 0) { return `${hours}h ${minutes % 60}m`; } else if (minutes > 0) { return `${minutes}m ${seconds % 60}s`; } else { return `${seconds}s`; } } formatStatusMessage(status: ServerStatus, config: ServerConfig): string { switch (status.status) { case 'running': const uptimeStr = this.formatUptime(status.uptime!); return `🟢 ${config.name} is running\n📊 PID: ${status.pid}\n🌐 Port: ${status.port}\n⏰ Started: ${status.startedAt?.toLocaleTimeString()}\n⏱️ Uptime: ${uptimeStr}`; case 'running_external': return `🟡 ${config.name} is running externally\n🌐 Port: ${status.port}\n💡 Not managed by this process`; case 'stopped': return `🔴 ${config.name} is stopped\n🌐 Port: ${status.port}\n💡 Use start_server to start it`; default: return `❌ ${config.name}: ${status.message}`; } } }