diff --git a/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs new file mode 100644 index 00000000..4e75bde3 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs @@ -0,0 +1,777 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Dev; + +public sealed class DevCommand : Command +{ + /// How long to wait for graceful shutdown before force-killing. + private const int GracefulShutdownTimeoutMs = 5000; + + /// How long to wait after force-kill before giving up. + private const int ForceKillTimeoutMs = 3000; + + private readonly List<(Process Process, string Label)> _processes = []; + private volatile int _shutdownState; // ShutdownPhase values + + private static class ShutdownPhase + { + public const int Running = 0; + public const int Graceful = 1; + public const int Force = 2; + } + + public override int Execute(CommandContext context, DevSettings settings) + { + var solution = SolutionContext.Discover(); + if (solution is null) + { + AnsiConsole.MarkupLine( + "[red]Could not find .slnx file. Run this command from within a SimpleModule project.[/]" + ); + return 1; + } + + var hostProject = solution.ApiCsprojPath; + if (!File.Exists(hostProject)) + { + AnsiConsole.MarkupLine($"[red]Host project not found at {hostProject}[/]"); + return 1; + } + + var hostDir = Path.GetDirectoryName(hostProject)!; + var clientAppDir = Path.Combine(hostDir, "ClientApp"); + var viteConfigPath = Path.Combine(clientAppDir, "vite.dev.config.ts"); + + Console.CancelKeyPress += OnCancelKeyPress; + AppDomain.CurrentDomain.ProcessExit += OnProcessExit; + + try + { + return Run(settings, solution, hostProject, viteConfigPath); + } + finally + { + // Always clean up — even on unhandled exceptions + ForceKillAll(); + DisposeAll(); + Console.CancelKeyPress -= OnCancelKeyPress; + AppDomain.CurrentDomain.ProcessExit -= OnProcessExit; + } + } + + private int Run( + DevSettings settings, + SolutionContext solution, + string hostProject, + string viteConfigPath + ) + { + AnsiConsole.MarkupLine("[bold blue]Starting SimpleModule development environment[/]"); + AnsiConsole.MarkupLine(""); + + var startDotnet = !settings.NoDotnet; + var startVite = !settings.NoVite && File.Exists(viteConfigPath); + + // --- Pre-flight: check all required ports before starting anything --- + if (startDotnet) + { + var dotnetPorts = DiscoverDotnetPorts(hostProject); + foreach (var port in dotnetPorts) + { + if (!PortChecker.EnsurePortFree(port, "dotnet")) + { + AnsiConsole.MarkupLine( + $"[red]Cannot start dotnet — port {port} is occupied.[/]" + ); + return 1; + } + } + } + + if (startVite) + { + if (!PortChecker.EnsurePortFree(settings.VitePort, "vite")) + { + AnsiConsole.MarkupLine( + $"[red]Cannot start Vite — port {settings.VitePort} is occupied.[/]" + ); + return 1; + } + } + + // --- Start processes --- + if (startDotnet) + { + AnsiConsole.MarkupLine("[cyan][[dotnet]][/] Starting dotnet watch..."); + var dotnetArgs = $"watch run --project \"{hostProject}\" --no-restore"; + var dotnetEnv = new Dictionary + { + ["ASPNETCORE_ENVIRONMENT"] = "Development", + }; + StartProcess("dotnet", dotnetArgs, solution.RootPath, "dotnet", dotnetEnv); + } + + if (startVite) + { + AnsiConsole.MarkupLine( + $"[cyan][[vite]][/] Starting Vite dev server on port {settings.VitePort}..." + ); + var npx = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "npx.cmd" : "npx"; + var viteArgs = + $"vite dev --config \"{viteConfigPath}\" --port {settings.VitePort} --strictPort"; + StartProcess(npx, viteArgs, solution.RootPath, "vite"); + } + else if (!settings.NoVite && !File.Exists(viteConfigPath)) + { + AnsiConsole.MarkupLine( + "[yellow][[vite]][/] vite.dev.config.ts not found in ClientApp — skipping Vite dev server" + ); + } + + if (_processes.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No processes started. Check your options.[/]"); + return 1; + } + + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine( + $"[green]Development environment running ({_processes.Count} process(es)). Press Ctrl+C to stop.[/]" + ); + + WaitForExit(); + return 0; + } + + private void StartProcess( + string fileName, + string arguments, + string workingDirectory, + string label, + IReadOnlyDictionary? environment = null + ) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + }; + + if (environment is not null) + { + foreach (var (key, value) in environment) + { + startInfo.Environment[key] = value; + } + } + + try + { + var process = Process.Start(startInfo); + if (process is not null) + { + _processes.Add((process, label)); + AnsiConsole.MarkupLine($"[dim][[{label}]][/] [dim]Started (PID {process.Id})[/]"); + } + else + { + AnsiConsole.MarkupLine($"[red][[{label}]][/] Failed to start process"); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 + { + AnsiConsole.MarkupLine($"[red][[{label}]][/] Error: {ex.Message}"); + } + } + + private void WaitForExit() + { + // Wait until any process exits or shutdown is requested + while (_shutdownState == ShutdownPhase.Running) + { + foreach (var (process, label) in _processes) + { + try + { + if (process.HasExited) + { + if (_shutdownState == ShutdownPhase.Running) + { + AnsiConsole.MarkupLine( + $"[yellow][[{label}]][/] Exited with code {process.ExitCode}. Shutting down..." + ); + GracefulShutdown(); + } + + return; + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Process may have been disposed between check and access + } +#pragma warning restore CA1031 + } + + Thread.Sleep(300); + } + } + + /// + /// Graceful shutdown: send termination signals to children, wait for them to exit, + /// then force-kill any stragglers. + /// + private void GracefulShutdown() + { + // Transition: running → graceful + if ( + Interlocked.CompareExchange( + ref _shutdownState, + ShutdownPhase.Graceful, + ShutdownPhase.Running + ) != ShutdownPhase.Running + ) + { + return; + } + + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine("[cyan]Stopping all processes gracefully...[/]"); + + // Phase 1: Send graceful termination signal to the entire process tree + foreach (var (process, label) in _processes) + { + SendTermSignal(process, label); + } + + // Phase 2: Wait for graceful exit + if (WaitAllExit(GracefulShutdownTimeoutMs)) + { + AnsiConsole.MarkupLine("[green]All processes stopped.[/]"); + return; + } + + // Phase 3: Force-kill survivors + AnsiConsole.MarkupLine( + "[yellow]Some processes did not exit gracefully. Force-killing...[/]" + ); + ForceKillAll(); + + if (!WaitAllExit(ForceKillTimeoutMs)) + { + AnsiConsole.MarkupLine( + "[red]Warning: Some processes may still be running. Check manually.[/]" + ); + LogSurvivorPids(); + } + else + { + AnsiConsole.MarkupLine("[green]All processes stopped.[/]"); + } + } + + /// + /// Send a graceful termination signal to a process and all its descendants. + /// + /// Linux/macOS: Enumerates the process tree via the system process list + /// and sends SIGTERM to each descendant (leaf-first) then to the root. + /// Cannot use kill -TERM -pgid because children started with + /// UseShellExecute=false inherit the parent's process group. + /// + /// + /// Windows: Uses taskkill /PID <pid> /T which sends + /// WM_CLOSE to the entire process tree. + /// + /// + private static void SendTermSignal(Process process, string label) + { + try + { + if (process.HasExited) + { + return; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // taskkill /T walks the process tree and sends WM_CLOSE (graceful) + // /F is intentionally omitted — we want graceful first + using var taskkill = Process.Start( + new ProcessStartInfo + { + FileName = "taskkill", + Arguments = $"/PID {process.Id} /T", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + } + ); + taskkill?.WaitForExit(3000); + } + else + { + // Collect all descendant PIDs first, then signal leaf-first so + // parent processes don't respawn children before we reach them. + var descendants = GetDescendantPids(process.Id); + descendants.Reverse(); // leaf-first order + + foreach (var pid in descendants) + { + SendSigterm(pid); + } + + // Finally signal the root process itself + SendSigterm(process.Id); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 + { + AnsiConsole.MarkupLine( + $"[dim][[{label}]][/] [dim]Failed to send term signal: {ex.Message}[/]" + ); + } + } + + /// + /// Send SIGTERM to a single PID via the kill command. + /// Silently ignores errors (process may have already exited). + /// + private static void SendSigterm(int pid) + { + try + { + using var kill = Process.Start( + new ProcessStartInfo + { + FileName = "kill", + Arguments = $"-TERM {pid}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + } + ); + kill?.WaitForExit(1000); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Process may have already exited + } +#pragma warning restore CA1031 + } + + /// + /// Get all descendant PIDs of a process by walking the process tree. + /// Works on both Linux (/proc) and macOS (pgrep -P). + /// Returns PIDs in breadth-first order (parents before children). + /// + private static List GetDescendantPids(int rootPid) + { + var descendants = new List(); + var queue = new Queue(); + queue.Enqueue(rootPid); + + while (queue.Count > 0) + { + var parentPid = queue.Dequeue(); + List children; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + children = GetChildPidsFromProc(parentPid); + } + else + { + // macOS (and other Unix): use pgrep -P + children = GetChildPidsViaPgrep(parentPid); + } + + foreach (var childPid in children) + { + descendants.Add(childPid); + queue.Enqueue(childPid); + } + } + + return descendants; + } + + /// + /// Linux: read /proc to find child PIDs. Each /proc/[pid]/stat has ppid as field 4. + /// + private static List GetChildPidsFromProc(int parentPid) + { + var children = new List(); + + try + { + foreach (var dir in Directory.GetDirectories("/proc")) + { + var dirName = Path.GetFileName(dir); + if (!int.TryParse(dirName, out var pid)) + { + continue; + } + + try + { + var stat = File.ReadAllText(Path.Combine(dir, "stat")); + // Format: pid (comm) state ppid ... + // Find the closing ')' to skip the command name (which can contain spaces) + var closeParen = stat.LastIndexOf(')'); + if (closeParen < 0) + { + continue; + } + + var fields = stat[(closeParen + 2)..].Split(' '); + // fields[0] = state, fields[1] = ppid + if ( + fields.Length > 1 + && int.TryParse(fields[1], out var ppid) + && ppid == parentPid + ) + { + children.Add(pid); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Process may have exited between directory listing and read + } +#pragma warning restore CA1031 + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // /proc not available or permission denied + } +#pragma warning restore CA1031 + + return children; + } + + /// + /// macOS/Unix: use pgrep -P <pid> to find child PIDs. + /// + private static List GetChildPidsViaPgrep(int parentPid) + { + var children = new List(); + + try + { + using var pgrep = Process.Start( + new ProcessStartInfo + { + FileName = "pgrep", + Arguments = $"-P {parentPid}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + } + ); + + if (pgrep is null) + { + return children; + } + + var output = pgrep.StandardOutput.ReadToEnd(); + pgrep.WaitForExit(2000); + + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + if (int.TryParse(line.Trim(), out var pid)) + { + children.Add(pid); + } + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // pgrep not available + } +#pragma warning restore CA1031 + + return children; + } + + /// + /// Force-kill all processes and their entire process trees. + /// Uses .NET's cross-platform Kill(entireProcessTree: true) which + /// walks /proc on Linux, libproc on macOS, and NtQuerySystemInformation on Windows. + /// + private void ForceKillAll() + { + // Transition to force state (from any state) + Interlocked.Exchange(ref _shutdownState, ShutdownPhase.Force); + + foreach (var (process, label) in _processes) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 + { + // Kill can fail if process exited between check and kill, + // or if we lack permissions for a child process. + // Fall back to killing just the direct process. + AnsiConsole.MarkupLine( + $"[dim][[{label}]][/] [dim]Tree kill failed (PID {GetSafePid(process)}): {ex.Message}[/]" + ); + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: false); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Truly unreachable + } +#pragma warning restore CA1031 + } + } + } + + /// + /// Wait for all tracked processes to exit within the given timeout. + /// Returns true if all exited, false if any are still alive. + /// + private bool WaitAllExit(int timeoutMs) + { + var deadline = Environment.TickCount64 + timeoutMs; + + foreach (var (process, _) in _processes) + { + var remaining = (int)(deadline - Environment.TickCount64); + if (remaining <= 0) + { + return AllExited(); + } + + try + { + process.WaitForExit(remaining); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Process may already be disposed + } +#pragma warning restore CA1031 + } + + return AllExited(); + } + + private bool AllExited() + { + foreach (var (process, _) in _processes) + { + try + { + if (!process.HasExited) + { + return false; + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Process disposed — treat as exited + } +#pragma warning restore CA1031 + } + + return true; + } + + private void LogSurvivorPids() + { + foreach (var (process, label) in _processes) + { + try + { + if (!process.HasExited) + { + AnsiConsole.MarkupLine($"[red][[{label}]][/] Still running: PID {process.Id}"); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Ignore + } +#pragma warning restore CA1031 + } + } + + private void DisposeAll() + { + foreach (var (process, _) in _processes) + { + try + { + process.Dispose(); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Best-effort dispose + } +#pragma warning restore CA1031 + } + + _processes.Clear(); + } + + private static int GetSafePid(Process process) + { + try + { + return process.Id; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + return -1; + } +#pragma warning restore CA1031 + } + + private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) + { + if (_shutdownState == ShutdownPhase.Running) + { + // First Ctrl+C: graceful shutdown, cancel the default termination + e.Cancel = true; + GracefulShutdown(); + } + else + { + // Second Ctrl+C: force-kill immediately, let process terminate + AnsiConsole.MarkupLine("[red]Force-killing all processes...[/]"); + ForceKillAll(); + e.Cancel = false; + } + } + + private void OnProcessExit(object? sender, EventArgs e) + { + // Process is exiting (terminal closed, kill signal, etc.) + // Force-kill children to prevent orphans + ForceKillAll(); + } + + /// + /// Parse launchSettings.json to find the ports ASP.NET will bind to. + /// Falls back to the default ports (5001, 5000) if the file can't be read. + /// + private static List DiscoverDotnetPorts(string hostProjectPath) + { + var ports = new List(); + var hostDir = Path.GetDirectoryName(hostProjectPath); + if (hostDir is null) + { + return [5001, 5000]; + } + + var launchSettingsPath = Path.Combine(hostDir, "Properties", "launchSettings.json"); + + if (!File.Exists(launchSettingsPath)) + { + return [5001, 5000]; + } + + try + { + var json = File.ReadAllText(launchSettingsPath); + + // Extract applicationUrl values and parse ports from them. + // Format: "applicationUrl": "https://localhost:5001;http://localhost:5000" + // Use simple string parsing to avoid adding a JSON dependency to the CLI. + var searchKey = "\"applicationUrl\""; + var idx = json.IndexOf(searchKey, StringComparison.OrdinalIgnoreCase); + while (idx >= 0) + { + var colonIdx = json.IndexOf(':', idx + searchKey.Length); + if (colonIdx < 0) + { + break; + } + + var quoteStart = json.IndexOf('"', colonIdx + 1); + if (quoteStart < 0) + { + break; + } + + var quoteEnd = json.IndexOf('"', quoteStart + 1); + if (quoteEnd < 0) + { + break; + } + + var urlValue = json[(quoteStart + 1)..quoteEnd]; + foreach (var url in urlValue.Split(';')) + { + // Extract port from URL like "https://localhost:5001" + var lastColon = url.LastIndexOf(':'); + if ( + lastColon >= 0 + && int.TryParse( + url[(lastColon + 1)..], + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, + out var port + ) + ) + { + if (!ports.Contains(port)) + { + ports.Add(port); + } + } + } + + idx = json.IndexOf(searchKey, quoteEnd + 1, StringComparison.OrdinalIgnoreCase); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // If we can't parse, fall back to defaults + } +#pragma warning restore CA1031 + + return ports.Count > 0 ? ports : [5001, 5000]; + } +} diff --git a/cli/SimpleModule.Cli/Commands/Dev/DevSettings.cs b/cli/SimpleModule.Cli/Commands/Dev/DevSettings.cs new file mode 100644 index 00000000..832ebd17 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Dev/DevSettings.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Dev; + +public sealed class DevSettings : CommandSettings +{ + [CommandOption("--no-vite")] + [Description("Skip starting the Vite dev server (frontend only rebuilds via file watcher)")] + public bool NoVite { get; set; } + + [CommandOption("--no-dotnet")] + [Description("Skip starting the .NET backend (useful when running it separately)")] + public bool NoDotnet { get; set; } + + [CommandOption("--vite-port")] + [Description("Port for the Vite dev server (default: 5173)")] + [DefaultValue(5173)] + public int VitePort { get; set; } = 5173; +} diff --git a/cli/SimpleModule.Cli/Infrastructure/PortChecker.cs b/cli/SimpleModule.Cli/Infrastructure/PortChecker.cs new file mode 100644 index 00000000..08e0af01 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/PortChecker.cs @@ -0,0 +1,319 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using Spectre.Console; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Cross-platform utility to check if a TCP port is in use and optionally +/// kill the process occupying it. +/// +public static class PortChecker +{ + /// + /// Check if a port is in use. If it is, display the blocking process + /// and ask the user whether to kill it. + /// Returns true if the port is free (or was freed), false if still occupied. + /// + public static bool EnsurePortFree(int port, string serviceName) + { + var blocker = FindProcessOnPort(port); + if (blocker is null) + { + return true; + } + + AnsiConsole.MarkupLine( + $"[yellow][[{serviceName}]][/] Port [bold]{port}[/] is already in use by " + + $"[bold]{EscapeMarkup(blocker.Value.ProcessName)}[/] (PID {blocker.Value.Pid})" + ); + + var kill = AnsiConsole.Confirm( + $" Kill {EscapeMarkup(blocker.Value.ProcessName)} (PID {blocker.Value.Pid}) to free port {port}?", + defaultValue: true + ); + + if (!kill) + { + AnsiConsole.MarkupLine( + $"[yellow][[{serviceName}]][/] Port {port} still in use. Skipping." + ); + return false; + } + + if (KillProcess(blocker.Value.Pid)) + { + // Wait briefly for the port to be released + Thread.Sleep(500); + + // Verify port is now free + var stillBlocked = FindProcessOnPort(port); + if (stillBlocked is null) + { + AnsiConsole.MarkupLine( + $"[green][[{serviceName}]][/] Port {port} freed successfully." + ); + return true; + } + + AnsiConsole.MarkupLine( + $"[red][[{serviceName}]][/] Port {port} still in use after kill. " + + $"Blocked by {EscapeMarkup(stillBlocked.Value.ProcessName)} (PID {stillBlocked.Value.Pid})." + ); + return false; + } + + AnsiConsole.MarkupLine( + $"[red][[{serviceName}]][/] Failed to kill process {blocker.Value.Pid}." + ); + return false; + } + + /// + /// Find the process listening on a given TCP port. + /// Returns null if the port is free. + /// + public static PortBlocker? FindProcessOnPort(int port) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return FindOnWindows(port); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return FindWithLsof(port); + } + + // Linux: try ss first (faster), fall back to lsof + return FindWithSs(port) ?? FindWithLsof(port); + } + + /// + /// Linux: use `ss -tlnp` to find the listener. + /// Output format: LISTEN 0 128 *:5001 *:* users:(("dotnet",pid=12345,fd=3)) + /// + private static PortBlocker? FindWithSs(int port) + { + var output = RunCommand("ss", $"-tlnp sport = :{port}"); + if (output is null) + { + return null; + } + + // Parse lines looking for pid=NNNN and the process name + foreach (var line in output.Split('\n')) + { + if (!line.Contains($":{port}", StringComparison.Ordinal)) + { + continue; + } + + // Extract pid from users:(("name",pid=NNN,...)) + var pidIdx = line.IndexOf("pid=", StringComparison.Ordinal); + if (pidIdx < 0) + { + continue; + } + + var pidStart = pidIdx + 4; + var pidEnd = line.IndexOfAny([',', ')'], pidStart); + if (pidEnd < 0) + { + pidEnd = line.Length; + } + + var pidStr = line[pidStart..pidEnd]; + if ( + !int.TryParse( + pidStr, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var pid + ) + ) + { + continue; + } + + // Extract process name from (("name",...)) + var nameStart = line.IndexOf("((\"", StringComparison.Ordinal); + var processName = "unknown"; + if (nameStart >= 0) + { + nameStart += 3; + var nameEnd = line.IndexOf('"', nameStart); + if (nameEnd > nameStart) + { + processName = line[nameStart..nameEnd]; + } + } + + return new PortBlocker(pid, processName); + } + + return null; + } + + /// + /// macOS / Linux fallback: use `lsof -iTCP:PORT -sTCP:LISTEN -nP`. + /// Output: dotnet 12345 user 3u IPv6 0x... 0t0 TCP *:5001 (LISTEN) + /// + private static PortBlocker? FindWithLsof(int port) + { + var output = RunCommand("lsof", $"-iTCP:{port} -sTCP:LISTEN -nP -t"); + if (output is null) + { + return null; + } + + // -t flag outputs just PIDs, one per line + var firstLine = output.Split('\n', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + if ( + firstLine is null + || !int.TryParse( + firstLine.Trim(), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var pid + ) + ) + { + return null; + } + + var processName = GetProcessName(pid) ?? "unknown"; + return new PortBlocker(pid, processName); + } + + /// + /// Windows: use `netstat -ano` to find the listener, then get process name. + /// Output: TCP 0.0.0.0:5001 0.0.0.0:0 LISTENING 12345 + /// + private static PortBlocker? FindOnWindows(int port) + { + var output = RunCommand("netstat", "-ano"); + if (output is null) + { + return null; + } + + var portSuffix = $":{port}"; + foreach (var line in output.Split('\n')) + { + var trimmed = line.Trim(); + if ( + !trimmed.Contains("LISTENING", StringComparison.OrdinalIgnoreCase) + || !trimmed.Contains(portSuffix, StringComparison.Ordinal) + ) + { + continue; + } + + // Split by whitespace, last field is PID + var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 5) + { + continue; + } + + // Verify the port is in the local address column (2nd field) + if (!parts[1].EndsWith(portSuffix, StringComparison.Ordinal)) + { + continue; + } + + var pidStr = parts[^1]; + if ( + !int.TryParse( + pidStr, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var pid + ) + ) + { + continue; + } + + var processName = GetProcessName(pid) ?? "unknown"; + return new PortBlocker(pid, processName); + } + + return null; + } + + private static string? GetProcessName(int pid) + { + try + { + using var proc = Process.GetProcessById(pid); + return proc.ProcessName; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + return null; + } +#pragma warning restore CA1031 + } + + private static bool KillProcess(int pid) + { + try + { + using var proc = Process.GetProcessById(pid); + proc.Kill(entireProcessTree: true); + proc.WaitForExit(3000); + return proc.HasExited; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + return false; + } +#pragma warning restore CA1031 + } + + private static string? RunCommand(string fileName, string arguments) + { + try + { + using var process = Process.Start( + new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + } + ); + + if (process is null) + { + return null; + } + + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(5000); + return output; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + return null; + } +#pragma warning restore CA1031 + } + + private static string EscapeMarkup(string text) + { + return text.Replace("[", "[[", StringComparison.Ordinal) + .Replace("]", "]]", StringComparison.Ordinal); + } +} + +public readonly record struct PortBlocker(int Pid, string ProcessName); diff --git a/cli/SimpleModule.Cli/Program.cs b/cli/SimpleModule.Cli/Program.cs index ddb8be9b..3487db1f 100644 --- a/cli/SimpleModule.Cli/Program.cs +++ b/cli/SimpleModule.Cli/Program.cs @@ -1,4 +1,5 @@ -using SimpleModule.Cli.Commands.Doctor; +using SimpleModule.Cli.Commands.Dev; +using SimpleModule.Cli.Commands.Doctor; using SimpleModule.Cli.Commands.Install; using SimpleModule.Cli.Commands.New; using Spectre.Console.Cli; @@ -29,6 +30,12 @@ } ); + config + .AddCommand("dev") + .WithDescription( + "Start the development environment (dotnet watch + Vite dev server with HMR)" + ); + config .AddCommand("install") .WithDescription("Install a SimpleModule package from NuGet"); diff --git a/framework/SimpleModule.DevTools/DevToolsConstants.cs b/framework/SimpleModule.DevTools/DevToolsConstants.cs new file mode 100644 index 00000000..3eb24be1 --- /dev/null +++ b/framework/SimpleModule.DevTools/DevToolsConstants.cs @@ -0,0 +1,13 @@ +namespace SimpleModule.DevTools; + +/// +/// Shared constants between DevTools and Hosting to avoid stringly-typed coupling. +/// +public static class DevToolsConstants +{ + /// + /// HttpContext.Items key set by when the Vite + /// dev server is detected, read by the Inertia page renderer to switch HTML mode. + /// + public const string ViteDevServerKey = "ViteDevServer"; +} diff --git a/framework/SimpleModule.DevTools/DevToolsExtensions.cs b/framework/SimpleModule.DevTools/DevToolsExtensions.cs index 20f0fc36..4fe2d8e6 100644 --- a/framework/SimpleModule.DevTools/DevToolsExtensions.cs +++ b/framework/SimpleModule.DevTools/DevToolsExtensions.cs @@ -11,6 +11,7 @@ public static class DevToolsExtensions /// public static IServiceCollection AddDevTools(this IServiceCollection services) { + services.AddSingleton(); services.AddHostedService(); return services; } diff --git a/framework/SimpleModule.DevTools/LiveReloadServer.cs b/framework/SimpleModule.DevTools/LiveReloadServer.cs new file mode 100644 index 00000000..b50a18fc --- /dev/null +++ b/framework/SimpleModule.DevTools/LiveReloadServer.cs @@ -0,0 +1,254 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace SimpleModule.DevTools; + +/// +/// Manages WebSocket connections from browsers and broadcasts reload signals +/// when Vite builds or Tailwind CSS compilation completes. +/// +public sealed partial class LiveReloadServer : IDisposable +{ + private readonly ConcurrentDictionary _clients = new(); + private readonly ILogger _logger; + + public LiveReloadServer(ILogger logger) + { + _logger = logger; + } + + /// + /// Notifies all connected browsers to reload. Sends the build type + /// so the client can decide between a full reload or CSS-only swap. + /// + public async Task NotifyReloadAsync(ReloadType type, string source) + { + if (_clients.IsEmpty) + { + return; + } + + var message = JsonSerializer.Serialize( + new ReloadMessage { Type = type, Source = source }, + LiveReloadJsonContext.Default.ReloadMessage + ); + var buffer = Encoding.UTF8.GetBytes(message); + + LogBroadcasting(_logger, type, source, _clients.Count); + + // Send to all clients concurrently so a slow/stalled connection + // doesn't block the rest of the broadcast. + var sendTasks = new List(_clients.Count); + foreach (var (id, socket) in _clients) + { + if (socket.State != WebSocketState.Open) + { + RemoveClient(id); + continue; + } + + sendTasks.Add(SendToClientAsync(id, socket, buffer)); + } + + await Task.WhenAll(sendTasks).ConfigureAwait(false); + } + + private async Task SendToClientAsync(string id, WebSocket socket, byte[] buffer) + { + try + { + await socket + .SendAsync(buffer, WebSocketMessageType.Text, true, CancellationToken.None) + .ConfigureAwait(false); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 + { + LogSendFailed(_logger, ex, id); + RemoveClient(id); + } + } + + /// + /// Handles an incoming WebSocket connection from a browser. + /// Keeps the connection alive until the client disconnects. + /// + internal async Task HandleWebSocketAsync(WebSocket webSocket) + { + var clientId = Guid.NewGuid().ToString("N"); + _clients.TryAdd(clientId, webSocket); + LogClientConnected(_logger, clientId, _clients.Count); + + try + { + // Keep connection alive — read until close + var buffer = new byte[256]; + while (webSocket.State == WebSocketState.Open) + { + var result = await webSocket + .ReceiveAsync(buffer, CancellationToken.None) + .ConfigureAwait(false); + + if ( + result.MessageType == WebSocketMessageType.Close + || webSocket.State != WebSocketState.Open + ) + { + break; + } + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (WebSocketException) + { + // Client disconnected unexpectedly — this is normal + } +#pragma warning restore CA1031 + finally + { + RemoveClient(clientId); + LogClientDisconnected(_logger, clientId, _clients.Count); + + if (webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + try + { + await webSocket + .CloseAsync( + WebSocketCloseStatus.NormalClosure, + "Server closing", + CancellationToken.None + ) + .ConfigureAwait(false); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Best-effort close + } +#pragma warning restore CA1031 + } + } + } + + public void Dispose() + { + foreach (var (_, socket) in _clients) + { + socket.Dispose(); + } + + _clients.Clear(); + } + + private void RemoveClient(string id) + { + if (_clients.TryRemove(id, out var socket)) + { + socket.Dispose(); + } + } + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Live reload: broadcasting {Type} from {Source} to {ClientCount} client(s)" + )] + private static partial void LogBroadcasting( + ILogger logger, + ReloadType type, + string source, + int clientCount + ); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Live reload: client {ClientId} connected ({TotalClients} total)" + )] + private static partial void LogClientConnected( + ILogger logger, + string clientId, + int totalClients + ); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Live reload: client {ClientId} disconnected ({TotalClients} remaining)" + )] + private static partial void LogClientDisconnected( + ILogger logger, + string clientId, + int totalClients + ); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Live reload: failed to send to client {ClientId}" + )] + private static partial void LogSendFailed(ILogger logger, Exception ex, string clientId); +} + +/// +/// The type of reload to perform in the browser. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ReloadType +{ + /// Full page reload (JS/TSX changes). + Full, + + /// CSS-only hot swap (Tailwind/CSS changes). + CssOnly, +} + +public sealed class ReloadMessage +{ + [JsonPropertyName("type")] + public ReloadType Type { get; set; } + + [JsonPropertyName("source")] + public string Source { get; set; } = ""; +} + +[JsonSerializable(typeof(ReloadMessage))] +internal sealed partial class LiveReloadJsonContext : JsonSerializerContext; + +/// +/// Extension methods to wire up the live reload WebSocket endpoint. +/// +public static class LiveReloadEndpointExtensions +{ + private const string LiveReloadPath = "/dev/live-reload"; + + /// + /// Maps the /dev/live-reload WebSocket endpoint used by the browser + /// to receive reload signals during development. + /// + public static WebApplication MapLiveReload(this WebApplication app) + { + app.UseWebSockets(); + + app.Map( + LiveReloadPath, + async (HttpContext context, LiveReloadServer server) => + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + using var ws = await context.WebSockets.AcceptWebSocketAsync(); + await server.HandleWebSocketAsync(ws); + } + ); + + return app; + } +} diff --git a/framework/SimpleModule.DevTools/ViteDevMiddleware.cs b/framework/SimpleModule.DevTools/ViteDevMiddleware.cs new file mode 100644 index 00000000..867c75c4 --- /dev/null +++ b/framework/SimpleModule.DevTools/ViteDevMiddleware.cs @@ -0,0 +1,184 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace SimpleModule.DevTools; + +/// +/// Middleware that proxies development asset requests to the Vite dev server +/// and transforms HTML responses to use Vite HMR. +/// Only active when the Vite dev server is detected. +/// +public sealed partial class ViteDevMiddleware : IDisposable +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly int _vitePort; + + // Thread-safe Vite detection state (middleware is singleton) + private volatile bool _viteDetected; + private long _lastCheckTicks; + + private static readonly long CheckIntervalTicks = TimeSpan.FromSeconds(10).Ticks; + + /// Path prefixes that should be proxied to Vite dev server. + private static readonly string[] ViteProxyPrefixes = + [ + "/@vite/", + "/@react-refresh", + "/@id/", + "/@fs/", + "/node_modules/", + ]; + + public ViteDevMiddleware( + RequestDelegate next, + ILogger logger, + int vitePort = 5173 + ) + { + _next = next; + _logger = logger; + _vitePort = vitePort; + _httpClient = new HttpClient + { + BaseAddress = new Uri($"http://localhost:{vitePort}"), + Timeout = TimeSpan.FromSeconds(5), + }; + } + + public async Task InvokeAsync(HttpContext context) + { + var path = context.Request.Path.Value ?? ""; + + // Periodically check if Vite dev server is running. + // Uses Interlocked to prevent thundering-herd probes from concurrent requests. + var now = DateTime.UtcNow.Ticks; + var lastCheck = Interlocked.Read(ref _lastCheckTicks); + if (now - lastCheck > CheckIntervalTicks) + { + // Only one thread wins the CAS; losers use the stale value (fine for 10s interval) + if (Interlocked.CompareExchange(ref _lastCheckTicks, now, lastCheck) == lastCheck) + { + _viteDetected = await IsViteRunningAsync().ConfigureAwait(false); + if (_viteDetected) + { + LogViteDetected(_logger, _vitePort); + } + } + } + + if (!_viteDetected) + { + await _next(context).ConfigureAwait(false); + return; + } + + context.Items[DevToolsConstants.ViteDevServerKey] = true; + + if (ShouldProxy(path) || IsSourceFileRequest(path)) + { + await ProxyToViteAsync(context).ConfigureAwait(false); + return; + } + + await _next(context).ConfigureAwait(false); + } + + private static bool ShouldProxy(string path) + { + foreach (var prefix in ViteProxyPrefixes) + { + if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static bool IsSourceFileRequest(string path) + { + return path.EndsWith(".tsx", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".jsx", StringComparison.OrdinalIgnoreCase); + } + + private async Task ProxyToViteAsync(HttpContext context) + { + try + { + var requestUri = context.Request.Path.Value + context.Request.QueryString; + + using var proxyRequest = new HttpRequestMessage(HttpMethod.Get, requestUri); + + if (context.Request.Headers.TryGetValue("Accept", out var accept)) + { + proxyRequest.Headers.TryAddWithoutValidation("Accept", accept.ToString()); + } + + using var response = await _httpClient + .SendAsync(proxyRequest, HttpCompletionOption.ResponseHeadersRead) + .ConfigureAwait(false); + + context.Response.StatusCode = (int)response.StatusCode; + + if (response.Content.Headers.ContentType is not null) + { + context.Response.ContentType = response.Content.Headers.ContentType.ToString(); + } + + foreach (var header in response.Headers) + { + if (header.Key.StartsWith("Access-Control", StringComparison.OrdinalIgnoreCase)) + { + context.Response.Headers[header.Key] = header.Value.ToArray(); + } + } + + await response.Content.CopyToAsync(context.Response.Body).ConfigureAwait(false); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 + { + LogProxyFailed(_logger, ex, context.Request.Path.Value ?? ""); + context.Response.StatusCode = StatusCodes.Status502BadGateway; + } + } + + private async Task IsViteRunningAsync() + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Head, "/"); + using var response = await _httpClient + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead) + .ConfigureAwait(false); + return true; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + return false; + } +#pragma warning restore CA1031 + } + + public void Dispose() + { + _httpClient.Dispose(); + } + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Vite dev server detected on port {Port}" + )] + private static partial void LogViteDetected(ILogger logger, int port); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Failed to proxy request to Vite dev server: {Path}" + )] + private static partial void LogProxyFailed(ILogger logger, Exception ex, string path); +} diff --git a/framework/SimpleModule.DevTools/ViteDevWatchService.cs b/framework/SimpleModule.DevTools/ViteDevWatchService.cs index dc133de3..e6bbf525 100644 --- a/framework/SimpleModule.DevTools/ViteDevWatchService.cs +++ b/framework/SimpleModule.DevTools/ViteDevWatchService.cs @@ -13,7 +13,8 @@ namespace SimpleModule.DevTools; /// public sealed partial class ViteDevWatchService( ILogger logger, - IHostEnvironment environment + IHostEnvironment environment, + LiveReloadServer liveReloadServer ) : BackgroundService { private static readonly string[] FrontendExtensions = @@ -163,7 +164,12 @@ void OnChanged(object sender, FileSystemEventArgs e) _watchers.Add(watcher); } - private async Task RunBuild(string name, string command, string workingDir) + private async Task RunBuild( + string name, + string command, + string workingDir, + ReloadType reloadType = ReloadType.Full + ) { LogRebuilding(logger, name); var stopwatch = Stopwatch.StartNew(); @@ -181,6 +187,7 @@ private async Task RunBuild(string name, string command, string workingDir) if (success) { LogRebuiltSuccessfully(logger, name, stopwatch.ElapsedMilliseconds); + await liveReloadServer.NotifyReloadAsync(reloadType, name).ConfigureAwait(false); } else { @@ -200,7 +207,7 @@ private async Task RunTailwindBuild() var tailwindCli = Path.Combine(_npmBinPath, tailwindBin); var command = $"\"{tailwindCli}\" -i \"{inputPath}\" -o \"{outputPath}\""; - await RunBuild("Tailwind", command, hostDir).ConfigureAwait(false); + await RunBuild("Tailwind", command, hostDir, ReloadType.CssOnly).ConfigureAwait(false); } private void DebouncedBuild(string buildKey, Func buildAction) diff --git a/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs b/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs index 819c05f6..80ece8fe 100644 --- a/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs +++ b/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs @@ -1,8 +1,10 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using SimpleModule.Core.Inertia; using SimpleModule.Core.Security; +using SimpleModule.DevTools; namespace SimpleModule.Hosting.Inertia; @@ -13,6 +15,9 @@ public sealed class HtmlFileInertiaPageRenderer : IInertiaPageRenderer private readonly string _beforePlaceholder; private readonly string _afterPlaceholder; + private readonly string _beforePlaceholderViteDev; + private readonly string _afterPlaceholderViteDev; + private readonly bool _isDevelopment; public HtmlFileInertiaPageRenderer(IWebHostEnvironment env) { @@ -27,19 +32,133 @@ public HtmlFileInertiaPageRenderer(IWebHostEnvironment env) _beforePlaceholder = html[..idx]; _afterPlaceholder = html[(idx + PagePlaceholder.Length)..]; + _isDevelopment = env.IsDevelopment(); + + if (_isDevelopment) + { + _beforePlaceholderViteDev = TransformForViteDev(_beforePlaceholder); + _afterPlaceholderViteDev = TransformForViteDev(_afterPlaceholder) + .Replace( + "", + ViteEntryPlaceholder, + StringComparison.Ordinal + ); + } + else + { + _beforePlaceholderViteDev = _beforePlaceholder; + _afterPlaceholderViteDev = _afterPlaceholder; + } } public Task RenderPageAsync(HttpContext httpContext, string pageJson) { var nonce = httpContext.RequestServices.GetRequiredService().Value; + var useViteDev = + _isDevelopment && httpContext.Items.ContainsKey(DevToolsConstants.ViteDevServerKey); + + var before = useViteDev ? _beforePlaceholderViteDev : _beforePlaceholder; + var after = useViteDev ? _afterPlaceholderViteDev : _afterPlaceholder; + var devScript = + _isDevelopment && !useViteDev + ? "" + : ""; httpContext.Response.ContentType = "text/html; charset=utf-8"; return httpContext.Response.WriteAsync( string.Concat( - _beforePlaceholder.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal), + before.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal), $"", - _afterPlaceholder.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal) + devScript, + after.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal) ) ); } + + private static string TransformForViteDev(string html) + { + var importMapStart = html.IndexOf("", importMapStart, StringComparison.Ordinal); + if (importMapEnd >= 0) + { + html = string.Concat( + html.AsSpan(0, importMapStart), + html.AsSpan(importMapEnd + "".Length) + ); + } + } + + html = html.Replace( + "", + "", + StringComparison.Ordinal + ); + + return html; + } + + /// + /// Placeholder for Vite entry scripts — nonce is injected at render time + /// via the replacement. + /// + private const string ViteEntryPlaceholder = + "\n" + + " "; + + private const string LiveReloadClientScript = """ + (function(){ + var protocol=location.protocol==='https:'?'wss:':'ws:'; + var url=protocol+'//'+location.host+'/dev/live-reload'; + var reconnectDelay=1000; + var maxReconnectDelay=10000; + var cssVersion=0; + + function connect(){ + var ws=new WebSocket(url); + ws.onopen=function(){ + console.log('[LiveReload] Connected'); + reconnectDelay=1000; + }; + ws.onmessage=function(event){ + try{ + var msg=JSON.parse(event.data); + if(msg.type==='CssOnly'){ + reloadCss(msg.source); + }else{ + console.log('[LiveReload] Reloading ('+msg.source+')'); + location.reload(); + } + }catch(e){ + location.reload(); + } + }; + ws.onclose=function(){ + console.log('[LiveReload] Disconnected, reconnecting in '+(reconnectDelay/1000)+'s...'); + setTimeout(connect,reconnectDelay); + reconnectDelay=Math.min(reconnectDelay*2,maxReconnectDelay); + }; + } + + function reloadCss(source){ + cssVersion++; + var links=document.querySelectorAll('link[rel="stylesheet"]'); + links.forEach(function(link){ + var href=link.getAttribute('href'); + if(href){ + var base=href.split('?')[0]; + link.href=base+'?v='+cssVersion; + } + }); + console.log('[LiveReload] CSS refreshed ('+source+')'); + } + + connect(); + })(); + """; } diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 5aef2348..bec11c48 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -150,6 +150,7 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) } app.UseHttpsRedirection(); + var isDevelopment = app.Environment.IsDevelopment(); app.Use( async (context, next) => { @@ -162,12 +163,14 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) headers["X-Frame-Options"] = "SAMEORIGIN"; headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; headers["X-Permitted-Cross-Domain-Policies"] = "none"; + // In development, allow WebSocket connections for live reload + var connectSrc = isDevelopment ? "'self' ws: wss:" : "'self'"; var csp = $"default-src 'none'; " + $"script-src 'self' 'nonce-{nonce}'; " + $"style-src 'self' 'unsafe-inline' fonts.googleapis.com; " + $"font-src 'self' fonts.gstatic.com; " - + $"connect-src 'self'; " + + $"connect-src {connectSrc}; " + $"img-src 'self' data:; " + $"object-src 'none'; " + $"base-uri 'self'; " @@ -184,6 +187,14 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) await next(); } ); + // Vite dev server proxy — intercepts /@vite/, /@fs/, .tsx requests and + // proxies them to the Vite dev server. Also sets HttpContext.Items["ViteDevServer"] + // so downstream middleware (Inertia renderer) can adapt the HTML. + if (options.EnableDevTools && isDevelopment) + { + app.UseMiddleware(); + } + app.UseInertia(); UseStaticFileCaching(app); app.MapStaticAssets(); @@ -202,6 +213,11 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) app.UseSimpleModuleRateLimiting(); app.UseMiddleware(); + if (options.EnableDevTools && app.Environment.IsDevelopment()) + { + app.MapLiveReload(); + } + // Module middleware is added by the source-generated UseSimpleModule() // via IModule.ConfigureMiddleware() calls. diff --git a/packages/SimpleModule.Client/package.json b/packages/SimpleModule.Client/package.json index dabe0409..3ad38e55 100644 --- a/packages/SimpleModule.Client/package.json +++ b/packages/SimpleModule.Client/package.json @@ -6,6 +6,7 @@ "exports": { ".": "./src/index.ts", "./vite": "./src/vite-plugin-vendor.ts", + "./vite-hmr": "./src/vite-plugin-module-hmr.ts", "./module": "./src/define-module-config.ts", "./resolve-page": "./src/resolve-page.ts", "./use-translation": "./src/use-translation.ts" diff --git a/packages/SimpleModule.Client/src/vite-plugin-module-hmr.ts b/packages/SimpleModule.Client/src/vite-plugin-module-hmr.ts new file mode 100644 index 00000000..c779fda5 --- /dev/null +++ b/packages/SimpleModule.Client/src/vite-plugin-module-hmr.ts @@ -0,0 +1,113 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { Plugin, ViteDevServer } from 'vite'; + +interface ModuleEntry { + /** Module name, e.g. "Products" */ + name: string; + /** Assembly name, e.g. "SimpleModule.Products" */ + assemblyName: string; + /** Absolute path to the module source, e.g. /repo/modules/Products/src/SimpleModule.Products */ + dir: string; + /** Absolute path to Pages/index.ts */ + pagesEntry: string; +} + +/** + * Discovers all SimpleModule modules that have a Pages/index.ts entry. + */ +function discoverModules(repoRoot: string): ModuleEntry[] { + const modulesDir = resolve(repoRoot, 'modules'); + const entries: ModuleEntry[] = []; + + if (!existsSync(modulesDir)) return entries; + + for (const group of readdirSync(modulesDir, { withFileTypes: true })) { + if (!group.isDirectory()) continue; + const srcDir = resolve(modulesDir, group.name, 'src'); + if (!existsSync(srcDir)) continue; + + for (const mod of readdirSync(srcDir, { withFileTypes: true })) { + if (!mod.isDirectory()) continue; + const pagesEntry = resolve(srcDir, mod.name, 'Pages', 'index.ts'); + if (!existsSync(pagesEntry)) continue; + + const assemblyName = mod.name; + // Derive the short module name (e.g. "SimpleModule.Products" → "Products") + const name = assemblyName.startsWith('SimpleModule.') + ? assemblyName.slice('SimpleModule.'.length) + : assemblyName; + + entries.push({ + name, + assemblyName, + dir: resolve(srcDir, mod.name), + pagesEntry, + }); + } + } + + return entries; +} + +/** + * Vite plugin that enables HMR for SimpleModule module pages. + * + * In production, module pages are loaded from static files: + * /_content/SimpleModule.Products/SimpleModule.Products.pages.js + * + * In dev mode, this plugin resolves those paths to the actual source files + * (e.g. modules/Products/src/SimpleModule.Products/Pages/index.ts) so Vite + * can serve them with full HMR / React Fast Refresh support. + */ +export function moduleHmrPlugin(repoRoot: string): Plugin { + let modules: ModuleEntry[] = []; + + return { + name: 'simplemodule:module-hmr', + enforce: 'pre', + + configResolved() { + modules = discoverModules(repoRoot); + }, + + configureServer(server: ViteDevServer) { + // Intercept /_content/ requests and rewrite to source files + server.middlewares.use((req, _res, next) => { + if (!req.url?.startsWith('/_content/')) return next(); + + for (const mod of modules) { + const prefix = `/_content/${mod.assemblyName}/${mod.assemblyName}.pages.js`; + if (req.url === prefix || req.url.startsWith(`${prefix}?`)) { + // Rewrite to the Pages/index.ts source file + req.url = `/@fs/${mod.pagesEntry}`; + return next(); + } + + // Handle module CSS requests + const cssPrefix = `/_content/${mod.assemblyName}/${mod.assemblyName.toLowerCase()}.css`; + if (req.url === cssPrefix || req.url.startsWith(`${cssPrefix}?`)) { + const cssPath = resolve(mod.dir, 'Pages', 'styles.css'); + if (existsSync(cssPath)) { + req.url = `/@fs/${cssPath}`; + } + return next(); + } + } + + next(); + }); + }, + + resolveId(source: string) { + // Handle virtual /_content/ imports + for (const mod of modules) { + const prefix = `/_content/${mod.assemblyName}/${mod.assemblyName}.pages.js`; + if (source === prefix || source.startsWith(`${prefix}?`)) { + return mod.pagesEntry; + } + } + return undefined; + }, + }; +} diff --git a/template/SimpleModule.Host/ClientApp/app.tsx b/template/SimpleModule.Host/ClientApp/app.tsx index 48f58be3..d7ca127d 100644 --- a/template/SimpleModule.Host/ClientApp/app.tsx +++ b/template/SimpleModule.Host/ClientApp/app.tsx @@ -3,6 +3,13 @@ import { resolvePage } from '@simplemodule/client/resolve-page'; import { resolveLayout } from '@simplemodule/ui/layouts'; import { createRoot } from 'react-dom/client'; +// In Vite dev server mode, import the Tailwind CSS entry so that +// @tailwindcss/vite can serve it with HMR. In production builds, +// Tailwind is compiled separately and included via tag. +if (import.meta.hot) { + import('../Styles/app.css'); +} + // Navigation progress bar — 150ms delay so instant navigations don't flash const PROGRESS_DELAY = 150; const PROGRESS_FILL_PAUSE = 200; diff --git a/template/SimpleModule.Host/ClientApp/vite.dev.config.ts b/template/SimpleModule.Host/ClientApp/vite.dev.config.ts new file mode 100644 index 00000000..a0d35cf2 --- /dev/null +++ b/template/SimpleModule.Host/ClientApp/vite.dev.config.ts @@ -0,0 +1,34 @@ +import path from 'node:path'; +import { moduleHmrPlugin } from '@simplemodule/client/vite-hmr'; +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +const repoRoot = path.resolve(import.meta.dirname, '../../..'); + +export default defineConfig({ + plugins: [react(), tailwindcss(), moduleHmrPlugin(repoRoot)], + root: import.meta.dirname, + resolve: { + alias: { + '@': import.meta.dirname, + }, + }, + server: { + port: 5173, + strictPort: true, + cors: true, + // Ensure HMR works when accessed through ASP.NET proxy + hmr: { + port: 5173, + }, + }, + // In dev server mode, CSS entry is loaded through the main app entry. + // Tailwind scans source files via the @source directives in the CSS. + css: { + transformer: 'lightningcss', + }, + optimizeDeps: { + include: ['react', 'react-dom', 'react/jsx-runtime', 'react-dom/client', '@inertiajs/react'], + }, +}); diff --git a/tests/SimpleModule.DevTools.Tests/LiveReloadServerTests.cs b/tests/SimpleModule.DevTools.Tests/LiveReloadServerTests.cs new file mode 100644 index 00000000..dabe01b8 --- /dev/null +++ b/tests/SimpleModule.DevTools.Tests/LiveReloadServerTests.cs @@ -0,0 +1,82 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; + +namespace SimpleModule.DevTools.Tests; + +public sealed class LiveReloadServerTests : IDisposable +{ + private readonly LiveReloadServer _server = new(NullLogger.Instance); + + public void Dispose() + { + _server.Dispose(); + } + + [Fact] + public async Task NotifyReloadAsync_Does_Not_Throw_When_No_Clients() + { + // Should be a no-op with zero connected clients + await _server.NotifyReloadAsync(ReloadType.Full, "test"); + } + + [Fact] + public async Task NotifyReloadAsync_Does_Not_Throw_For_CssOnly() + { + await _server.NotifyReloadAsync(ReloadType.CssOnly, "Tailwind"); + } + + [Fact] + public void Dispose_Is_Idempotent() + { + var server = new LiveReloadServer(NullLogger.Instance); + server.Dispose(); + server.Dispose(); + } +} + +public sealed class ReloadMessageSerializationTests +{ + [Fact] + public void ReloadMessage_Serializes_Full_Type() + { + var message = new ReloadMessage { Type = ReloadType.Full, Source = "Products" }; + var json = System.Text.Json.JsonSerializer.Serialize( + message, + LiveReloadJsonContext.Default.ReloadMessage + ); + + json.Should().Contain("\"type\":\"Full\""); + json.Should().Contain("\"source\":\"Products\""); + } + + [Fact] + public void ReloadMessage_Serializes_CssOnly_Type() + { + var message = new ReloadMessage { Type = ReloadType.CssOnly, Source = "Tailwind" }; + var json = System.Text.Json.JsonSerializer.Serialize( + message, + LiveReloadJsonContext.Default.ReloadMessage + ); + + json.Should().Contain("\"type\":\"CssOnly\""); + json.Should().Contain("\"source\":\"Tailwind\""); + } + + [Fact] + public void ReloadMessage_Deserializes_Roundtrip() + { + var original = new ReloadMessage { Type = ReloadType.Full, Source = "ClientApp" }; + var json = System.Text.Json.JsonSerializer.Serialize( + original, + LiveReloadJsonContext.Default.ReloadMessage + ); + var deserialized = System.Text.Json.JsonSerializer.Deserialize( + json, + LiveReloadJsonContext.Default.ReloadMessage + ); + + deserialized.Should().NotBeNull(); + deserialized!.Type.Should().Be(ReloadType.Full); + deserialized.Source.Should().Be("ClientApp"); + } +} diff --git a/tests/SimpleModule.DevTools.Tests/ViteDevWatchServiceTests.cs b/tests/SimpleModule.DevTools.Tests/ViteDevWatchServiceTests.cs index 8e92196e..6ac09df6 100644 --- a/tests/SimpleModule.DevTools.Tests/ViteDevWatchServiceTests.cs +++ b/tests/SimpleModule.DevTools.Tests/ViteDevWatchServiceTests.cs @@ -300,6 +300,8 @@ public sealed class ServiceLifecycleTests : IDisposable $"devtools-test-{Guid.NewGuid():N}" ); + private readonly LiveReloadServer _liveReload = new(NullLogger.Instance); + public ServiceLifecycleTests() { Directory.CreateDirectory(_tempDir); @@ -307,6 +309,7 @@ public ServiceLifecycleTests() public void Dispose() { + _liveReload.Dispose(); if (Directory.Exists(_tempDir)) { Directory.Delete(_tempDir, recursive: true); @@ -317,7 +320,11 @@ public void Dispose() public async Task ExecuteAsync_Returns_When_No_Git_Root_Found() { var env = new FakeHostEnvironment(_tempDir); - using var service = new ViteDevWatchService(NullLogger.Instance, env); + using var service = new ViteDevWatchService( + NullLogger.Instance, + env, + _liveReload + ); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); await service.StartAsync(cts.Token); @@ -340,7 +347,11 @@ public async Task ExecuteAsync_Discovers_Modules_And_Sets_Up_Watchers() Directory.CreateDirectory(hostDir); var env = new FakeHostEnvironment(hostDir); - using var service = new ViteDevWatchService(NullLogger.Instance, env); + using var service = new ViteDevWatchService( + NullLogger.Instance, + env, + _liveReload + ); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); await service.StartAsync(cts.Token); @@ -366,7 +377,11 @@ public async Task Service_Shuts_Down_Cleanly_On_Cancellation() Directory.CreateDirectory(hostDir); var env = new FakeHostEnvironment(hostDir); - using var service = new ViteDevWatchService(NullLogger.Instance, env); + using var service = new ViteDevWatchService( + NullLogger.Instance, + env, + _liveReload + ); using var cts = new CancellationTokenSource(); await service.StartAsync(cts.Token); @@ -384,7 +399,11 @@ public async Task Service_Shuts_Down_Cleanly_On_Cancellation() public async Task Dispose_Is_Idempotent() { var env = new FakeHostEnvironment(_tempDir); - var service = new ViteDevWatchService(NullLogger.Instance, env); + var service = new ViteDevWatchService( + NullLogger.Instance, + env, + _liveReload + ); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); await service.StartAsync(cts.Token); @@ -403,6 +422,8 @@ public sealed class FileWatcherIntegrationTests : IDisposable $"devtools-test-{Guid.NewGuid():N}" ); + private readonly LiveReloadServer _liveReload = new(NullLogger.Instance); + public FileWatcherIntegrationTests() { Directory.CreateDirectory(_tempDir); @@ -410,6 +431,7 @@ public FileWatcherIntegrationTests() public void Dispose() { + _liveReload.Dispose(); if (Directory.Exists(_tempDir)) { Directory.Delete(_tempDir, recursive: true); @@ -426,7 +448,11 @@ public async Task Watches_ClientApp_When_Directory_Exists() Directory.CreateDirectory(clientAppDir); var env = new FakeHostEnvironment(hostDir); - using var service = new ViteDevWatchService(NullLogger.Instance, env); + using var service = new ViteDevWatchService( + NullLogger.Instance, + env, + _liveReload + ); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); await service.StartAsync(cts.Token); @@ -449,7 +475,11 @@ public async Task Watches_Styles_When_Directory_Exists() Directory.CreateDirectory(stylesDir); var env = new FakeHostEnvironment(hostDir); - using var service = new ViteDevWatchService(NullLogger.Instance, env); + using var service = new ViteDevWatchService( + NullLogger.Instance, + env, + _liveReload + ); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); await service.StartAsync(cts.Token); @@ -472,7 +502,11 @@ public async Task Skips_ClientApp_When_Directory_Missing() // Intentionally do NOT create ClientApp/ var env = new FakeHostEnvironment(hostDir); - using var service = new ViteDevWatchService(NullLogger.Instance, env); + using var service = new ViteDevWatchService( + NullLogger.Instance, + env, + _liveReload + ); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); await service.StartAsync(cts.Token); diff --git a/tools/dev-orchestrator.mjs b/tools/dev-orchestrator.mjs index 0b848543..1d0aa824 100644 --- a/tools/dev-orchestrator.mjs +++ b/tools/dev-orchestrator.mjs @@ -1,119 +1,412 @@ -import { spawn } from 'child_process'; +import { spawn, execFileSync } from 'child_process'; import path from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; +import { readdirSync, readFileSync, existsSync } from 'fs'; +import * as readline from 'readline'; import { createLogger, discoverModulesWithVite } from './orchestrator-utils.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); +const isWindows = process.platform === 'win32'; // Auto-discover all module workspace paths const modules = discoverModulesWithVite(rootDir); const childProcesses = []; const log = createLogger(); +let shuttingDown = false; -function startDotnetRun() { - log('setup', 'Starting dotnet run...'); - const proc = spawn('dotnet', ['run', '--no-restore', '--project', 'template/SimpleModule.Host'], { - cwd: rootDir, +// --------------------------------------------------------------------------- +// Process spawning +// +// Key: use `detached: true` on Unix so each child gets its own process group. +// This lets us `process.kill(-pid, signal)` to signal the entire tree +// (the child + all its descendants). +// +// On Windows `detached: true` opens a new console window, so we skip it +// and rely on taskkill /T for tree cleanup instead. +// --------------------------------------------------------------------------- + +function spawnChild(command, args, options, label) { + const proc = spawn(command, args, { + cwd: options.cwd ?? rootDir, stdio: 'inherit', - shell: true, + // Unix: new process group so we can signal the whole tree later. + // Windows: no detach (avoids spawning a new console window). + detached: !isWindows, + // Avoid shell: true — it creates an intermediary sh/cmd process that + // swallows signals and makes tree cleanup unreliable. Instead, resolve + // the binary directly. + shell: false, }); proc.on('error', (err) => { - log('dotnet', `Critical error: ${err.message}`); - log('error', 'Failed to start dotnet backend. Shutting down.'); - process.exit(1); + log(label, `Error: ${err.message}`); }); + childProcesses.push({ proc, label }); + + if (proc.pid) { + log('setup', `${label} started (PID ${proc.pid})`); + } + + // Unref so the parent doesn't wait on children when exiting + // (we handle cleanup explicitly in shutdown) + if (!isWindows) { + proc.unref(); + } + + return proc; +} + +function startDotnetRun() { + log('setup', 'Starting dotnet run...'); + const proc = spawnChild( + 'dotnet', + ['run', '--no-restore', '--project', 'template/SimpleModule.Host'], + { cwd: rootDir }, + 'dotnet', + ); + proc.on('exit', (code) => { - if (code !== 0) { + if (code !== 0 && !shuttingDown) { log('dotnet', `Exited with code ${code}`); log('error', 'Backend process terminated unexpectedly. Shutting down.'); - process.exit(1); + shutdown(1); } }); - childProcesses.push(proc); return proc; } function startModuleWatch(modulePath) { - const moduleName = path.basename(path.dirname(modulePath)); + const moduleName = path.basename(modulePath); log('setup', `Starting watch for ${moduleName}...`); - const proc = spawn('npm', ['run', 'watch'], { - cwd: path.resolve(rootDir, modulePath), - stdio: 'inherit', - shell: true, - }); - - proc.on('error', (err) => { - log(moduleName, `Error: ${err.message}`); - // Module watch failure is non-fatal; continue running other watches - }); + // Resolve npx binary path to avoid shell: true + const npxBin = isWindows ? 'npx.cmd' : 'npx'; + const proc = spawnChild( + npxBin, + ['vite', 'build', '--configLoader', 'runner', '--watch'], + { cwd: path.resolve(rootDir, modulePath) }, + moduleName, + ); proc.on('exit', (code) => { - if (code !== 0 && code !== null) { + if (code !== 0 && code !== null && !shuttingDown) { log(moduleName, `Watch exited with non-zero code ${code}`); } }); - childProcesses.push(proc); return proc; } function startClientAppWatch() { log('setup', 'Starting ClientApp watch...'); - const proc = spawn('npm', ['run', 'watch'], { - cwd: path.resolve(rootDir, 'template/SimpleModule.Host/ClientApp'), - stdio: 'inherit', - shell: true, - }); - - proc.on('error', (err) => { - log('ClientApp', `Error: ${err.message}`); - // ClientApp watch failure is non-fatal; continue running backend and other modules - }); + const npxBin = isWindows ? 'npx.cmd' : 'npx'; + const proc = spawnChild( + npxBin, + ['vite', 'build', '--configLoader', 'runner', '--watch'], + { cwd: path.resolve(rootDir, 'template/SimpleModule.Host/ClientApp') }, + 'ClientApp', + ); proc.on('exit', (code) => { - if (code !== 0 && code !== null) { + if (code !== 0 && code !== null && !shuttingDown) { log('ClientApp', `Watch exited with non-zero code ${code}`); } }); - childProcesses.push(proc); return proc; } -function shutdown() { +// --------------------------------------------------------------------------- +// Process cleanup +// --------------------------------------------------------------------------- + +/** + * Graceful termination for a single child. + * + * Unix: `process.kill(-pid, 'SIGTERM')` — signals the entire process group + * because we spawned with `detached: true` (child is a group leader). + * + * Windows: `taskkill /PID /T` — walks the process tree. + * (no /F = graceful WM_CLOSE) + */ +function killGraceful(proc, label) { + try { + if (proc.exitCode !== null) return; + + if (isWindows) { + spawn('taskkill', ['/PID', String(proc.pid), '/T'], { + stdio: 'ignore', + }); + } else { + // Negative PID = signal the process group (works because detached: true) + process.kill(-proc.pid, 'SIGTERM'); + } + } catch (err) { + // ESRCH: process/group already exited — that's fine + if (err.code !== 'ESRCH') { + log(label, `Warning: Failed to terminate (PID ${proc.pid}): ${err.message}`); + } + } +} + +/** + * Force-kill a single child and its entire tree. + * + * Unix: SIGKILL to the process group. + * Windows: taskkill /T /F (force). + */ +function killForce(proc, label) { + try { + if (proc.exitCode !== null) return; + + if (isWindows) { + spawn('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { + stdio: 'ignore', + }); + } else { + process.kill(-proc.pid, 'SIGKILL'); + } + } catch (err) { + if (err.code !== 'ESRCH') { + log(label, `Warning: Force-kill failed (PID ${proc.pid}): ${err.message}`); + } + } +} + +function forceKillAll() { + for (const { proc, label } of childProcesses) { + killForce(proc, label); + } +} + +function shutdown(exitCode = 0) { + if (shuttingDown) return; + shuttingDown = true; + log('shutdown', 'Stopping all processes...'); - childProcesses.forEach((proc) => { - try { - proc.kill('SIGTERM'); - } catch (err) { - log('shutdown', `Warning: Failed to terminate process: ${err.message}`); + + // Phase 1: Graceful termination (SIGTERM / WM_CLOSE) + for (const { proc, label } of childProcesses) { + killGraceful(proc, label); + } + + // Phase 2: Wait 3s, then force-kill survivors + setTimeout(() => { + const survivors = childProcesses.filter(({ proc }) => proc.exitCode === null); + if (survivors.length > 0) { + log('shutdown', `Force-killing ${survivors.length} remaining process(es)...`); + for (const { proc, label } of survivors) { + killForce(proc, label); + } } - }); - setTimeout(() => process.exit(0), 500); + + // Phase 3: Final exit after a short grace period + setTimeout(() => { + const stillAlive = childProcesses.filter(({ proc }) => proc.exitCode === null); + if (stillAlive.length > 0) { + log('shutdown', `Warning: ${stillAlive.length} process(es) may still be running:`); + for (const { proc, label } of stillAlive) { + log('shutdown', ` ${label} (PID ${proc.pid})`); + } + } + process.exit(exitCode); + }, 2000); + }, 3000); } +// --------------------------------------------------------------------------- // Allow syntax check +// --------------------------------------------------------------------------- if (process.argv.includes('--check')) { log('check', 'Syntax valid'); log('check', `Discovered ${modules.length} modules: ${modules.join(', ')}`); process.exit(0); } -// Handle signals -process.on('SIGINT', shutdown); -process.on('SIGTERM', shutdown); +// --------------------------------------------------------------------------- +// Signal handlers +// +// SIGINT: Ctrl+C in terminal +// SIGTERM: kill , Docker stop, systemd, etc. +// SIGHUP: terminal closed (Linux/macOS only, doesn't exist on Windows) +// --------------------------------------------------------------------------- +process.on('SIGINT', () => shutdown(0)); +process.on('SIGTERM', () => shutdown(0)); +if (!isWindows) { + process.on('SIGHUP', () => shutdown(0)); +} + +// Safety net: force-kill children when this process exits. +// Note: `process.on('exit')` callbacks run synchronously — no async. +// `process.kill()` is synchronous, so this works. `spawn()` would not. +process.on('exit', () => { + for (const { proc, label } of childProcesses) { + try { + if (proc.exitCode === null) { + if (isWindows) { + // On Windows in 'exit' handler, we can't spawn new processes reliably. + // Use proc.kill() as best-effort (only kills direct child, not tree). + proc.kill('SIGTERM'); + } else { + // Unix: kill the process group synchronously + process.kill(-proc.pid, 'SIGKILL'); + } + } + } catch { + // ESRCH or already exited — ignore + } + } +}); + +// Handle uncaught exceptions — kill children before crashing +process.on('uncaughtException', (err) => { + log('error', `Uncaught exception: ${err.message}`); + forceKillAll(); + process.exit(1); +}); + +// --------------------------------------------------------------------------- +// Port checking +// --------------------------------------------------------------------------- +/** + * Find the PID and process name listening on a TCP port. + * Returns { pid, name } or null if port is free. + * Works on Linux (ss), macOS (lsof), and Windows (netstat). + */ +function findProcessOnPort(port) { + try { + if (isWindows) { + const out = execFileSync('netstat', ['-ano'], { encoding: 'utf8', timeout: 5000 }); + for (const line of out.split('\n')) { + const trimmed = line.trim(); + if (!trimmed.includes('LISTENING') || !trimmed.includes(`:${port}`)) continue; + const parts = trimmed.split(/\s+/); + if (parts.length < 5 || !parts[1].endsWith(`:${port}`)) continue; + const pid = parseInt(parts[parts.length - 1], 10); + if (isNaN(pid)) continue; + let name = 'unknown'; + try { + name = execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { + encoding: 'utf8', + timeout: 3000, + }) + .split(',')[0] + ?.replace(/"/g, '') || 'unknown'; + } catch {} + return { pid, name }; + } + } else { + // Try lsof (works on Linux + macOS) + const pidStr = execFileSync('lsof', ['-iTCP:' + port, '-sTCP:LISTEN', '-nP', '-t'], { + encoding: 'utf8', + timeout: 5000, + }).trim(); + const pid = parseInt(pidStr.split('\n')[0], 10); + if (isNaN(pid)) return null; + let name = 'unknown'; + try { + if (process.platform === 'linux') { + name = readFileSync(`/proc/${pid}/comm`, 'utf8').trim(); + } else { + name = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], { + encoding: 'utf8', + timeout: 3000, + }).trim(); + } + } catch {} + return { pid, name }; + } + } catch { + // Command failed = port is free (lsof exits non-zero when nothing found) + } + return null; +} + +/** + * Prompt the user to kill a process blocking a port. + * Returns true if port is free (or was freed), false otherwise. + */ +function ensurePortFree(port, label) { + const blocker = findProcessOnPort(port); + if (!blocker) return true; + + log(label, `Port ${port} is in use by ${blocker.name} (PID ${blocker.pid})`); + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(` Kill ${blocker.name} (PID ${blocker.pid}) to free port ${port}? [Y/n] `, (answer) => { + rl.close(); + const yes = !answer || answer.toLowerCase().startsWith('y'); + if (!yes) { + log(label, `Port ${port} still in use. Aborting.`); + resolve(false); + return; + } + + try { + process.kill(blocker.pid, 'SIGKILL'); + } catch (err) { + log(label, `Failed to kill PID ${blocker.pid}: ${err.message}`); + resolve(false); + return; + } + + // Wait briefly for port release + setTimeout(() => { + const still = findProcessOnPort(port); + if (still) { + log(label, `Port ${port} still in use after kill.`); + resolve(false); + } else { + log(label, `Port ${port} freed.`); + resolve(true); + } + }, 500); + }); + }); +} + +// --------------------------------------------------------------------------- +// Start all processes (with port checks) +// --------------------------------------------------------------------------- +async function main() { + log('startup', 'Starting development environment...'); + + // Check ASP.NET ports (from launchSettings.json) + const launchSettingsPath = path.resolve(rootDir, 'template/SimpleModule.Host/Properties/launchSettings.json'); + const dotnetPorts = []; + try { + const ls = JSON.parse(readFileSync(launchSettingsPath, 'utf8')); + for (const profile of Object.values(ls.profiles || {})) { + if (profile.applicationUrl) { + for (const url of profile.applicationUrl.split(';')) { + const match = url.match(/:(\d+)$/); + if (match) { + const p = parseInt(match[1], 10); + if (!dotnetPorts.includes(p)) dotnetPorts.push(p); + } + } + break; // Use first profile only + } + } + } catch {} + + for (const port of dotnetPorts) { + if (!(await ensurePortFree(port, 'dotnet'))) { + process.exit(1); + } + } -// Start all processes -log('startup', 'Starting development environment...'); -startDotnetRun(); -startClientAppWatch(); -modules.forEach((modulePath) => startModuleWatch(modulePath)); + startDotnetRun(); + startClientAppWatch(); + modules.forEach((modulePath) => startModuleWatch(modulePath)); + + log('startup', `All processes started. Press Ctrl+C to stop.`); +} -log('startup', `All processes started. Press Ctrl+C to stop.`); +main();