node-server-orchestrator
Version:
CLI tool for orchestrating Node.js development servers (backend, frontend, databases, etc.)
374 lines (322 loc) • 11.3 kB
text/typescript
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}`;
}
}
}