Spaces:
Paused
Paused
| /** | |
| * Dev Server Service | |
| * | |
| * Manages multiple development server processes for git worktrees. | |
| * Each worktree can have its own dev server running on a unique port. | |
| * | |
| * Developers should configure their projects to use the PORT environment variable. | |
| */ | |
| import { spawn, execSync, type ChildProcess } from 'child_process'; | |
| import * as secureFs from '../lib/secure-fs.js'; | |
| import path from 'path'; | |
| import net from 'net'; | |
| import { createLogger } from '@automaker/utils'; | |
| import type { EventEmitter } from '../lib/events.js'; | |
| import fs from 'fs/promises'; | |
| import { constants } from 'fs'; | |
| const logger = createLogger('DevServerService'); | |
| // Maximum scrollback buffer size (characters) - matches TerminalService pattern | |
| const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server | |
| // Timeout (ms) before falling back to the allocated port if URL detection hasn't succeeded. | |
| // This handles cases where the dev server output format is not recognized by any pattern. | |
| const URL_DETECTION_TIMEOUT_MS = 30_000; | |
| // URL patterns for detecting full URLs from dev server output. | |
| // Defined once at module level to avoid reallocation on every call to detectUrlFromOutput. | |
| // Ordered from most specific (framework-specific) to least specific. | |
| const URL_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ | |
| // Vite / Nuxt / SvelteKit / Astro / Angular CLI format: "Local: http://..." | |
| { | |
| pattern: /(?:Local|Network|External):\s+(https?:\/\/[^\s]+)/i, | |
| description: 'Vite/Nuxt/SvelteKit/Astro/Angular format', | |
| }, | |
| // Next.js format: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000" | |
| // Next.js 14+: "▲ Next.js 14.0.0\n- Local: http://localhost:3000" | |
| { | |
| pattern: /(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, | |
| description: 'Next.js format', | |
| }, | |
| // Remix format: "started at http://localhost:3000" | |
| // Django format: "Starting development server at http://127.0.0.1:8000/" | |
| // Rails / Puma: "Listening on http://127.0.0.1:3000" | |
| // Generic: "listening at http://...", "available at http://...", "running at http://..." | |
| { | |
| pattern: | |
| /(?:starting|started|listening|running|available|serving|accessible)\s+(?:at|on)\s+(https?:\/\/[^\s,)]+)/i, | |
| description: 'Generic "starting/started/listening at" format', | |
| }, | |
| // PHP built-in server: "Development Server (http://localhost:8000) started" | |
| { | |
| pattern: /(?:server|development server)\s*\(\s*(https?:\/\/[^\s)]+)\s*\)/i, | |
| description: 'PHP server format', | |
| }, | |
| // Webpack Dev Server: "Project is running at http://localhost:8080/" | |
| { | |
| pattern: /(?:project|app|application)\s+(?:is\s+)?running\s+(?:at|on)\s+(https?:\/\/[^\s,]+)/i, | |
| description: 'Webpack/generic "running at" format', | |
| }, | |
| // Go / Rust / generic: "Serving on http://...", "Server on http://..." | |
| { | |
| pattern: /(?:serving|server)\s+(?:on|at)\s+(https?:\/\/[^\s,]+)/i, | |
| description: 'Generic "serving on" format', | |
| }, | |
| // Localhost URL with port (conservative - must be localhost/127.0.0.1/[::]/0.0.0.0) | |
| // This catches anything that looks like a dev server URL | |
| { | |
| pattern: /(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]|0\.0\.0\.0):\d+\S*)/i, | |
| description: 'Generic localhost URL with port', | |
| }, | |
| ]; | |
| // Port-only patterns for detecting port numbers from dev server output | |
| // when a full URL is not present in the output. | |
| // Defined once at module level to avoid reallocation on every call to detectUrlFromOutput. | |
| const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ | |
| // "listening on port 3000", "server on port 3000", "started on port 3000" | |
| { | |
| pattern: /(?:listening|running|started|serving|available)\s+on\s+port\s+(\d+)/i, | |
| description: '"listening on port" format', | |
| }, | |
| // "Port: 3000", "port 3000" (at start of line or after whitespace) | |
| { | |
| pattern: /(?:^|\s)port[:\s]+(\d{4,5})(?:\s|$|[.,;])/im, | |
| description: '"port:" format', | |
| }, | |
| ]; | |
| // Throttle output to prevent overwhelming WebSocket under heavy load. | |
| // 100ms (~10fps) is sufficient for readable log streaming while keeping | |
| // WebSocket traffic manageable. The previous 4ms rate (~250fps) generated | |
| // up to 250 events/sec which caused progressive browser slowdown from | |
| // accumulated console logs, JSON serialization overhead, and React re-renders. | |
| const OUTPUT_THROTTLE_MS = 100; // ~10fps max update rate | |
| const OUTPUT_BATCH_SIZE = 8192; // Larger batches to compensate for lower frequency | |
| export interface DevServerInfo { | |
| worktreePath: string; | |
| /** The port originally reserved by findAvailablePort() – never mutated after startDevServer sets it */ | |
| allocatedPort: number; | |
| port: number; | |
| url: string; | |
| process: ChildProcess | null; | |
| startedAt: Date; | |
| // Scrollback buffer for log history (replay on reconnect) | |
| scrollbackBuffer: string; | |
| // Pending output to be flushed to subscribers | |
| outputBuffer: string; | |
| // Throttle timer for batching output | |
| flushTimeout: NodeJS.Timeout | null; | |
| // Flag to indicate server is stopping (prevents output after stop) | |
| stopping: boolean; | |
| // Flag to indicate if URL has been detected from output | |
| urlDetected: boolean; | |
| // Timer for URL detection timeout fallback | |
| urlDetectionTimeout: NodeJS.Timeout | null; | |
| // Custom command used to start the server | |
| customCommand?: string; | |
| } | |
| /** | |
| * Persistable subset of DevServerInfo for survival across server restarts | |
| */ | |
| interface PersistedDevServerInfo { | |
| worktreePath: string; | |
| allocatedPort: number; | |
| port: number; | |
| url: string; | |
| startedAt: string; | |
| urlDetected: boolean; | |
| customCommand?: string; | |
| } | |
| // Port allocation starts at 3001 to avoid conflicts with common dev ports | |
| const BASE_PORT = 3001; | |
| const MAX_PORT = 3099; // Safety limit | |
| // Common livereload ports that may need cleanup when stopping dev servers | |
| const LIVERELOAD_PORTS = [35729, 35730, 35731] as const; | |
| class DevServerService { | |
| private runningServers: Map<string, DevServerInfo> = new Map(); | |
| private startingServers: Set<string> = new Set(); | |
| private allocatedPorts: Set<number> = new Set(); | |
| private emitter: EventEmitter | null = null; | |
| private dataDir: string | null = null; | |
| private saveQueue: Promise<void> = Promise.resolve(); | |
| /** | |
| * Initialize the service with data directory for persistence | |
| */ | |
| async initialize(dataDir: string, emitter: EventEmitter): Promise<void> { | |
| this.dataDir = dataDir; | |
| this.emitter = emitter; | |
| await this.loadState(); | |
| } | |
| /** | |
| * Set the event emitter for streaming log events | |
| * Called during service initialization with the global event emitter | |
| */ | |
| setEventEmitter(emitter: EventEmitter): void { | |
| this.emitter = emitter; | |
| } | |
| /** | |
| * Save the current state of running servers to disk | |
| */ | |
| private async saveState(): Promise<void> { | |
| if (!this.dataDir) return; | |
| // Queue the save operation to prevent concurrent writes | |
| this.saveQueue = this.saveQueue | |
| .then(async () => { | |
| if (!this.dataDir) return; | |
| try { | |
| const statePath = path.join(this.dataDir, 'dev-servers.json'); | |
| const persistedInfo: PersistedDevServerInfo[] = Array.from( | |
| this.runningServers.values() | |
| ).map((s) => ({ | |
| worktreePath: s.worktreePath, | |
| allocatedPort: s.allocatedPort, | |
| port: s.port, | |
| url: s.url, | |
| startedAt: s.startedAt.toISOString(), | |
| urlDetected: s.urlDetected, | |
| customCommand: s.customCommand, | |
| })); | |
| await fs.writeFile(statePath, JSON.stringify(persistedInfo, null, 2)); | |
| logger.debug(`Saved dev server state to ${statePath}`); | |
| } catch (error) { | |
| logger.error('Failed to save dev server state:', error); | |
| } | |
| }) | |
| .catch((error) => { | |
| logger.error('Error in save queue:', error); | |
| }); | |
| return this.saveQueue; | |
| } | |
| /** | |
| * Load the state of running servers from disk | |
| */ | |
| private async loadState(): Promise<void> { | |
| if (!this.dataDir) return; | |
| try { | |
| const statePath = path.join(this.dataDir, 'dev-servers.json'); | |
| try { | |
| await fs.access(statePath, constants.F_OK); | |
| } catch { | |
| // File doesn't exist, which is fine | |
| return; | |
| } | |
| const content = await fs.readFile(statePath, 'utf-8'); | |
| const rawParsed: unknown = JSON.parse(content); | |
| if (!Array.isArray(rawParsed)) { | |
| logger.warn('Dev server state file is not an array, skipping load'); | |
| return; | |
| } | |
| const persistedInfo: PersistedDevServerInfo[] = rawParsed.filter((entry: unknown) => { | |
| if (entry === null || typeof entry !== 'object') { | |
| logger.warn('Dropping invalid dev server entry (not an object):', entry); | |
| return false; | |
| } | |
| const e = entry as Record<string, unknown>; | |
| const valid = | |
| typeof e.worktreePath === 'string' && | |
| e.worktreePath.length > 0 && | |
| typeof e.allocatedPort === 'number' && | |
| Number.isInteger(e.allocatedPort) && | |
| e.allocatedPort >= 1 && | |
| e.allocatedPort <= 65535 && | |
| typeof e.port === 'number' && | |
| Number.isInteger(e.port) && | |
| e.port >= 1 && | |
| e.port <= 65535 && | |
| typeof e.url === 'string' && | |
| typeof e.startedAt === 'string' && | |
| typeof e.urlDetected === 'boolean' && | |
| (e.customCommand === undefined || typeof e.customCommand === 'string'); | |
| if (!valid) { | |
| logger.warn('Dropping malformed dev server entry:', e); | |
| } | |
| return valid; | |
| }) as PersistedDevServerInfo[]; | |
| logger.info(`Loading ${persistedInfo.length} dev servers from state`); | |
| for (const info of persistedInfo) { | |
| // Check if the process is still running on the port | |
| // Since we can't reliably re-attach to the process for output, | |
| // we'll just check if the port is in use. | |
| const portInUse = !(await this.isPortAvailable(info.port)); | |
| if (portInUse) { | |
| logger.info(`Re-attached to dev server on port ${info.port} for ${info.worktreePath}`); | |
| const serverInfo: DevServerInfo = { | |
| ...info, | |
| startedAt: new Date(info.startedAt), | |
| process: null, // Process object is lost, but we know it's running | |
| scrollbackBuffer: '', | |
| outputBuffer: '', | |
| flushTimeout: null, | |
| stopping: false, | |
| urlDetectionTimeout: null, | |
| }; | |
| this.runningServers.set(info.worktreePath, serverInfo); | |
| this.allocatedPorts.add(info.allocatedPort); | |
| } else { | |
| logger.info( | |
| `Dev server on port ${info.port} for ${info.worktreePath} is no longer running` | |
| ); | |
| } | |
| } | |
| // Cleanup stale entries from the file if any | |
| if (this.runningServers.size !== persistedInfo.length) { | |
| await this.saveState(); | |
| } | |
| } catch (error) { | |
| logger.error('Failed to load dev server state:', error); | |
| } | |
| } | |
| /** | |
| * Prune a stale server entry whose process has exited without cleanup. | |
| * Clears any pending timers, removes the port from allocatedPorts, deletes | |
| * the entry from runningServers, and emits the "dev-server:stopped" event | |
| * so all callers consistently notify the frontend when pruning entries. | |
| * | |
| * @param worktreePath - The key used in runningServers | |
| * @param server - The DevServerInfo entry to prune | |
| */ | |
| private pruneStaleServer(worktreePath: string, server: DevServerInfo): void { | |
| if (server.flushTimeout) clearTimeout(server.flushTimeout); | |
| if (server.urlDetectionTimeout) clearTimeout(server.urlDetectionTimeout); | |
| // Use allocatedPort (immutable) to free the reserved slot; server.port may have | |
| // been mutated by detectUrlFromOutput to reflect the actual detected port. | |
| this.allocatedPorts.delete(server.allocatedPort); | |
| this.runningServers.delete(worktreePath); | |
| // Persist state change | |
| this.saveState().catch((err) => logger.error('Failed to save state in pruneStaleServer:', err)); | |
| if (this.emitter) { | |
| this.emitter.emit('dev-server:stopped', { | |
| worktreePath, | |
| port: server.port, // Report the externally-visible (detected) port | |
| exitCode: server.process?.exitCode ?? null, | |
| timestamp: new Date().toISOString(), | |
| }); | |
| } | |
| } | |
| /** | |
| * Append data to scrollback buffer with size limit enforcement | |
| * Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE | |
| */ | |
| private appendToScrollback(server: DevServerInfo, data: string): void { | |
| server.scrollbackBuffer += data; | |
| if (server.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) { | |
| server.scrollbackBuffer = server.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE); | |
| } | |
| } | |
| /** | |
| * Flush buffered output to WebSocket subscribers | |
| * Sends batched output to prevent overwhelming clients under heavy load | |
| */ | |
| private flushOutput(server: DevServerInfo): void { | |
| // Skip flush if server is stopping or buffer is empty | |
| if (server.stopping || server.outputBuffer.length === 0) { | |
| server.flushTimeout = null; | |
| return; | |
| } | |
| let dataToSend = server.outputBuffer; | |
| if (dataToSend.length > OUTPUT_BATCH_SIZE) { | |
| // Send in batches if buffer is large | |
| dataToSend = server.outputBuffer.slice(0, OUTPUT_BATCH_SIZE); | |
| server.outputBuffer = server.outputBuffer.slice(OUTPUT_BATCH_SIZE); | |
| // Schedule another flush for remaining data | |
| server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS); | |
| } else { | |
| server.outputBuffer = ''; | |
| server.flushTimeout = null; | |
| } | |
| // Emit output event for WebSocket streaming | |
| if (this.emitter) { | |
| this.emitter.emit('dev-server:output', { | |
| worktreePath: server.worktreePath, | |
| content: dataToSend, | |
| timestamp: new Date().toISOString(), | |
| }); | |
| } | |
| } | |
| /** | |
| * Strip ANSI escape codes from a string | |
| * Dev server output often contains color codes that can interfere with URL detection | |
| */ | |
| private stripAnsi(str: string): string { | |
| // Matches ANSI escape sequences: CSI sequences, OSC sequences, and simple escapes | |
| // eslint-disable-next-line no-control-regex | |
| return str.replace(/\x1B(?:\[[0-9;]*[a-zA-Z]|\].*?(?:\x07|\x1B\\)|\[[?]?[0-9;]*[hl])/g, ''); | |
| } | |
| /** | |
| * Extract port number from a URL string. | |
| * Returns the explicit port if present, or null if no port is specified. | |
| * Default protocol ports (80/443) are intentionally NOT returned to avoid | |
| * overwriting allocated dev server ports with protocol defaults. | |
| */ | |
| private extractPortFromUrl(url: string): number | null { | |
| try { | |
| const parsed = new URL(url); | |
| if (parsed.port) { | |
| return parseInt(parsed.port, 10); | |
| } | |
| return null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| /** | |
| * Detect actual server URL from output | |
| * Parses stdout/stderr for common URL patterns from dev servers. | |
| * | |
| * Supports detection of URLs from: | |
| * - Vite: "Local: http://localhost:5173/" | |
| * - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000" | |
| * - Nuxt: "Local: http://localhost:3000/" | |
| * - Remix: "started at http://localhost:3000" | |
| * - Astro: "Local http://localhost:4321/" | |
| * - SvelteKit: "Local: http://localhost:5173/" | |
| * - CRA/Webpack: "On Your Network: http://192.168.1.1:3000" | |
| * - Angular: "Local: http://localhost:4200/" | |
| * - Express/Fastify/Koa: "Server listening on port 3000" | |
| * - Django: "Starting development server at http://127.0.0.1:8000/" | |
| * - Rails: "Listening on http://127.0.0.1:3000" | |
| * - PHP: "Development Server (http://localhost:8000) started" | |
| * - Generic: Any localhost URL with a port | |
| */ | |
| private async detectUrlFromOutput(server: DevServerInfo, content: string): Promise<void> { | |
| // Skip if URL already detected | |
| if (server.urlDetected) { | |
| return; | |
| } | |
| // Strip ANSI escape codes to prevent color codes from breaking regex matching | |
| const cleanContent = this.stripAnsi(content); | |
| // Phase 1: Try to detect a full URL from output | |
| // Patterns are defined at module level (URL_PATTERNS) and reused across calls | |
| for (const { pattern, description } of URL_PATTERNS) { | |
| const match = cleanContent.match(pattern); | |
| if (match && match[1]) { | |
| let detectedUrl = match[1].trim(); | |
| // Remove trailing punctuation that might have been captured | |
| detectedUrl = detectedUrl.replace(/[.,;:!?)\]}>]+$/, ''); | |
| if (detectedUrl.startsWith('http://') || detectedUrl.startsWith('https://')) { | |
| // Normalize 0.0.0.0 to localhost for browser accessibility | |
| detectedUrl = detectedUrl.replace( | |
| /\/\/0\.0\.0\.0(:\d+)?/, | |
| (_, port) => `//localhost${port || ''}` | |
| ); | |
| // Normalize [::] to localhost for browser accessibility | |
| detectedUrl = detectedUrl.replace( | |
| /\/\/\[::\](:\d+)?/, | |
| (_, port) => `//localhost${port || ''}` | |
| ); | |
| // Normalize [::1] (IPv6 loopback) to localhost for browser accessibility | |
| detectedUrl = detectedUrl.replace( | |
| /\/\/\[::1\](:\d+)?/, | |
| (_, port) => `//localhost${port || ''}` | |
| ); | |
| server.url = detectedUrl; | |
| server.urlDetected = true; | |
| // Clear the URL detection timeout since we found the URL | |
| if (server.urlDetectionTimeout) { | |
| clearTimeout(server.urlDetectionTimeout); | |
| server.urlDetectionTimeout = null; | |
| } | |
| // Update the port to match the detected URL's actual port | |
| const detectedPort = this.extractPortFromUrl(detectedUrl); | |
| if (detectedPort && detectedPort !== server.port) { | |
| logger.info( | |
| `Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}` | |
| ); | |
| server.port = detectedPort; | |
| } | |
| logger.info(`Detected server URL via ${description}: ${detectedUrl}`); | |
| // Persist state change | |
| await this.saveState().catch((err) => | |
| logger.error('Failed to save state in detectUrlFromOutput:', err) | |
| ); | |
| // Emit URL update event | |
| if (this.emitter) { | |
| this.emitter.emit('dev-server:url-detected', { | |
| worktreePath: server.worktreePath, | |
| url: detectedUrl, | |
| port: server.port, | |
| timestamp: new Date().toISOString(), | |
| }); | |
| } | |
| return; | |
| } | |
| } | |
| } | |
| // Phase 2: Try to detect just a port number from output (no full URL) | |
| // Some servers only print "listening on port 3000" without a full URL | |
| // Patterns are defined at module level (PORT_PATTERNS) and reused across calls | |
| for (const { pattern, description } of PORT_PATTERNS) { | |
| const match = cleanContent.match(pattern); | |
| if (match && match[1]) { | |
| const detectedPort = parseInt(match[1], 10); | |
| // Sanity check: port should be in a reasonable range | |
| if (detectedPort > 0 && detectedPort <= 65535) { | |
| const detectedUrl = `http://localhost:${detectedPort}`; | |
| server.url = detectedUrl; | |
| server.urlDetected = true; | |
| // Clear the URL detection timeout since we found the port | |
| if (server.urlDetectionTimeout) { | |
| clearTimeout(server.urlDetectionTimeout); | |
| server.urlDetectionTimeout = null; | |
| } | |
| if (detectedPort !== server.port) { | |
| logger.info( | |
| `Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}` | |
| ); | |
| server.port = detectedPort; | |
| } | |
| logger.info(`Detected server port via ${description}: ${detectedPort} → ${detectedUrl}`); | |
| // Persist state change | |
| await this.saveState().catch((err) => | |
| logger.error('Failed to save state in detectUrlFromOutput Phase 2:', err) | |
| ); | |
| // Emit URL update event | |
| if (this.emitter) { | |
| this.emitter.emit('dev-server:url-detected', { | |
| worktreePath: server.worktreePath, | |
| url: detectedUrl, | |
| port: server.port, | |
| timestamp: new Date().toISOString(), | |
| }); | |
| } | |
| return; | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Handle incoming stdout/stderr data from dev server process | |
| * Buffers data for scrollback replay and schedules throttled emission | |
| */ | |
| private async handleProcessOutput(server: DevServerInfo, data: Buffer): Promise<void> { | |
| // Skip output if server is stopping | |
| if (server.stopping) { | |
| return; | |
| } | |
| const content = data.toString(); | |
| // Try to detect actual server URL from output | |
| await this.detectUrlFromOutput(server, content); | |
| // Append to scrollback buffer for replay on reconnect | |
| this.appendToScrollback(server, content); | |
| // Buffer output for throttled live delivery | |
| server.outputBuffer += content; | |
| // Schedule flush if not already scheduled | |
| if (!server.flushTimeout) { | |
| server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS); | |
| } | |
| // Also log for debugging (existing behavior) | |
| logger.debug(`[Port${server.port}] ${content.trim()}`); | |
| } | |
| /** | |
| * Check if a port is available (not in use by system or by us) | |
| */ | |
| private async isPortAvailable(port: number): Promise<boolean> { | |
| // First check if we've already allocated it | |
| if (this.allocatedPorts.has(port)) { | |
| return false; | |
| } | |
| // Then check if the system has it in use | |
| return new Promise((resolve) => { | |
| const server = net.createServer(); | |
| server.once('error', () => resolve(false)); | |
| server.once('listening', () => { | |
| server.close(); | |
| resolve(true); | |
| }); | |
| server.listen(port, '127.0.0.1'); | |
| }); | |
| } | |
| /** | |
| * Kill any process running on the given port | |
| */ | |
| private killProcessOnPort(port: number): void { | |
| try { | |
| if (process.platform === 'win32') { | |
| // Windows: find and kill process on port | |
| const result = execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf-8' }); | |
| const lines = result.trim().split('\n'); | |
| const pids = new Set<string>(); | |
| for (const line of lines) { | |
| const parts = line.trim().split(/\s+/); | |
| const pid = parts[parts.length - 1]; | |
| if (pid && pid !== '0') { | |
| pids.add(pid); | |
| } | |
| } | |
| for (const pid of pids) { | |
| try { | |
| execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' }); | |
| logger.debug(`Killed process ${pid} on port ${port}`); | |
| } catch { | |
| // Process may have already exited | |
| } | |
| } | |
| } else { | |
| // macOS/Linux: use lsof to find and kill process | |
| try { | |
| const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8' }); | |
| const pids = result.trim().split('\n').filter(Boolean); | |
| for (const pid of pids) { | |
| try { | |
| execSync(`kill -9 ${pid}`, { stdio: 'ignore' }); | |
| logger.debug(`Killed process ${pid} on port ${port}`); | |
| } catch { | |
| // Process may have already exited | |
| } | |
| } | |
| } catch { | |
| // No process found on port, which is fine | |
| } | |
| } | |
| } catch { | |
| // Ignore errors - port might not have any process | |
| logger.debug(`No process to kill on port ${port}`); | |
| } | |
| } | |
| /** | |
| * Find the next available port, killing any process on it first | |
| */ | |
| private async findAvailablePort(): Promise<number> { | |
| let port = BASE_PORT; | |
| while (port <= MAX_PORT) { | |
| // Skip ports we've already allocated internally | |
| if (this.allocatedPorts.has(port)) { | |
| port++; | |
| continue; | |
| } | |
| // Force kill any process on this port before checking availability | |
| // This ensures we can claim the port even if something stale is holding it | |
| this.killProcessOnPort(port); | |
| // Small delay to let the port be released | |
| await new Promise((resolve) => setTimeout(resolve, 100)); | |
| // Now check if it's available | |
| if (await this.isPortAvailable(port)) { | |
| return port; | |
| } | |
| port++; | |
| } | |
| throw new Error(`No available ports found between ${BASE_PORT} and ${MAX_PORT}`); | |
| } | |
| /** | |
| * Helper to check if a file exists using secureFs | |
| */ | |
| private async fileExists(filePath: string): Promise<boolean> { | |
| try { | |
| await secureFs.access(filePath); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| /** | |
| * Detect the package manager used in a directory | |
| */ | |
| private async detectPackageManager(dir: string): Promise<'npm' | 'yarn' | 'pnpm' | 'bun' | null> { | |
| if (await this.fileExists(path.join(dir, 'bun.lockb'))) return 'bun'; | |
| if (await this.fileExists(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm'; | |
| if (await this.fileExists(path.join(dir, 'yarn.lock'))) return 'yarn'; | |
| if (await this.fileExists(path.join(dir, 'package-lock.json'))) return 'npm'; | |
| if (await this.fileExists(path.join(dir, 'package.json'))) return 'npm'; // Default | |
| return null; | |
| } | |
| /** | |
| * Get the dev script command for a directory | |
| */ | |
| private async getDevCommand(dir: string): Promise<{ cmd: string; args: string[] } | null> { | |
| const pm = await this.detectPackageManager(dir); | |
| if (!pm) return null; | |
| switch (pm) { | |
| case 'bun': | |
| return { cmd: 'bun', args: ['run', 'dev'] }; | |
| case 'pnpm': | |
| return { cmd: 'pnpm', args: ['run', 'dev'] }; | |
| case 'yarn': | |
| return { cmd: 'yarn', args: ['dev'] }; | |
| case 'npm': | |
| default: | |
| return { cmd: 'npm', args: ['run', 'dev'] }; | |
| } | |
| } | |
| /** | |
| * Parse a custom command string into cmd and args | |
| * Handles quoted strings with spaces (e.g., "my command" arg1 arg2) | |
| */ | |
| private parseCustomCommand(command: string): { cmd: string; args: string[] } { | |
| const tokens: string[] = []; | |
| let current = ''; | |
| let inQuote = false; | |
| let quoteChar = ''; | |
| for (let i = 0; i < command.length; i++) { | |
| const char = command[i]; | |
| if (inQuote) { | |
| if (char === quoteChar) { | |
| inQuote = false; | |
| } else { | |
| current += char; | |
| } | |
| } else if (char === '"' || char === "'") { | |
| inQuote = true; | |
| quoteChar = char; | |
| } else if (char === ' ') { | |
| if (current) { | |
| tokens.push(current); | |
| current = ''; | |
| } | |
| } else { | |
| current += char; | |
| } | |
| } | |
| if (current) { | |
| tokens.push(current); | |
| } | |
| const [cmd, ...args] = tokens; | |
| return { cmd: cmd || '', args }; | |
| } | |
| /** | |
| * Start a dev server for a worktree | |
| * @param projectPath - The project root path | |
| * @param worktreePath - The worktree directory path | |
| * @param customCommand - Optional custom command to run instead of auto-detected dev command | |
| */ | |
| async startDevServer( | |
| projectPath: string, | |
| worktreePath: string, | |
| customCommand?: string | |
| ): Promise<{ | |
| success: boolean; | |
| result?: { | |
| worktreePath: string; | |
| port: number; | |
| url: string; | |
| message: string; | |
| }; | |
| error?: string; | |
| }> { | |
| // Check if already running or starting | |
| if (this.runningServers.has(worktreePath) || this.startingServers.has(worktreePath)) { | |
| const existing = this.runningServers.get(worktreePath); | |
| if (existing) { | |
| return { | |
| success: true, | |
| result: { | |
| worktreePath: existing.worktreePath, | |
| port: existing.port, | |
| url: existing.url, | |
| message: `Dev server already running on port ${existing.port}`, | |
| }, | |
| }; | |
| } | |
| return { | |
| success: false, | |
| error: 'Dev server is already starting', | |
| }; | |
| } | |
| this.startingServers.add(worktreePath); | |
| try { | |
| // Verify the worktree exists | |
| if (!(await this.fileExists(worktreePath))) { | |
| return { | |
| success: false, | |
| error: `Worktree path does not exist: ${worktreePath}`, | |
| }; | |
| } | |
| // Determine the dev command to use | |
| let devCommand: { cmd: string; args: string[] }; | |
| // Normalize custom command: trim whitespace and treat empty strings as undefined | |
| const normalizedCustomCommand = customCommand?.trim(); | |
| if (normalizedCustomCommand) { | |
| // Use the provided custom command | |
| devCommand = this.parseCustomCommand(normalizedCustomCommand); | |
| if (!devCommand.cmd) { | |
| return { | |
| success: false, | |
| error: 'Invalid custom command: command cannot be empty', | |
| }; | |
| } | |
| logger.debug(`Using custom command: ${normalizedCustomCommand}`); | |
| } else { | |
| // Check for package.json when auto-detecting | |
| const packageJsonPath = path.join(worktreePath, 'package.json'); | |
| if (!(await this.fileExists(packageJsonPath))) { | |
| return { | |
| success: false, | |
| error: `No package.json found in: ${worktreePath}`, | |
| }; | |
| } | |
| // Get dev command from package manager detection | |
| const detectedCommand = await this.getDevCommand(worktreePath); | |
| if (!detectedCommand) { | |
| return { | |
| success: false, | |
| error: `Could not determine dev command for: ${worktreePath}`, | |
| }; | |
| } | |
| devCommand = detectedCommand; | |
| } | |
| // Find available port | |
| let port: number; | |
| try { | |
| port = await this.findAvailablePort(); | |
| } catch (error) { | |
| return { | |
| success: false, | |
| error: error instanceof Error ? error.message : 'Port allocation failed', | |
| }; | |
| } | |
| // Reserve the port (port was already force-killed in findAvailablePort) | |
| this.allocatedPorts.add(port); | |
| // Also kill common related ports (livereload, etc.) | |
| // Some dev servers use fixed ports for HMR/livereload regardless of main port | |
| for (const relatedPort of LIVERELOAD_PORTS) { | |
| this.killProcessOnPort(relatedPort); | |
| } | |
| // Small delay to ensure related ports are freed | |
| await new Promise((resolve) => setTimeout(resolve, 100)); | |
| logger.info(`Starting dev server on port ${port}`); | |
| logger.debug(`Working directory (cwd): ${worktreePath}`); | |
| logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`); | |
| // Emit starting only after preflight checks pass to avoid dangling starting state. | |
| if (this.emitter) { | |
| this.emitter.emit('dev-server:starting', { | |
| worktreePath, | |
| timestamp: new Date().toISOString(), | |
| }); | |
| } | |
| // Spawn the dev process with PORT environment variable | |
| // FORCE_COLOR enables colored output even when not running in a TTY | |
| const env = { | |
| ...process.env, | |
| PORT: String(port), | |
| FORCE_COLOR: '1', | |
| // Some tools use these additional env vars for color detection | |
| COLORTERM: 'truecolor', | |
| TERM: 'xterm-256color', | |
| }; | |
| const devProcess = spawn(devCommand.cmd, devCommand.args, { | |
| cwd: worktreePath, | |
| env, | |
| stdio: ['ignore', 'pipe', 'pipe'], | |
| detached: false, | |
| }); | |
| // Track if process failed early using object to work around TypeScript narrowing | |
| const status = { error: null as string | null, exited: false }; | |
| // Create server info early so we can reference it in handlers | |
| // We'll add it to runningServers after verifying the process started successfully | |
| const fallbackHost = 'localhost'; | |
| const serverInfo: DevServerInfo = { | |
| worktreePath, | |
| allocatedPort: port, // Immutable: records which port we reserved; never changed after this point | |
| port, | |
| url: `http://${fallbackHost}:${port}`, // Initial URL, may be updated by detectUrlFromOutput | |
| process: devProcess, | |
| startedAt: new Date(), | |
| scrollbackBuffer: '', | |
| outputBuffer: '', | |
| flushTimeout: null, | |
| stopping: false, | |
| urlDetected: false, // Will be set to true when actual URL is detected from output | |
| urlDetectionTimeout: null, // Will be set after server starts successfully | |
| customCommand: normalizedCustomCommand, | |
| }; | |
| // Capture stdout with buffer management and event emission | |
| if (devProcess.stdout) { | |
| devProcess.stdout.on('data', (data: Buffer) => { | |
| this.handleProcessOutput(serverInfo, data).catch((error: unknown) => { | |
| logger.error('Failed to handle dev server stdout output:', error); | |
| }); | |
| }); | |
| } | |
| // Capture stderr with buffer management and event emission | |
| if (devProcess.stderr) { | |
| devProcess.stderr.on('data', (data: Buffer) => { | |
| this.handleProcessOutput(serverInfo, data).catch((error: unknown) => { | |
| logger.error('Failed to handle dev server stderr output:', error); | |
| }); | |
| }); | |
| } | |
| // Helper to clean up resources and emit stop event | |
| const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => { | |
| if (serverInfo.flushTimeout) { | |
| clearTimeout(serverInfo.flushTimeout); | |
| serverInfo.flushTimeout = null; | |
| } | |
| // Clear URL detection timeout to prevent stale fallback emission | |
| if (serverInfo.urlDetectionTimeout) { | |
| clearTimeout(serverInfo.urlDetectionTimeout); | |
| serverInfo.urlDetectionTimeout = null; | |
| } | |
| // Emit stopped event (only if not already stopping - prevents duplicate events) | |
| if (this.emitter && !serverInfo.stopping) { | |
| this.emitter.emit('dev-server:stopped', { | |
| worktreePath, | |
| port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it) | |
| exitCode, | |
| error: errorMessage, | |
| timestamp: new Date().toISOString(), | |
| }); | |
| } | |
| this.allocatedPorts.delete(serverInfo.allocatedPort); | |
| this.runningServers.delete(worktreePath); | |
| // Persist state change | |
| this.saveState().catch((err) => logger.error('Failed to save state in cleanup:', err)); | |
| }; | |
| devProcess.on('error', (error) => { | |
| logger.error(`Process error:`, error); | |
| status.error = error.message; | |
| cleanupAndEmitStop(null, error.message); | |
| }); | |
| devProcess.on('exit', (code) => { | |
| logger.info(`Process for ${worktreePath} exited with code ${code}`); | |
| status.exited = true; | |
| cleanupAndEmitStop(code); | |
| }); | |
| // Wait a moment to see if the process fails immediately | |
| await new Promise((resolve) => setTimeout(resolve, 500)); | |
| if (status.error) { | |
| return { | |
| success: false, | |
| error: `Failed to start dev server: ${status.error}`, | |
| }; | |
| } | |
| if (status.exited) { | |
| return { | |
| success: false, | |
| error: `Dev server process exited immediately. Check server logs for details.`, | |
| }; | |
| } | |
| // Server started successfully - add to running servers map | |
| this.runningServers.set(worktreePath, serverInfo); | |
| // Persist state change | |
| await this.saveState().catch((err) => | |
| logger.error('Failed to save state in startDevServer:', err) | |
| ); | |
| // Emit started event for WebSocket subscribers | |
| if (this.emitter) { | |
| this.emitter.emit('dev-server:started', { | |
| worktreePath, | |
| port, | |
| url: serverInfo.url, | |
| timestamp: new Date().toISOString(), | |
| }); | |
| } | |
| // Set up URL detection timeout fallback. | |
| // If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if | |
| // the allocated port is actually in use (server probably started successfully) | |
| // and emit a url-detected event with the allocated port as fallback. | |
| // Also re-scan the scrollback buffer in case the URL was printed before | |
| // our patterns could match (e.g., it was split across multiple data chunks). | |
| serverInfo.urlDetectionTimeout = setTimeout(async () => { | |
| serverInfo.urlDetectionTimeout = null; | |
| // Only run fallback if server is still running and URL wasn't detected | |
| if ( | |
| serverInfo.stopping || | |
| serverInfo.urlDetected || | |
| !this.runningServers.has(worktreePath) | |
| ) { | |
| return; | |
| } | |
| // Re-scan the entire scrollback buffer for URL patterns | |
| // This catches cases where the URL was split across multiple output chunks | |
| logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`); | |
| await this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer).catch((err) => | |
| logger.error('Failed to re-scan scrollback buffer:', err) | |
| ); | |
| // If still not detected after full rescan, use the allocated port as fallback | |
| if (!serverInfo.urlDetected) { | |
| logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`); | |
| const fallbackUrl = `http://${fallbackHost}:${port}`; | |
| serverInfo.url = fallbackUrl; | |
| serverInfo.urlDetected = true; | |
| // Persist state change | |
| await this.saveState().catch((err) => | |
| logger.error('Failed to save state in URL detection fallback:', err) | |
| ); | |
| if (this.emitter) { | |
| this.emitter.emit('dev-server:url-detected', { | |
| worktreePath: serverInfo.worktreePath, | |
| url: fallbackUrl, | |
| port, | |
| timestamp: new Date().toISOString(), | |
| }); | |
| } | |
| } | |
| }, URL_DETECTION_TIMEOUT_MS); | |
| return { | |
| success: true, | |
| result: { | |
| worktreePath: serverInfo.worktreePath, | |
| port: serverInfo.port, | |
| url: serverInfo.url, | |
| message: `Dev server started on port ${port}`, | |
| }, | |
| }; | |
| } finally { | |
| this.startingServers.delete(worktreePath); | |
| } | |
| } | |
| /** | |
| * Stop a dev server for a worktree | |
| */ | |
| async stopDevServer(worktreePath: string): Promise<{ | |
| success: boolean; | |
| result?: { worktreePath: string; message: string }; | |
| error?: string; | |
| }> { | |
| const server = this.runningServers.get(worktreePath); | |
| // If we don't have a record of this server, it may have crashed/exited on its own | |
| // Return success so the frontend can clear its state | |
| if (!server) { | |
| logger.debug(`No server record for ${worktreePath}, may have already stopped`); | |
| return { | |
| success: true, | |
| result: { | |
| worktreePath, | |
| message: `Dev server already stopped`, | |
| }, | |
| }; | |
| } | |
| logger.info(`Stopping dev server for ${worktreePath}`); | |
| // Mark as stopping to prevent further output events | |
| server.stopping = true; | |
| // Clean up flush timeout to prevent memory leaks | |
| if (server.flushTimeout) { | |
| clearTimeout(server.flushTimeout); | |
| server.flushTimeout = null; | |
| } | |
| // Clean up URL detection timeout | |
| if (server.urlDetectionTimeout) { | |
| clearTimeout(server.urlDetectionTimeout); | |
| server.urlDetectionTimeout = null; | |
| } | |
| // Clear any pending output buffer | |
| server.outputBuffer = ''; | |
| // Emit stopped event immediately so UI updates right away | |
| if (this.emitter) { | |
| this.emitter.emit('dev-server:stopped', { | |
| worktreePath, | |
| port: server.port, | |
| exitCode: null, // Will be populated by exit handler if process exits normally | |
| timestamp: new Date().toISOString(), | |
| }); | |
| } | |
| // Kill the process; persisted/re-attached entries may not have a process handle. | |
| if (server.process && !server.process.killed) { | |
| server.process.kill('SIGTERM'); | |
| } else { | |
| this.killProcessOnPort(server.port); | |
| } | |
| // Free the originally-reserved port slot (allocatedPort is immutable and always | |
| // matches what was added to allocatedPorts in startDevServer; server.port may | |
| // have been updated by detectUrlFromOutput to the actual detected port). | |
| this.allocatedPorts.delete(server.allocatedPort); | |
| this.runningServers.delete(worktreePath); | |
| // Persist state change | |
| await this.saveState().catch((err) => | |
| logger.error('Failed to save state in stopDevServer:', err) | |
| ); | |
| return { | |
| success: true, | |
| result: { | |
| worktreePath, | |
| message: `Stopped dev server on port ${server.port}`, | |
| }, | |
| }; | |
| } | |
| /** | |
| * List all running dev servers | |
| * Also verifies that each server's process is still alive, removing stale entries | |
| */ | |
| listDevServers(): { | |
| success: boolean; | |
| result: { | |
| servers: Array<{ | |
| worktreePath: string; | |
| port: number; | |
| url: string; | |
| urlDetected: boolean; | |
| startedAt: string; | |
| }>; | |
| }; | |
| } { | |
| // Prune any servers whose process has died without us being notified | |
| // This handles edge cases where the process exited but the 'exit' event was missed | |
| const stalePaths: string[] = []; | |
| for (const [worktreePath, server] of this.runningServers) { | |
| // Check if exitCode is a number (not null/undefined) - indicates process has exited | |
| if (server.process && typeof server.process.exitCode === 'number') { | |
| logger.info( | |
| `Pruning stale server entry for ${worktreePath} (process exited with code ${server.process.exitCode})` | |
| ); | |
| stalePaths.push(worktreePath); | |
| } | |
| } | |
| for (const stalePath of stalePaths) { | |
| const server = this.runningServers.get(stalePath); | |
| if (server) { | |
| // Delegate to the shared helper so timers, ports, and the stopped event | |
| // are all handled consistently with isRunning and getServerInfo. | |
| this.pruneStaleServer(stalePath, server); | |
| } | |
| } | |
| const servers = Array.from(this.runningServers.values()).map((s) => ({ | |
| worktreePath: s.worktreePath, | |
| port: s.port, | |
| url: s.url, | |
| urlDetected: s.urlDetected, | |
| startedAt: s.startedAt.toISOString(), | |
| })); | |
| return { | |
| success: true, | |
| result: { servers }, | |
| }; | |
| } | |
| /** | |
| * Check if a worktree has a running dev server. | |
| * Also prunes stale entries where the process has exited. | |
| */ | |
| isRunning(worktreePath: string): boolean { | |
| const server = this.runningServers.get(worktreePath); | |
| if (!server) return false; | |
| // Prune stale entry if the process has exited | |
| if (server.process && typeof server.process.exitCode === 'number') { | |
| this.pruneStaleServer(worktreePath, server); | |
| return false; | |
| } | |
| return true; | |
| } | |
| /** | |
| * Get info for a specific worktree's dev server. | |
| * Also prunes stale entries where the process has exited. | |
| */ | |
| getServerInfo(worktreePath: string): DevServerInfo | undefined { | |
| const server = this.runningServers.get(worktreePath); | |
| if (!server) return undefined; | |
| // Prune stale entry if the process has exited | |
| if (server.process && typeof server.process.exitCode === 'number') { | |
| this.pruneStaleServer(worktreePath, server); | |
| return undefined; | |
| } | |
| return server; | |
| } | |
| /** | |
| * Get buffered logs for a worktree's dev server | |
| * Returns the scrollback buffer containing historical log output | |
| * Used by the API to serve logs to clients on initial connection | |
| */ | |
| getServerLogs(worktreePath: string): { | |
| success: boolean; | |
| result?: { | |
| worktreePath: string; | |
| port: number; | |
| url: string; | |
| logs: string; | |
| startedAt: string; | |
| }; | |
| error?: string; | |
| } { | |
| const server = this.runningServers.get(worktreePath); | |
| if (!server) { | |
| return { | |
| success: false, | |
| error: `No dev server running for worktree: ${worktreePath}`, | |
| }; | |
| } | |
| // Prune stale entry if the process has been killed or has exited | |
| if (server.process && (server.process.killed || server.process.exitCode != null)) { | |
| this.pruneStaleServer(worktreePath, server); | |
| return { | |
| success: false, | |
| error: `No dev server running for worktree: ${worktreePath}`, | |
| }; | |
| } | |
| return { | |
| success: true, | |
| result: { | |
| worktreePath: server.worktreePath, | |
| port: server.port, | |
| url: server.url, | |
| logs: server.scrollbackBuffer, | |
| startedAt: server.startedAt.toISOString(), | |
| }, | |
| }; | |
| } | |
| /** | |
| * Get all allocated ports | |
| */ | |
| getAllocatedPorts(): number[] { | |
| return Array.from(this.allocatedPorts); | |
| } | |
| /** | |
| * Stop all running dev servers (for cleanup) | |
| */ | |
| async stopAll(): Promise<void> { | |
| logger.info(`Stopping all ${this.runningServers.size} dev servers`); | |
| for (const [worktreePath] of this.runningServers) { | |
| await this.stopDevServer(worktreePath); | |
| } | |
| } | |
| } | |
| // Singleton instance | |
| let devServerServiceInstance: DevServerService | null = null; | |
| export function getDevServerService(): DevServerService { | |
| if (!devServerServiceInstance) { | |
| devServerServiceInstance = new DevServerService(); | |
| } | |
| return devServerServiceInstance; | |
| } | |
| // Cleanup on process exit | |
| process.on('SIGTERM', async () => { | |
| if (devServerServiceInstance) { | |
| await devServerServiceInstance.stopAll(); | |
| } | |
| }); | |
| process.on('SIGINT', async () => { | |
| if (devServerServiceInstance) { | |
| await devServerServiceInstance.stopAll(); | |
| } | |
| }); | |