mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Add the ability to view dev server logs in a dedicated panel with: - Real-time log streaming via WebSocket events - ANSI color support using xterm.js - Scrollback buffer (50KB) for log history on reconnect - Output throttling to prevent UI flooding - "View Logs" option in worktree dropdown menu Server changes: - Add scrollback buffer and event emission to DevServerService - Add GET /api/worktree/dev-server-logs endpoint - Add dev-server:started, dev-server:output, dev-server:stopped events UI changes: - Add reusable XtermLogViewer component - Add DevServerLogsPanel dialog component - Add useDevServerLogs hook for WebSocket subscription Closes #462 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
663 lines
19 KiB
TypeScript
663 lines
19 KiB
TypeScript
/**
|
|
* 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';
|
|
|
|
const logger = createLogger('DevServerService');
|
|
|
|
// Maximum scrollback buffer size (characters) - matches TerminalService pattern
|
|
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server
|
|
|
|
// Throttle output to prevent overwhelming WebSocket under heavy load
|
|
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback
|
|
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
|
|
|
export interface DevServerInfo {
|
|
worktreePath: string;
|
|
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;
|
|
}
|
|
|
|
// 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 allocatedPorts: Set<number> = new Set();
|
|
private emitter: EventEmitter | null = null;
|
|
|
|
/**
|
|
* Set the event emitter for streaming log events
|
|
* Called during service initialization with the global event emitter
|
|
*/
|
|
setEventEmitter(emitter: EventEmitter): void {
|
|
this.emitter = emitter;
|
|
}
|
|
|
|
/**
|
|
* 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(),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle incoming stdout/stderr data from dev server process
|
|
* Buffers data for scrollback replay and schedules throttled emission
|
|
*/
|
|
private handleProcessOutput(server: DevServerInfo, data: Buffer): void {
|
|
// Skip output if server is stopping
|
|
if (server.stopping) {
|
|
return;
|
|
}
|
|
|
|
const content = data.toString();
|
|
|
|
// 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 (error) {
|
|
// 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'] };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start a dev server for a worktree
|
|
*/
|
|
async startDevServer(
|
|
projectPath: string,
|
|
worktreePath: string
|
|
): Promise<{
|
|
success: boolean;
|
|
result?: {
|
|
worktreePath: string;
|
|
port: number;
|
|
url: string;
|
|
message: string;
|
|
};
|
|
error?: string;
|
|
}> {
|
|
// Check if already running
|
|
if (this.runningServers.has(worktreePath)) {
|
|
const existing = this.runningServers.get(worktreePath)!;
|
|
return {
|
|
success: true,
|
|
result: {
|
|
worktreePath: existing.worktreePath,
|
|
port: existing.port,
|
|
url: existing.url,
|
|
message: `Dev server already running on port ${existing.port}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Verify the worktree exists
|
|
if (!(await this.fileExists(worktreePath))) {
|
|
return {
|
|
success: false,
|
|
error: `Worktree path does not exist: ${worktreePath}`,
|
|
};
|
|
}
|
|
|
|
// Check for package.json
|
|
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
|
|
const devCommand = await this.getDevCommand(worktreePath);
|
|
if (!devCommand) {
|
|
return {
|
|
success: false,
|
|
error: `Could not determine dev command for: ${worktreePath}`,
|
|
};
|
|
}
|
|
|
|
// 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}`);
|
|
|
|
// 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 serverInfo: DevServerInfo = {
|
|
worktreePath,
|
|
port,
|
|
url: `http://localhost:${port}`,
|
|
process: devProcess,
|
|
startedAt: new Date(),
|
|
scrollbackBuffer: '',
|
|
outputBuffer: '',
|
|
flushTimeout: null,
|
|
stopping: false,
|
|
};
|
|
|
|
// Capture stdout with buffer management and event emission
|
|
if (devProcess.stdout) {
|
|
devProcess.stdout.on('data', (data: Buffer) => {
|
|
this.handleProcessOutput(serverInfo, data);
|
|
});
|
|
}
|
|
|
|
// Capture stderr with buffer management and event emission
|
|
if (devProcess.stderr) {
|
|
devProcess.stderr.on('data', (data: Buffer) => {
|
|
this.handleProcessOutput(serverInfo, data);
|
|
});
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Emit stopped event (only if not already stopping - prevents duplicate events)
|
|
if (this.emitter && !serverInfo.stopping) {
|
|
this.emitter.emit('dev-server:stopped', {
|
|
worktreePath,
|
|
port,
|
|
exitCode,
|
|
error: errorMessage,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
this.allocatedPorts.delete(port);
|
|
this.runningServers.delete(worktreePath);
|
|
};
|
|
|
|
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);
|
|
|
|
// Emit started event for WebSocket subscribers
|
|
if (this.emitter) {
|
|
this.emitter.emit('dev-server:started', {
|
|
worktreePath,
|
|
port,
|
|
url: serverInfo.url,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
result: {
|
|
worktreePath,
|
|
port,
|
|
url: `http://localhost:${port}`,
|
|
message: `Dev server started on port ${port}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// 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
|
|
if (server.process && !server.process.killed) {
|
|
server.process.kill('SIGTERM');
|
|
}
|
|
|
|
// Free the port
|
|
this.allocatedPorts.delete(server.port);
|
|
this.runningServers.delete(worktreePath);
|
|
|
|
return {
|
|
success: true,
|
|
result: {
|
|
worktreePath,
|
|
message: `Stopped dev server on port ${server.port}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* List all running dev servers
|
|
*/
|
|
listDevServers(): {
|
|
success: boolean;
|
|
result: {
|
|
servers: Array<{
|
|
worktreePath: string;
|
|
port: number;
|
|
url: string;
|
|
}>;
|
|
};
|
|
} {
|
|
const servers = Array.from(this.runningServers.values()).map((s) => ({
|
|
worktreePath: s.worktreePath,
|
|
port: s.port,
|
|
url: s.url,
|
|
}));
|
|
|
|
return {
|
|
success: true,
|
|
result: { servers },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if a worktree has a running dev server
|
|
*/
|
|
isRunning(worktreePath: string): boolean {
|
|
return this.runningServers.has(worktreePath);
|
|
}
|
|
|
|
/**
|
|
* Get info for a specific worktree's dev server
|
|
*/
|
|
getServerInfo(worktreePath: string): DevServerInfo | undefined {
|
|
return this.runningServers.get(worktreePath);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
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}`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
result: {
|
|
worktreePath: server.worktreePath,
|
|
port: server.port,
|
|
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();
|
|
}
|
|
});
|