/** * TypeScriptServerLauncher - Generic library for launching TypeScript servers locally * * Features: * - Configurable port selection with preferred port and fallback ranges * - Process management with PID tracking * - Automatic dependency installation and builds * - Watch mode support for development * - Retry logic for port conflicts (EADDRINUSE) * - Custom build hooks */ import { spawn, type ChildProcess } from 'node:child_process'; import path from 'node:path'; import { findAvailablePort, isPortAvailable, type FindAvailablePortOptions } from './findAvailablePort.js'; import { terminateProcessByPid, readPidFile, writePidFile, removePidFile, reclaimPort } from './ProcessManager.js'; import { checkAndInstallDependencies, buildTypeScript, runCustomPrepareScript } from './BuildOrchestrator.js'; export interface ServerLauncherConfig { /** * Project root directory (defaults to process.cwd()) */ projectRoot?: string; /** * Server directory relative to project root (e.g., 'backend') */ serverDir: string; /** * Command to start the server in development mode (e.g., ['npm', 'run', 'dev']) */ devCommand: string[]; /** * Command to start the server in production mode (e.g., ['npm', 'run', 'start']) */ startCommand: string[]; /** * Preferred port (will try this first) */ preferredPort?: number; /** * Minimum port for fallback search */ minPort?: number; /** * Maximum port for fallback search */ maxPort?: number; /** * Force reclaim the preferred port by terminating existing processes */ forcePort?: boolean; /** * Enable watch mode (auto-restart on file changes) */ watchMode?: boolean; /** * Custom environment variables */ env?: Record; /** * Maximum retries for EADDRINUSE errors */ maxRetries?: number; /** * PID file path (relative to serverDir, defaults to '.local-server.pid') */ pidFile?: string; /** * Build command (defaults to ['npm', 'run', 'build']) */ buildCommand?: string[]; /** * Skip dependency installation (defaults to false) */ skipDependencyInstall?: boolean; /** * Skip build step (defaults to false) */ skipBuild?: boolean; /** * Custom prepare script path (runs before build) */ prepareScriptPath?: string; /** * Hook called before build */ onBeforeBuild?: () => Promise | void; /** * Hook called after build */ onAfterBuild?: () => Promise | void; /** * Hook called when server starts successfully */ onServerStarted?: (port: number, pid: number) => Promise | void; /** * Hook called when server exits */ onServerExit?: (code: number | null) => Promise | void; } export class TypeScriptServerLauncher { private config: Required> & Pick; private serverProcess: ChildProcess | null = null; private currentPort: number | null = null; constructor(config: ServerLauncherConfig) { this.config = { projectRoot: config.projectRoot ?? process.cwd(), serverDir: config.serverDir, devCommand: config.devCommand, startCommand: config.startCommand, preferredPort: config.preferredPort ?? 2000, minPort: config.minPort ?? 2000, maxPort: config.maxPort ?? 2500, forcePort: config.forcePort ?? false, watchMode: config.watchMode ?? true, env: config.env ?? {}, maxRetries: config.maxRetries ?? 3, pidFile: config.pidFile ?? '.local-server.pid', buildCommand: config.buildCommand ?? ['npm', 'run', 'build'], skipDependencyInstall: config.skipDependencyInstall ?? false, skipBuild: config.skipBuild ?? false, onBeforeBuild: config.onBeforeBuild, onAfterBuild: config.onAfterBuild, onServerStarted: config.onServerStarted, onServerExit: config.onServerExit, prepareScriptPath: config.prepareScriptPath }; } private get serverDirPath(): string { return path.join(this.config.projectRoot, this.config.serverDir); } private get pidFilePath(): string { return path.join(this.serverDirPath, this.config.pidFile); } /** * Select an available port with preference and fallback logic */ private async selectPort(): Promise { const { preferredPort, forcePort, minPort, maxPort } = this.config; if (forcePort) { await reclaimPort(preferredPort); return preferredPort; } if (await isPortAvailable(preferredPort)) { return preferredPort; } console.warn( `Preferred port ${preferredPort} is not available. Falling back to random selection within the configured range.` ); // Calculate fallback range, ensuring minPort <= maxPort const fallbackMinPort = Math.max(preferredPort + 1, minPort); // If preferred port is at the upper bound, fall back to the full original range if (fallbackMinPort > maxPort) { const fallbackOptions: FindAvailablePortOptions = { minPort, maxPort }; return findAvailablePort(fallbackOptions); } const fallbackOptions: FindAvailablePortOptions = { minPort: fallbackMinPort, maxPort }; return findAvailablePort(fallbackOptions); } /** * Run the build pipeline */ private async runBuildPipeline(): Promise { const { prepareScriptPath } = this.config; const serverPath = this.serverDirPath; // Run custom prepare script if provided if (prepareScriptPath) { const fullScriptPath = path.join(this.config.projectRoot, prepareScriptPath); // Run from project root so relative paths in script work correctly runCustomPrepareScript(fullScriptPath, this.config.projectRoot); } // onBeforeBuild hook if (this.config.onBeforeBuild) { await this.config.onBeforeBuild(); } // Check and install dependencies if (!this.config.skipDependencyInstall) { checkAndInstallDependencies(serverPath); } // Build TypeScript if (!this.config.skipBuild) { buildTypeScript(serverPath, this.config.buildCommand); } // onAfterBuild hook if (this.config.onAfterBuild) { await this.config.onAfterBuild(); } } /** * Start the server process */ private async startServerProcess(port: number): Promise<{ success: boolean; isPortConflict: boolean }> { const { watchMode, env: customEnv, devCommand, startCommand } = this.config; const serverPath = this.serverDirPath; console.log(''); console.log('═══════════════════════════════════════════════════════════'); console.log(`🚀 Starting server on PORT ${port}`); if (watchMode) { console.log('🔄 Watch mode enabled - server will auto-restart on file changes'); } console.log(`📡 Server listening on 0.0.0.0:${port} (accessible via http://localhost:${port})`); console.log('═══════════════════════════════════════════════════════════'); console.log(''); // Build environment const env: NodeJS.ProcessEnv = { ...process.env, ...customEnv, NODE_ENV: watchMode ? 'development' : 'production', PORT: String(port) }; // Select command based on mode const command = watchMode ? devCommand : startCommand; const npmCommand = process.platform === 'win32' && command[0] === 'npm' ? 'npm.cmd' : command[0]; return new Promise<{ success: boolean; isPortConflict: boolean }>((resolve) => { let resolved = false; const safeResolve = (result: { success: boolean; isPortConflict: boolean }) => { if (!resolved) { resolved = true; resolve(result); } }; const child = spawn(npmCommand, command.slice(1), { cwd: serverPath, env, stdio: 'pipe' }); this.serverProcess = child; if (child.pid) { writePidFile(this.pidFilePath, child.pid); this.currentPort = port; } else { removePidFile(this.pidFilePath); } let isPortConflict = false; // Forward output to console child.stdout?.on('data', (data: Buffer) => { const text = data.toString(); process.stdout.write(text); }); child.stderr?.on('data', (data: Buffer) => { const text = data.toString(); process.stderr.write(text); // Detect EADDRINUSE in error output if (text.includes('EADDRINUSE') || text.includes('address already in use')) { isPortConflict = true; } }); const handleSignal = (signal: NodeJS.Signals): void => { child.kill(signal); }; process.on('SIGINT', handleSignal); process.on('SIGTERM', handleSignal); child.on('exit', async (code) => { removePidFile(this.pidFilePath); this.serverProcess = null; this.currentPort = null; process.off('SIGINT', handleSignal); process.off('SIGTERM', handleSignal); if (this.config.onServerExit) { await this.config.onServerExit(code); } // If exit code is 0 or null in watch mode, consider it success if (code === 0 || code === null) { safeResolve({ success: true, isPortConflict: false }); } else if (isPortConflict) { safeResolve({ success: false, isPortConflict: true }); } else { // Non-port-conflict error, don't retry safeResolve({ success: false, isPortConflict: false }); } }); child.on('error', (error) => { removePidFile(this.pidFilePath); this.serverProcess = null; this.currentPort = null; process.off('SIGINT', handleSignal); process.off('SIGTERM', handleSignal); console.error('Failed to start the server:', error); safeResolve({ success: false, isPortConflict: false }); }); // In watch mode, the process doesn't exit on success, so we consider it started after a delay if (watchMode) { setTimeout(async () => { if (!isPortConflict && child.exitCode === null) { if (this.config.onServerStarted && child.pid) { await this.config.onServerStarted(port, child.pid); } safeResolve({ success: true, isPortConflict: false }); } }, 3000); // Wait 3 seconds to see if EADDRINUSE occurs } else { // In production mode, verify process is still running before declaring success setTimeout(async () => { // Check process still alive and no port conflict detected if (!isPortConflict && child.exitCode === null && child.pid && !child.killed) { if (this.config.onServerStarted) { await this.config.onServerStarted(port, child.pid); } safeResolve({ success: true, isPortConflict: false }); } else if (!resolved) { // Process died or was killed during startup - fail safeResolve({ success: false, isPortConflict }); } }, 1000); } }); } /** * Launch the server with build pipeline and retry logic */ async launch(): Promise { try { // Terminate any existing server from previous run const previousPid = readPidFile(this.pidFilePath); if (previousPid) { console.log(` Terminating previous server process (PID ${previousPid})...`); const terminated = await terminateProcessByPid(previousPid); if (terminated) { console.log(` ✓ Terminated previous server process ${previousPid}`); await new Promise((resolve) => setTimeout(resolve, 2000)); } } // Run build pipeline await this.runBuildPipeline(); // Select initial port let port = await this.selectPort(); // Retry logic for EADDRINUSE errors const { maxRetries, watchMode } = this.config; let retryCount = 0; let serverStarted = false; while (retryCount < maxRetries && !serverStarted) { if (retryCount > 0) { console.warn(`⚠️ Port ${port} conflict detected. Retrying with a new port...`); // Select a new port for retry const nextMinPort = port + 1; const nextMaxPort = this.config.maxPort; if (nextMinPort > nextMaxPort) { throw new Error( `No valid port range available for retry (minPort: ${nextMinPort}, maxPort: ${nextMaxPort}).` ); } const fallbackOptions: FindAvailablePortOptions = { minPort: nextMinPort, maxPort: nextMaxPort }; port = await findAvailablePort(fallbackOptions); } const startResult = await this.startServerProcess(port); if (startResult.success) { serverStarted = true; // Keep process alive in watch mode if (watchMode) { await new Promise(() => {}); // Never resolves } } else if (startResult.isPortConflict && retryCount < maxRetries) { retryCount += 1; } else { // Non-retriable error or max retries reached throw new Error('Failed to start server: non-retriable error occurred'); } } if (!serverStarted) { throw new Error(`Failed to start server after ${maxRetries} attempts`); } } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Unable to start the server: ${message}`); } } /** * Stop the server if it's running */ async stop(): Promise { if (this.serverProcess && this.serverProcess.pid) { console.log(`Stopping server (PID ${this.serverProcess.pid})...`); await terminateProcessByPid(this.serverProcess.pid); this.serverProcess = null; this.currentPort = null; } removePidFile(this.pidFilePath); } /** * Get the current port the server is running on */ getPort(): number | null { return this.currentPort; } /** * Get the server process PID */ getPid(): number | null { return this.serverProcess?.pid ?? null; } }