| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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'); |
|
|
| |
| const MAX_SCROLLBACK_SIZE = 50000; |
|
|
| |
| |
| const URL_DETECTION_TIMEOUT_MS = 30_000; |
|
|
| |
| |
| |
| const URL_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ |
| |
| { |
| pattern: /(?:Local|Network|External):\s+(https?:\/\/[^\s]+)/i, |
| description: 'Vite/Nuxt/SvelteKit/Astro/Angular format', |
| }, |
| |
| |
| { |
| pattern: /(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, |
| description: 'Next.js format', |
| }, |
| |
| |
| |
| |
| { |
| pattern: |
| /(?:starting|started|listening|running|available|serving|accessible)\s+(?:at|on)\s+(https?:\/\/[^\s,)]+)/i, |
| description: 'Generic "starting/started/listening at" format', |
| }, |
| |
| { |
| pattern: /(?:server|development server)\s*\(\s*(https?:\/\/[^\s)]+)\s*\)/i, |
| description: 'PHP server format', |
| }, |
| |
| { |
| pattern: /(?:project|app|application)\s+(?:is\s+)?running\s+(?:at|on)\s+(https?:\/\/[^\s,]+)/i, |
| description: 'Webpack/generic "running at" format', |
| }, |
| |
| { |
| pattern: /(?:serving|server)\s+(?:on|at)\s+(https?:\/\/[^\s,]+)/i, |
| description: 'Generic "serving on" format', |
| }, |
| |
| |
| { |
| pattern: /(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]|0\.0\.0\.0):\d+\S*)/i, |
| description: 'Generic localhost URL with port', |
| }, |
| ]; |
|
|
| |
| |
| |
| const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ |
| |
| { |
| pattern: /(?:listening|running|started|serving|available)\s+on\s+port\s+(\d+)/i, |
| description: '"listening on port" format', |
| }, |
| |
| { |
| pattern: /(?:^|\s)port[:\s]+(\d{4,5})(?:\s|$|[.,;])/im, |
| description: '"port:" format', |
| }, |
| ]; |
|
|
| |
| |
| |
| |
| |
| const OUTPUT_THROTTLE_MS = 100; |
| const OUTPUT_BATCH_SIZE = 8192; |
|
|
| export interface DevServerInfo { |
| worktreePath: string; |
| |
| allocatedPort: number; |
| port: number; |
| url: string; |
| process: ChildProcess | null; |
| startedAt: Date; |
| |
| scrollbackBuffer: string; |
| |
| outputBuffer: string; |
| |
| flushTimeout: NodeJS.Timeout | null; |
| |
| stopping: boolean; |
| |
| urlDetected: boolean; |
| |
| urlDetectionTimeout: NodeJS.Timeout | null; |
| |
| customCommand?: string; |
| } |
|
|
| |
| |
| |
| interface PersistedDevServerInfo { |
| worktreePath: string; |
| allocatedPort: number; |
| port: number; |
| url: string; |
| startedAt: string; |
| urlDetected: boolean; |
| customCommand?: string; |
| } |
|
|
| |
| const BASE_PORT = 3001; |
| const MAX_PORT = 3099; |
|
|
| |
| 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(); |
|
|
| |
| |
| |
| async initialize(dataDir: string, emitter: EventEmitter): Promise<void> { |
| this.dataDir = dataDir; |
| this.emitter = emitter; |
| await this.loadState(); |
| } |
|
|
| |
| |
| |
| |
| setEventEmitter(emitter: EventEmitter): void { |
| this.emitter = emitter; |
| } |
|
|
| |
| |
| |
| private async saveState(): Promise<void> { |
| if (!this.dataDir) return; |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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 { |
| |
| 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) { |
| |
| |
| |
| 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, |
| 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` |
| ); |
| } |
| } |
|
|
| |
| if (this.runningServers.size !== persistedInfo.length) { |
| await this.saveState(); |
| } |
| } catch (error) { |
| logger.error('Failed to load dev server state:', error); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private pruneStaleServer(worktreePath: string, server: DevServerInfo): void { |
| if (server.flushTimeout) clearTimeout(server.flushTimeout); |
| if (server.urlDetectionTimeout) clearTimeout(server.urlDetectionTimeout); |
| |
| |
| this.allocatedPorts.delete(server.allocatedPort); |
| this.runningServers.delete(worktreePath); |
|
|
| |
| 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, |
| exitCode: server.process?.exitCode ?? null, |
| timestamp: new Date().toISOString(), |
| }); |
| } |
| } |
|
|
| |
| |
| |
| |
| 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); |
| } |
| } |
|
|
| |
| |
| |
| |
| private flushOutput(server: DevServerInfo): void { |
| |
| if (server.stopping || server.outputBuffer.length === 0) { |
| server.flushTimeout = null; |
| return; |
| } |
|
|
| let dataToSend = server.outputBuffer; |
| if (dataToSend.length > OUTPUT_BATCH_SIZE) { |
| |
| dataToSend = server.outputBuffer.slice(0, OUTPUT_BATCH_SIZE); |
| server.outputBuffer = server.outputBuffer.slice(OUTPUT_BATCH_SIZE); |
| |
| server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS); |
| } else { |
| server.outputBuffer = ''; |
| server.flushTimeout = null; |
| } |
|
|
| |
| if (this.emitter) { |
| this.emitter.emit('dev-server:output', { |
| worktreePath: server.worktreePath, |
| content: dataToSend, |
| timestamp: new Date().toISOString(), |
| }); |
| } |
| } |
|
|
| |
| |
| |
| |
| private stripAnsi(str: string): string { |
| |
| |
| return str.replace(/\x1B(?:\[[0-9;]*[a-zA-Z]|\].*?(?:\x07|\x1B\\)|\[[?]?[0-9;]*[hl])/g, ''); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private async detectUrlFromOutput(server: DevServerInfo, content: string): Promise<void> { |
| |
| if (server.urlDetected) { |
| return; |
| } |
|
|
| |
| const cleanContent = this.stripAnsi(content); |
|
|
| |
| |
| for (const { pattern, description } of URL_PATTERNS) { |
| const match = cleanContent.match(pattern); |
| if (match && match[1]) { |
| let detectedUrl = match[1].trim(); |
| |
| detectedUrl = detectedUrl.replace(/[.,;:!?)\]}>]+$/, ''); |
|
|
| if (detectedUrl.startsWith('http://') || detectedUrl.startsWith('https://')) { |
| |
| detectedUrl = detectedUrl.replace( |
| /\/\/0\.0\.0\.0(:\d+)?/, |
| (_, port) => `//localhost${port || ''}` |
| ); |
| |
| detectedUrl = detectedUrl.replace( |
| /\/\/\[::\](:\d+)?/, |
| (_, port) => `//localhost${port || ''}` |
| ); |
| |
| detectedUrl = detectedUrl.replace( |
| /\/\/\[::1\](:\d+)?/, |
| (_, port) => `//localhost${port || ''}` |
| ); |
|
|
| server.url = detectedUrl; |
| server.urlDetected = true; |
|
|
| |
| if (server.urlDetectionTimeout) { |
| clearTimeout(server.urlDetectionTimeout); |
| server.urlDetectionTimeout = null; |
| } |
|
|
| |
| 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}`); |
|
|
| |
| await this.saveState().catch((err) => |
| logger.error('Failed to save state in detectUrlFromOutput:', err) |
| ); |
|
|
| |
| if (this.emitter) { |
| this.emitter.emit('dev-server:url-detected', { |
| worktreePath: server.worktreePath, |
| url: detectedUrl, |
| port: server.port, |
| timestamp: new Date().toISOString(), |
| }); |
| } |
| return; |
| } |
| } |
| } |
|
|
| |
| |
| |
| for (const { pattern, description } of PORT_PATTERNS) { |
| const match = cleanContent.match(pattern); |
| if (match && match[1]) { |
| const detectedPort = parseInt(match[1], 10); |
| |
| if (detectedPort > 0 && detectedPort <= 65535) { |
| const detectedUrl = `http://localhost:${detectedPort}`; |
| server.url = detectedUrl; |
| server.urlDetected = true; |
|
|
| |
| 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}`); |
|
|
| |
| await this.saveState().catch((err) => |
| logger.error('Failed to save state in detectUrlFromOutput Phase 2:', err) |
| ); |
|
|
| |
| if (this.emitter) { |
| this.emitter.emit('dev-server:url-detected', { |
| worktreePath: server.worktreePath, |
| url: detectedUrl, |
| port: server.port, |
| timestamp: new Date().toISOString(), |
| }); |
| } |
| return; |
| } |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| private async handleProcessOutput(server: DevServerInfo, data: Buffer): Promise<void> { |
| |
| if (server.stopping) { |
| return; |
| } |
|
|
| const content = data.toString(); |
|
|
| |
| await this.detectUrlFromOutput(server, content); |
|
|
| |
| this.appendToScrollback(server, content); |
|
|
| |
| server.outputBuffer += content; |
|
|
| |
| if (!server.flushTimeout) { |
| server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS); |
| } |
|
|
| |
| logger.debug(`[Port${server.port}] ${content.trim()}`); |
| } |
|
|
| |
| |
| |
| private async isPortAvailable(port: number): Promise<boolean> { |
| |
| if (this.allocatedPorts.has(port)) { |
| return false; |
| } |
|
|
| |
| 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'); |
| }); |
| } |
|
|
| |
| |
| |
| private killProcessOnPort(port: number): void { |
| try { |
| if (process.platform === 'win32') { |
| |
| 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 { |
| |
| } |
| } |
| } else { |
| |
| 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 { |
| |
| } |
| } |
| } catch { |
| |
| } |
| } |
| } catch { |
| |
| logger.debug(`No process to kill on port ${port}`); |
| } |
| } |
|
|
| |
| |
| |
| private async findAvailablePort(): Promise<number> { |
| let port = BASE_PORT; |
|
|
| while (port <= MAX_PORT) { |
| |
| if (this.allocatedPorts.has(port)) { |
| port++; |
| continue; |
| } |
|
|
| |
| |
| this.killProcessOnPort(port); |
|
|
| |
| await new Promise((resolve) => setTimeout(resolve, 100)); |
|
|
| |
| if (await this.isPortAvailable(port)) { |
| return port; |
| } |
| port++; |
| } |
|
|
| throw new Error(`No available ports found between ${BASE_PORT} and ${MAX_PORT}`); |
| } |
|
|
| |
| |
| |
| private async fileExists(filePath: string): Promise<boolean> { |
| try { |
| await secureFs.access(filePath); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| 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'; |
| return null; |
| } |
|
|
| |
| |
| |
| 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'] }; |
| } |
| } |
|
|
| |
| |
| |
| |
| 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 }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async startDevServer( |
| projectPath: string, |
| worktreePath: string, |
| customCommand?: string |
| ): Promise<{ |
| success: boolean; |
| result?: { |
| worktreePath: string; |
| port: number; |
| url: string; |
| message: string; |
| }; |
| error?: string; |
| }> { |
| |
| 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 { |
| |
| if (!(await this.fileExists(worktreePath))) { |
| return { |
| success: false, |
| error: `Worktree path does not exist: ${worktreePath}`, |
| }; |
| } |
|
|
| |
| let devCommand: { cmd: string; args: string[] }; |
|
|
| |
| const normalizedCustomCommand = customCommand?.trim(); |
|
|
| if (normalizedCustomCommand) { |
| |
| 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 { |
| |
| const packageJsonPath = path.join(worktreePath, 'package.json'); |
| if (!(await this.fileExists(packageJsonPath))) { |
| return { |
| success: false, |
| error: `No package.json found in: ${worktreePath}`, |
| }; |
| } |
|
|
| |
| const detectedCommand = await this.getDevCommand(worktreePath); |
| if (!detectedCommand) { |
| return { |
| success: false, |
| error: `Could not determine dev command for: ${worktreePath}`, |
| }; |
| } |
| devCommand = detectedCommand; |
| } |
|
|
| |
| let port: number; |
| try { |
| port = await this.findAvailablePort(); |
| } catch (error) { |
| return { |
| success: false, |
| error: error instanceof Error ? error.message : 'Port allocation failed', |
| }; |
| } |
|
|
| |
| this.allocatedPorts.add(port); |
|
|
| |
| |
| for (const relatedPort of LIVERELOAD_PORTS) { |
| this.killProcessOnPort(relatedPort); |
| } |
|
|
| |
| 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}`); |
|
|
| |
| if (this.emitter) { |
| this.emitter.emit('dev-server:starting', { |
| worktreePath, |
| timestamp: new Date().toISOString(), |
| }); |
| } |
|
|
| |
| |
| const env = { |
| ...process.env, |
| PORT: String(port), |
| FORCE_COLOR: '1', |
| |
| COLORTERM: 'truecolor', |
| TERM: 'xterm-256color', |
| }; |
|
|
| const devProcess = spawn(devCommand.cmd, devCommand.args, { |
| cwd: worktreePath, |
| env, |
| stdio: ['ignore', 'pipe', 'pipe'], |
| detached: false, |
| }); |
|
|
| |
| const status = { error: null as string | null, exited: false }; |
|
|
| |
| |
| const fallbackHost = 'localhost'; |
| const serverInfo: DevServerInfo = { |
| worktreePath, |
| allocatedPort: port, |
| port, |
| url: `http://${fallbackHost}:${port}`, |
| process: devProcess, |
| startedAt: new Date(), |
| scrollbackBuffer: '', |
| outputBuffer: '', |
| flushTimeout: null, |
| stopping: false, |
| urlDetected: false, |
| urlDetectionTimeout: null, |
| customCommand: normalizedCustomCommand, |
| }; |
|
|
| |
| 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); |
| }); |
| }); |
| } |
|
|
| |
| 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); |
| }); |
| }); |
| } |
|
|
| |
| const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => { |
| if (serverInfo.flushTimeout) { |
| clearTimeout(serverInfo.flushTimeout); |
| serverInfo.flushTimeout = null; |
| } |
|
|
| |
| if (serverInfo.urlDetectionTimeout) { |
| clearTimeout(serverInfo.urlDetectionTimeout); |
| serverInfo.urlDetectionTimeout = null; |
| } |
|
|
| |
| if (this.emitter && !serverInfo.stopping) { |
| this.emitter.emit('dev-server:stopped', { |
| worktreePath, |
| port: serverInfo.port, |
| exitCode, |
| error: errorMessage, |
| timestamp: new Date().toISOString(), |
| }); |
| } |
|
|
| this.allocatedPorts.delete(serverInfo.allocatedPort); |
| this.runningServers.delete(worktreePath); |
|
|
| |
| 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); |
| }); |
|
|
| |
| 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.`, |
| }; |
| } |
|
|
| |
| this.runningServers.set(worktreePath, serverInfo); |
|
|
| |
| await this.saveState().catch((err) => |
| logger.error('Failed to save state in startDevServer:', err) |
| ); |
|
|
| |
| if (this.emitter) { |
| this.emitter.emit('dev-server:started', { |
| worktreePath, |
| port, |
| url: serverInfo.url, |
| timestamp: new Date().toISOString(), |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| serverInfo.urlDetectionTimeout = setTimeout(async () => { |
| serverInfo.urlDetectionTimeout = null; |
|
|
| |
| if ( |
| serverInfo.stopping || |
| serverInfo.urlDetected || |
| !this.runningServers.has(worktreePath) |
| ) { |
| return; |
| } |
|
|
| |
| |
| 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 (!serverInfo.urlDetected) { |
| logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`); |
| const fallbackUrl = `http://${fallbackHost}:${port}`; |
| serverInfo.url = fallbackUrl; |
| serverInfo.urlDetected = true; |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| |
| |
| async stopDevServer(worktreePath: string): Promise<{ |
| success: boolean; |
| result?: { worktreePath: string; message: string }; |
| error?: string; |
| }> { |
| const server = this.runningServers.get(worktreePath); |
|
|
| |
| |
| 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}`); |
|
|
| |
| server.stopping = true; |
|
|
| |
| if (server.flushTimeout) { |
| clearTimeout(server.flushTimeout); |
| server.flushTimeout = null; |
| } |
|
|
| |
| if (server.urlDetectionTimeout) { |
| clearTimeout(server.urlDetectionTimeout); |
| server.urlDetectionTimeout = null; |
| } |
|
|
| |
| server.outputBuffer = ''; |
|
|
| |
| if (this.emitter) { |
| this.emitter.emit('dev-server:stopped', { |
| worktreePath, |
| port: server.port, |
| exitCode: null, |
| timestamp: new Date().toISOString(), |
| }); |
| } |
|
|
| |
| if (server.process && !server.process.killed) { |
| server.process.kill('SIGTERM'); |
| } else { |
| this.killProcessOnPort(server.port); |
| } |
|
|
| |
| |
| |
| this.allocatedPorts.delete(server.allocatedPort); |
| this.runningServers.delete(worktreePath); |
|
|
| |
| 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}`, |
| }, |
| }; |
| } |
|
|
| |
| |
| |
| |
| listDevServers(): { |
| success: boolean; |
| result: { |
| servers: Array<{ |
| worktreePath: string; |
| port: number; |
| url: string; |
| urlDetected: boolean; |
| startedAt: string; |
| }>; |
| }; |
| } { |
| |
| |
| const stalePaths: string[] = []; |
| for (const [worktreePath, server] of this.runningServers) { |
| |
| 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) { |
| |
| |
| 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 }, |
| }; |
| } |
|
|
| |
| |
| |
| |
| isRunning(worktreePath: string): boolean { |
| const server = this.runningServers.get(worktreePath); |
| if (!server) return false; |
| |
| if (server.process && typeof server.process.exitCode === 'number') { |
| this.pruneStaleServer(worktreePath, server); |
| return false; |
| } |
| return true; |
| } |
|
|
| |
| |
| |
| |
| getServerInfo(worktreePath: string): DevServerInfo | undefined { |
| const server = this.runningServers.get(worktreePath); |
| if (!server) return undefined; |
| |
| if (server.process && typeof server.process.exitCode === 'number') { |
| this.pruneStaleServer(worktreePath, server); |
| return undefined; |
| } |
| return server; |
| } |
|
|
| |
| |
| |
| |
| |
| 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}`, |
| }; |
| } |
|
|
| |
| 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(), |
| }, |
| }; |
| } |
|
|
| |
| |
| |
| getAllocatedPorts(): number[] { |
| return Array.from(this.allocatedPorts); |
| } |
|
|
| |
| |
| |
| async stopAll(): Promise<void> { |
| logger.info(`Stopping all ${this.runningServers.size} dev servers`); |
|
|
| for (const [worktreePath] of this.runningServers) { |
| await this.stopDevServer(worktreePath); |
| } |
| } |
| } |
|
|
| |
| let devServerServiceInstance: DevServerService | null = null; |
|
|
| export function getDevServerService(): DevServerService { |
| if (!devServerServiceInstance) { |
| devServerServiceInstance = new DevServerService(); |
| } |
| return devServerServiceInstance; |
| } |
|
|
| |
| process.on('SIGTERM', async () => { |
| if (devServerServiceInstance) { |
| await devServerServiceInstance.stopAll(); |
| } |
| }); |
|
|
| process.on('SIGINT', async () => { |
| if (devServerServiceInstance) { |
| await devServerServiceInstance.stopAll(); |
| } |
| }); |
|
|