From 320b90f5110d0cee174d08dae3f8eaa562d65f66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 14:09:17 +0000 Subject: [PATCH 1/6] Add WebSocket-based live reload for development Implement automatic browser refresh when Vite builds or Tailwind CSS compilation completes during development. This eliminates the need to manually refresh the browser after editing frontend code. Key changes: - LiveReloadServer: WebSocket hub that broadcasts reload signals to connected browsers at /dev/live-reload - ViteDevWatchService: now notifies LiveReloadServer after successful builds, with CSS-only vs full reload distinction - HtmlFileInertiaPageRenderer: injects a lightweight client script in dev mode that connects to the WebSocket and auto-reloads (CSS-only swap for Tailwind changes, full reload for JS/TSX) - CSP updated to allow WebSocket connections in development - Reconnection with exponential backoff (1s to 10s max) --- .../DevToolsExtensions.cs | 1 + .../SimpleModule.DevTools/LiveReloadServer.cs | 254 ++++++++++++++++++ .../ViteDevWatchService.cs | 13 +- .../Inertia/HtmlFileInertiaPageRenderer.cs | 60 +++++ .../SimpleModuleHostExtensions.cs | 10 +- .../LiveReloadServerTests.cs | 82 ++++++ .../ViteDevWatchServiceTests.cs | 48 +++- 7 files changed, 457 insertions(+), 11 deletions(-) create mode 100644 framework/SimpleModule.DevTools/LiveReloadServer.cs create mode 100644 tests/SimpleModule.DevTools.Tests/LiveReloadServerTests.cs 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..0bb8a26a --- /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); + + List? deadClients = null; + + foreach (var (id, socket) in _clients) + { + if (socket.State != WebSocketState.Open) + { + (deadClients ??= []).Add(id); + continue; + } + + 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); + (deadClients ??= []).Add(id); + } + } + + if (deadClients is not null) + { + foreach (var id in deadClients) + { + 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/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..efffb77d 100644 --- a/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs +++ b/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using SimpleModule.Core.Inertia; using SimpleModule.Core.Security; @@ -13,6 +14,7 @@ public sealed class HtmlFileInertiaPageRenderer : IInertiaPageRenderer private readonly string _beforePlaceholder; private readonly string _afterPlaceholder; + private readonly bool _isDevelopment; public HtmlFileInertiaPageRenderer(IWebHostEnvironment env) { @@ -27,19 +29,77 @@ public HtmlFileInertiaPageRenderer(IWebHostEnvironment env) _beforePlaceholder = html[..idx]; _afterPlaceholder = html[(idx + PagePlaceholder.Length)..]; + _isDevelopment = env.IsDevelopment(); } public Task RenderPageAsync(HttpContext httpContext, string pageJson) { var nonce = httpContext.RequestServices.GetRequiredService().Value; + var devScript = _isDevelopment ? GetLiveReloadScript(nonce) : ""; + httpContext.Response.ContentType = "text/html; charset=utf-8"; return httpContext.Response.WriteAsync( string.Concat( _beforePlaceholder.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal), $"", + devScript, _afterPlaceholder.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal) ) ); } + + private static string GetLiveReloadScript(string nonce) => + ""; + + 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 b6eaa933..4cf1b28e 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'; " @@ -203,6 +206,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/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); From 414b2c1d39441daf0e0520ec02a2a279daa2535b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 14:35:38 +0000 Subject: [PATCH 2/6] Add native Vite HMR and dotnet watch via sm dev command Replace the build-and-refresh workflow with native Vite dev server HMR and .NET hot reload for a proper development experience: Vite dev server integration: - vite.dev.config.ts: Dev server config with React Fast Refresh, @tailwindcss/vite for CSS HMR, and module page resolution plugin - vite-plugin-module-hmr.ts: Resolves /_content/ module page imports to actual source files so Vite can serve them with HMR - ViteDevMiddleware: ASP.NET middleware that detects and proxies requests to Vite dev server (/@vite/, /@fs/, .tsx files) - HtmlFileInertiaPageRenderer: In Vite dev mode, strips import map, removes pre-built CSS link, and injects Vite HMR client scripts sm dev CLI command: - Runs dotnet watch (C# hot reload) + Vite dev server (frontend HMR) - Supports --no-vite, --no-dotnet, --vite-port flags - Graceful shutdown of all processes on Ctrl+C The LiveReload WebSocket fallback is preserved for running plain dotnet run without the Vite dev server. --- .../Commands/Dev/DevCommand.cs | 192 +++++++++++++++++ .../Commands/Dev/DevSettings.cs | 20 ++ cli/SimpleModule.Cli/Program.cs | 9 +- .../ViteDevMiddleware.cs | 198 ++++++++++++++++++ .../Inertia/HtmlFileInertiaPageRenderer.cs | 96 ++++++++- .../SimpleModuleHostExtensions.cs | 8 + packages/SimpleModule.Client/package.json | 1 + .../src/vite-plugin-module-hmr.ts | 113 ++++++++++ template/SimpleModule.Host/ClientApp/app.tsx | 7 + .../ClientApp/vite.dev.config.ts | 34 +++ 10 files changed, 672 insertions(+), 6 deletions(-) create mode 100644 cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs create mode 100644 cli/SimpleModule.Cli/Commands/Dev/DevSettings.cs create mode 100644 framework/SimpleModule.DevTools/ViteDevMiddleware.cs create mode 100644 packages/SimpleModule.Client/src/vite-plugin-module-hmr.ts create mode 100644 template/SimpleModule.Host/ClientApp/vite.dev.config.ts diff --git a/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs new file mode 100644 index 00000000..9bdd320e --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs @@ -0,0 +1,192 @@ +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 +{ + private readonly List _processes = []; + private volatile bool _shuttingDown; + + 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; + + AnsiConsole.MarkupLine("[bold blue]Starting SimpleModule development environment[/]"); + AnsiConsole.MarkupLine(""); + + // Start dotnet watch (hot reload for C# changes) + if (!settings.NoDotnet) + { + AnsiConsole.MarkupLine("[cyan][[dotnet]][/] Starting dotnet watch..."); + var dotnetArgs = $"watch run --project \"{hostProject}\" --no-restore"; + StartProcess("dotnet", dotnetArgs, solution.RootPath, "dotnet"); + } + + // Start Vite dev server (HMR for frontend changes) + if (!settings.NoVite && File.Exists(viteConfigPath)) + { + 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.[/]" + ); + + // Wait for any process to exit + WaitForExit(); + + return 0; + } + + private void StartProcess( + string fileName, + string arguments, + string workingDirectory, + string label + ) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + }; + + // Ensure ASPNETCORE_ENVIRONMENT is set for dotnet + if (label == "dotnet") + { + startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "Development"; + } + + try + { + var process = Process.Start(startInfo); + if (process is not null) + { + _processes.Add(process); + } + 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 critical process exits + while (!_shuttingDown) + { + foreach (var process in _processes) + { + if (process.HasExited) + { + if (!_shuttingDown) + { + AnsiConsole.MarkupLine( + $"[yellow]Process exited with code {process.ExitCode}. Shutting down...[/]" + ); + Shutdown(); + } + + return; + } + } + + Thread.Sleep(500); + } + } + + private void Shutdown() + { + if (_shuttingDown) + { + return; + } + + _shuttingDown = true; + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine("[cyan]Stopping all processes...[/]"); + + foreach (var process in _processes) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch + { + // Best-effort kill + } +#pragma warning restore CA1031 + } + } + + private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) + { + e.Cancel = true; + Shutdown(); + } + + private void OnProcessExit(object? sender, EventArgs e) + { + Shutdown(); + } +} 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/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/ViteDevMiddleware.cs b/framework/SimpleModule.DevTools/ViteDevMiddleware.cs new file mode 100644 index 00000000..150835ac --- /dev/null +++ b/framework/SimpleModule.DevTools/ViteDevMiddleware.cs @@ -0,0 +1,198 @@ +using System.Net.Http.Headers; +using System.Text.RegularExpressions; +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 Uri _viteBaseUri; + private readonly int _vitePort; + private bool _viteDetected; + private DateTime _lastCheck = DateTime.MinValue; + + /// 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; + _viteBaseUri = new Uri($"http://localhost:{vitePort}"); + _httpClient = new HttpClient + { + BaseAddress = _viteBaseUri, + Timeout = TimeSpan.FromSeconds(5), + }; + } + + public async Task InvokeAsync(HttpContext context) + { + var path = context.Request.Path.Value ?? ""; + + // Periodically check if Vite dev server is running (every 10 seconds) + if (DateTime.UtcNow - _lastCheck > TimeSpan.FromSeconds(10)) + { + _viteDetected = await IsViteRunningAsync().ConfigureAwait(false); + _lastCheck = DateTime.UtcNow; + if (_viteDetected) + { + LogViteDetected(_logger, _vitePort); + } + } + + if (!_viteDetected) + { + await _next(context).ConfigureAwait(false); + return; + } + + // Signal to HtmlFileInertiaPageRenderer that Vite dev server is active + context.Items["ViteDevServer"] = true; + + // Proxy Vite-specific requests + if (ShouldProxy(path)) + { + await ProxyToViteAsync(context).ConfigureAwait(false); + return; + } + + // Proxy source file requests (.tsx, .ts, .css, .jsx, .js — not built assets) + if (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) + { + // Proxy requests for the app entry point (app.tsx) + if ( + path.EndsWith(".tsx", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".jsx", StringComparison.OrdinalIgnoreCase) + ) + { + return true; + } + + return false; + } + + private async Task ProxyToViteAsync(HttpContext context) + { + try + { + var requestUri = context.Request.Path.Value + context.Request.QueryString; + + using var proxyRequest = new HttpRequestMessage(HttpMethod.Get, requestUri); + + // Forward relevant headers + 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; + + // Forward content type + if (response.Content.Headers.ContentType is not null) + { + context.Response.ContentType = response.Content.Headers.ContentType.ToString(); + } + + // Forward CORS headers from Vite + 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.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs b/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs index efffb77d..a99db9d1 100644 --- a/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs +++ b/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs @@ -14,6 +14,8 @@ 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) @@ -30,28 +32,112 @@ public HtmlFileInertiaPageRenderer(IWebHostEnvironment env) _beforePlaceholder = html[..idx]; _afterPlaceholder = html[(idx + PagePlaceholder.Length)..]; _isDevelopment = env.IsDevelopment(); + + if (_isDevelopment) + { + // Pre-compute the Vite dev mode HTML transformation: + // 1. Strip import map (Vite handles module resolution) + // 2. Strip /css/app.css link (Tailwind is served via Vite) + // 3. Replace /js/app.js with Vite entry point + _beforePlaceholderViteDev = TransformForViteDev(_beforePlaceholder); + _afterPlaceholderViteDev = TransformForViteDev(_afterPlaceholder) + .Replace( + "", + ViteEntryScripts, + StringComparison.Ordinal + ); + } + else + { + _beforePlaceholderViteDev = _beforePlaceholder; + _afterPlaceholderViteDev = _afterPlaceholder; + } } public Task RenderPageAsync(HttpContext httpContext, string pageJson) { var nonce = httpContext.RequestServices.GetRequiredService().Value; - var devScript = _isDevelopment ? GetLiveReloadScript(nonce) : ""; + // Detect Vite dev mode via request header set by ViteDevMiddleware + var useViteDev = _isDevelopment && httpContext.Items.ContainsKey("ViteDevServer"); + + string before; + string after; + string devScript; + + if (useViteDev) + { + before = _beforePlaceholderViteDev; + after = _afterPlaceholderViteDev; + devScript = ""; + } + else if (_isDevelopment) + { + before = _beforePlaceholder; + after = _afterPlaceholder; + devScript = ""; + } + else + { + before = _beforePlaceholder; + after = _afterPlaceholder; + devScript = ""; + } 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), $"", devScript, - _afterPlaceholder.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal) + after.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal) ) ); } - private static string GetLiveReloadScript(string nonce) => - ""; + /// + /// Transforms HTML for Vite dev server mode by stripping import maps + /// and the pre-built CSS link. + /// + private static string TransformForViteDev(string html) + { + // Remove the import map script block (Vite handles module resolution) + var importMapStart = html.IndexOf("", importMapStart, StringComparison.Ordinal); + if (importMapEnd >= 0) + { + html = string.Concat( + html.AsSpan(0, importMapStart), + html.AsSpan(importMapEnd + "".Length) + ); + } + } + + // Remove the pre-built CSS link (Tailwind is served via @tailwindcss/vite) + html = html.Replace( + "", + "", + StringComparison.Ordinal + ); + + return html; + } + + /// + /// Script tags injected in Vite dev mode to load the HMR client and + /// the app entry point from Vite dev server (proxied through ASP.NET). + /// + private const string ViteEntryScripts = """ + + + """; + /// + /// Fallback live reload script for when Vite dev server is not running + /// (e.g. running just dotnet run with the file-watch service). + /// private const string LiveReloadClientScript = """ (function(){ var protocol=location.protocol==='https:'?'wss:':'ws:'; diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 4cf1b28e..4c45727d 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -187,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(); 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 48051a40..f5b96463 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'], + }, +}); From 937dd5b145a90546443288f15c7f5abe38590989 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 14:50:01 +0000 Subject: [PATCH 3/6] Harden process cleanup to prevent ghost processes DevCommand (sm dev): - Graceful shutdown: SIGTERM first, wait 5s, then SIGKILL - Second Ctrl+C force-kills immediately instead of being swallowed - Unix: kill process group (-PID) so shell-spawned children get the signal - Windows: taskkill /T for process tree termination - Log PIDs on start and warn about survivors on failed shutdown - Dispose all Process handles on exit - Unregister event handlers to prevent accumulation - ProcessExit handler force-kills to prevent orphans on unexpected exit dev-orchestrator.mjs (npm run dev): - Graceful SIGTERM then SIGKILL after 3s timeout - Unix: process.kill(-pid, 'SIGTERM') for process group cleanup - Windows: taskkill /T /F for process tree cleanup - Handle SIGHUP (terminal close) - process.on('exit') safety net force-kills all children - uncaughtException handler kills children before crashing - Track labels with processes for better diagnostics --- .../Commands/Dev/DevCommand.cs | 328 ++++++++++++++++-- tools/dev-orchestrator.mjs | 118 ++++++- 2 files changed, 402 insertions(+), 44 deletions(-) diff --git a/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs index 9bdd320e..df500a94 100644 --- a/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs +++ b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs @@ -8,8 +8,14 @@ namespace SimpleModule.Cli.Commands.Dev; public sealed class DevCommand : Command { - private readonly List _processes = []; - private volatile bool _shuttingDown; + /// 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; // 0=running, 1=graceful, 2=force public override int Execute(CommandContext context, DevSettings settings) { @@ -36,6 +42,27 @@ public override int Execute(CommandContext context, DevSettings settings) 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(""); @@ -76,9 +103,7 @@ public override int Execute(CommandContext context, DevSettings settings) $"[green]Development environment running ({_processes.Count} process(es)). Press Ctrl+C to stop.[/]" ); - // Wait for any process to exit WaitForExit(); - return 0; } @@ -110,7 +135,8 @@ string label var process = Process.Start(startInfo); if (process is not null) { - _processes.Add(process); + _processes.Add((process, label)); + AnsiConsole.MarkupLine($"[dim][[{label}]][/] [dim]Started (PID {process.Id})[/]"); } else { @@ -127,41 +153,168 @@ string label private void WaitForExit() { - // Wait until any critical process exits - while (!_shuttingDown) + // Wait until any process exits or shutdown is requested + while (_shutdownState == 0) { - foreach (var process in _processes) + foreach (var (process, label) in _processes) { - if (process.HasExited) + try { - if (!_shuttingDown) + if (process.HasExited) { - AnsiConsole.MarkupLine( - $"[yellow]Process exited with code {process.ExitCode}. Shutting down...[/]" - ); - Shutdown(); - } + if (_shutdownState == 0) + { + AnsiConsole.MarkupLine( + $"[yellow][[{label}]][/] Exited with code {process.ExitCode}. Shutting down..." + ); + GracefulShutdown(); + } - return; + 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(500); + Thread.Sleep(300); } } - private void Shutdown() + /// + /// Graceful shutdown: send SIGTERM/SIGINT to children, wait for them to exit, + /// then force-kill any stragglers. + /// + private void GracefulShutdown() { - if (_shuttingDown) + // Transition: running → graceful + if (Interlocked.CompareExchange(ref _shutdownState, 1, 0) != 0) { return; } - _shuttingDown = true; AnsiConsole.MarkupLine(""); - AnsiConsole.MarkupLine("[cyan]Stopping all processes...[/]"); + AnsiConsole.MarkupLine("[cyan]Stopping all processes gracefully...[/]"); - foreach (var process in _processes) + // Phase 1: Send graceful termination signal + 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 SIGTERM on Linux/macOS or Kill on Windows to a single process tree. + /// + private static void SendTermSignal(Process process, string label) + { + try + { + if (process.HasExited) + { + return; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Windows has no graceful signal — use taskkill /T (tree) + // which sends WM_CLOSE to console apps + 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 + { + // Unix: send SIGTERM to the process group (negative PID) + // This catches child processes spawned by shell wrappers like npx + var killPgid = Process.Start( + new ProcessStartInfo + { + FileName = "kill", + Arguments = $"-TERM -{process.Id}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + } + ); + killPgid?.WaitForExit(1000); + + // Also send SIGTERM directly to the process (in case it's not a group leader) + if (!process.HasExited) + { + var killPid = Process.Start( + new ProcessStartInfo + { + FileName = "kill", + Arguments = $"-TERM {process.Id}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + } + ); + killPid?.WaitForExit(1000); + } + } + } +#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}[/]" + ); + } + } + + /// + /// Force-kill all processes and their entire process trees. + /// + private void ForceKillAll() + { + // Transition to force state (from any state) + Interlocked.Exchange(ref _shutdownState, 2); + + foreach (var (process, label) in _processes) { try { @@ -170,23 +323,146 @@ private void Shutdown() 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 + AnsiConsole.MarkupLine( + $"[dim][[{label}]][/] [dim]Force-kill failed (PID {GetSafePid(process)}): {ex.Message}[/]" + ); + } + } + } + + /// + /// 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 kill + // 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) { - e.Cancel = true; - Shutdown(); + if (_shutdownState == 0) + { + // 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) { - Shutdown(); + // Process is exiting (terminal closed, kill signal, etc.) + // Force-kill children to prevent orphans + ForceKillAll(); } } diff --git a/tools/dev-orchestrator.mjs b/tools/dev-orchestrator.mjs index 0b848543..f476c22b 100644 --- a/tools/dev-orchestrator.mjs +++ b/tools/dev-orchestrator.mjs @@ -12,6 +12,7 @@ const modules = discoverModulesWithVite(rootDir); const childProcesses = []; const log = createLogger(); +let shuttingDown = false; function startDotnetRun() { log('setup', 'Starting dotnet run...'); @@ -24,18 +25,19 @@ function startDotnetRun() { proc.on('error', (err) => { log('dotnet', `Critical error: ${err.message}`); log('error', 'Failed to start dotnet backend. Shutting down.'); - process.exit(1); + shutdown(1); }); 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); + childProcesses.push({ proc, label: 'dotnet' }); + log('setup', `dotnet started (PID ${proc.pid})`); return proc; } @@ -55,12 +57,12 @@ function startModuleWatch(modulePath) { }); 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); + childProcesses.push({ proc, label: moduleName }); return proc; } @@ -78,25 +80,92 @@ function startClientAppWatch() { }); 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); + childProcesses.push({ proc, label: 'ClientApp' }); return proc; } -function shutdown() { - log('shutdown', 'Stopping all processes...'); - childProcesses.forEach((proc) => { +function killProcess(proc, label) { + try { + if (proc.exitCode !== null) return; // already exited + + if (process.platform === 'win32') { + // Windows: use taskkill to kill the entire process tree + spawn('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { + stdio: 'ignore', + }); + } else { + // Unix: send SIGTERM to the process group (negative PID kills the group) + // This ensures shell-spawned children (node, vite, dotnet) also receive the signal + try { + process.kill(-proc.pid, 'SIGTERM'); + } catch { + // Process group may not exist; fall back to direct signal + proc.kill('SIGTERM'); + } + } + } catch (err) { + log(label, `Warning: Failed to terminate (PID ${proc.pid}): ${err.message}`); + } +} + +function forceKillAll() { + for (const { proc, label } of childProcesses) { try { - proc.kill('SIGTERM'); + if (proc.exitCode === null) { + if (process.platform === 'win32') { + spawn('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { + stdio: 'ignore', + }); + } else { + try { + process.kill(-proc.pid, 'SIGKILL'); + } catch { + proc.kill('SIGKILL'); + } + } + } } catch (err) { - log('shutdown', `Warning: Failed to terminate process: ${err.message}`); + log(label, `Warning: Force-kill failed (PID ${proc.pid}): ${err.message}`); } - }); - setTimeout(() => process.exit(0), 500); + } +} + +function shutdown(exitCode = 0) { + if (shuttingDown) return; + shuttingDown = true; + + log('shutdown', 'Stopping all processes...'); + + // Phase 1: Graceful termination (SIGTERM) + for (const { proc, label } of childProcesses) { + killProcess(proc, label); + } + + // Phase 2: Wait briefly, 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)...`); + forceKillAll(); + } + + // 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 @@ -106,9 +175,22 @@ if (process.argv.includes('--check')) { process.exit(0); } -// Handle signals -process.on('SIGINT', shutdown); -process.on('SIGTERM', shutdown); +// Handle all termination signals +process.on('SIGINT', () => shutdown(0)); +process.on('SIGTERM', () => shutdown(0)); +process.on('SIGHUP', () => shutdown(0)); + +// Safety net: kill children if this process exits unexpectedly +process.on('exit', () => { + forceKillAll(); +}); + +// Handle uncaught exceptions — kill children before crashing +process.on('uncaughtException', (err) => { + log('error', `Uncaught exception: ${err.message}`); + forceKillAll(); + process.exit(1); +}); // Start all processes log('startup', 'Starting development environment...'); From 6283c795545af03a72296460b9fe77f9f0365890 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 15:05:49 +0000 Subject: [PATCH 4/6] Fix cross-platform process cleanup for Linux, macOS, and Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DevCommand (sm dev) — C#: - Replace broken `kill -TERM -` (process group kill) with proper descendant tree walk: /proc/*/stat on Linux, pgrep -P on macOS - Send SIGTERM leaf-first so parents don't respawn before we reach them - Force-kill fallback: Kill(entireProcessTree: true) which uses /proc on Linux, libproc on macOS, NtQuerySystemInformation on Windows - If tree kill fails (permission denied on a child), fall back to killing just the direct process - Windows: taskkill /T (no /F) for graceful, Kill(tree) for force dev-orchestrator.mjs — Node: - Use detached: true on Unix so each child becomes a process group leader, making process.kill(-pid) actually work - Remove shell: true — it created intermediary sh processes that swallowed signals and made proc.pid point to the shell, not the actual dotnet/node process - Resolve npx binary directly instead of going through shell - Guard SIGHUP handler behind platform check (doesn't exist on Windows) - Fix 'exit' handler: use process.kill() (sync) not spawn() (async) since exit callbacks can't do async work - Handle ESRCH (already exited) gracefully instead of logging warnings --- .../Commands/Dev/DevCommand.cs | 246 +++++++++++++++--- tools/dev-orchestrator.mjs | 223 +++++++++++----- 2 files changed, 362 insertions(+), 107 deletions(-) diff --git a/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs index df500a94..d89a522f 100644 --- a/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs +++ b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs @@ -186,7 +186,7 @@ private void WaitForExit() } /// - /// Graceful shutdown: send SIGTERM/SIGINT to children, wait for them to exit, + /// Graceful shutdown: send termination signals to children, wait for them to exit, /// then force-kill any stragglers. /// private void GracefulShutdown() @@ -200,7 +200,7 @@ private void GracefulShutdown() AnsiConsole.MarkupLine(""); AnsiConsole.MarkupLine("[cyan]Stopping all processes gracefully...[/]"); - // Phase 1: Send graceful termination signal + // Phase 1: Send graceful termination signal to the entire process tree foreach (var (process, label) in _processes) { SendTermSignal(process, label); @@ -233,7 +233,17 @@ private void GracefulShutdown() } /// - /// Send SIGTERM on Linux/macOS or Kill on Windows to a single process tree. + /// 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) { @@ -246,8 +256,8 @@ private static void SendTermSignal(Process process, string label) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - // Windows has no graceful signal — use taskkill /T (tree) - // which sends WM_CLOSE to console apps + // 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 { @@ -263,37 +273,18 @@ private static void SendTermSignal(Process process, string label) } else { - // Unix: send SIGTERM to the process group (negative PID) - // This catches child processes spawned by shell wrappers like npx - var killPgid = Process.Start( - new ProcessStartInfo - { - FileName = "kill", - Arguments = $"-TERM -{process.Id}", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - } - ); - killPgid?.WaitForExit(1000); + // 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 - // Also send SIGTERM directly to the process (in case it's not a group leader) - if (!process.HasExited) + foreach (var pid in descendants) { - var killPid = Process.Start( - new ProcessStartInfo - { - FileName = "kill", - Arguments = $"-TERM {process.Id}", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - } - ); - killPid?.WaitForExit(1000); + SendSigterm(pid); } + + // Finally signal the root process itself + SendSigterm(process.Id); } } #pragma warning disable CA1031 // Do not catch general exception types @@ -306,8 +297,179 @@ private static void SendTermSignal(Process process, string label) } } + /// + /// 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() { @@ -328,10 +490,24 @@ private void ForceKillAll() #pragma warning restore CA1031 { // Kill can fail if process exited between check and kill, - // or if we lack permissions for a child process + // or if we lack permissions for a child process. + // Fall back to killing just the direct process. AnsiConsole.MarkupLine( - $"[dim][[{label}]][/] [dim]Force-kill failed (PID {GetSafePid(process)}): {ex.Message}[/]" + $"[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 } } } diff --git a/tools/dev-orchestrator.mjs b/tools/dev-orchestrator.mjs index f476c22b..066b2fb7 100644 --- a/tools/dev-orchestrator.mjs +++ b/tools/dev-orchestrator.mjs @@ -1,11 +1,13 @@ -import { spawn } from 'child_process'; +import { spawn, execSync } from 'child_process'; import path from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; +import { readdirSync, readFileSync, existsSync } from 'fs'; 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); @@ -14,20 +16,58 @@ 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.'); - shutdown(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 && !shuttingDown) { log('dotnet', `Exited with code ${code}`); @@ -36,25 +76,21 @@ function startDotnetRun() { } }); - childProcesses.push({ proc, label: 'dotnet' }); - log('setup', `dotnet started (PID ${proc.pid})`); 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 && !shuttingDown) { @@ -62,22 +98,18 @@ function startModuleWatch(modulePath) { } }); - childProcesses.push({ proc, label: moduleName }); 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 && !shuttingDown) { @@ -85,53 +117,69 @@ function startClientAppWatch() { } }); - childProcesses.push({ proc, label: 'ClientApp' }); return proc; } -function killProcess(proc, label) { +// --------------------------------------------------------------------------- +// 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; // already exited + if (proc.exitCode !== null) return; - if (process.platform === 'win32') { - // Windows: use taskkill to kill the entire process tree + 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 { - // Unix: send SIGTERM to the process group (negative PID kills the group) - // This ensures shell-spawned children (node, vite, dotnet) also receive the signal - try { - process.kill(-proc.pid, 'SIGTERM'); - } catch { - // Process group may not exist; fall back to direct signal - proc.kill('SIGTERM'); - } + process.kill(-proc.pid, 'SIGKILL'); } } catch (err) { - log(label, `Warning: Failed to terminate (PID ${proc.pid}): ${err.message}`); + if (err.code !== 'ESRCH') { + log(label, `Warning: Force-kill failed (PID ${proc.pid}): ${err.message}`); + } } } function forceKillAll() { for (const { proc, label } of childProcesses) { - try { - if (proc.exitCode === null) { - if (process.platform === 'win32') { - spawn('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { - stdio: 'ignore', - }); - } else { - try { - process.kill(-proc.pid, 'SIGKILL'); - } catch { - proc.kill('SIGKILL'); - } - } - } - } catch (err) { - log(label, `Warning: Force-kill failed (PID ${proc.pid}): ${err.message}`); - } + killForce(proc, label); } } @@ -141,17 +189,19 @@ function shutdown(exitCode = 0) { log('shutdown', 'Stopping all processes...'); - // Phase 1: Graceful termination (SIGTERM) + // Phase 1: Graceful termination (SIGTERM / WM_CLOSE) for (const { proc, label } of childProcesses) { - killProcess(proc, label); + killGraceful(proc, label); } - // Phase 2: Wait briefly, then force-kill survivors + // 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)...`); - forceKillAll(); + for (const { proc, label } of survivors) { + killForce(proc, label); + } } // Phase 3: Final exit after a short grace period @@ -168,21 +218,48 @@ function shutdown(exitCode = 0) { }, 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 all termination signals +// --------------------------------------------------------------------------- +// 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)); -process.on('SIGHUP', () => shutdown(0)); +if (!isWindows) { + process.on('SIGHUP', () => shutdown(0)); +} -// Safety net: kill children if this process exits unexpectedly +// 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', () => { - forceKillAll(); + 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 @@ -192,7 +269,9 @@ process.on('uncaughtException', (err) => { process.exit(1); }); +// --------------------------------------------------------------------------- // Start all processes +// --------------------------------------------------------------------------- log('startup', 'Starting development environment...'); startDotnetRun(); startClientAppWatch(); From a6b55cb4b9cf7a81b18bec4d6cd083813fe94f9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 15:29:08 +0000 Subject: [PATCH 5/6] Check ports before starting and offer to kill blocking processes Before launching dotnet watch or Vite dev server, both sm dev and npm run dev now check if the required ports are free. If a port is occupied, the user sees what process holds it and can choose to kill it interactively. PortChecker (C#): - Linux: ss -tlnp (fast) with lsof fallback - macOS: lsof -iTCP:PORT -sTCP:LISTEN - Windows: netstat -ano + tasklist for process name - Parses launchSettings.json to discover ASP.NET ports dynamically - Kill uses Process.Kill(entireProcessTree: true) then verifies release dev-orchestrator.mjs (Node): - Same cross-platform port detection (lsof on Unix, netstat on Windows) - Linux: reads /proc//comm for process name - macOS: ps -p -o comm= for process name - Windows: tasklist /FI for process name - Interactive Y/n prompt before killing - Parses launchSettings.json for ASP.NET port discovery --- .../Commands/Dev/DevCommand.cs | 121 ++++++- .../Infrastructure/PortChecker.cs | 319 ++++++++++++++++++ tools/dev-orchestrator.mjs | 146 +++++++- 3 files changed, 576 insertions(+), 10 deletions(-) create mode 100644 cli/SimpleModule.Cli/Infrastructure/PortChecker.cs diff --git a/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs index d89a522f..133d41eb 100644 --- a/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs +++ b/cli/SimpleModule.Cli/Commands/Dev/DevCommand.cs @@ -66,16 +66,45 @@ string viteConfigPath AnsiConsole.MarkupLine("[bold blue]Starting SimpleModule development environment[/]"); AnsiConsole.MarkupLine(""); - // Start dotnet watch (hot reload for C# changes) - if (!settings.NoDotnet) + 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"; StartProcess("dotnet", dotnetArgs, solution.RootPath, "dotnet"); } - // Start Vite dev server (HMR for frontend changes) - if (!settings.NoVite && File.Exists(viteConfigPath)) + if (startVite) { AnsiConsole.MarkupLine( $"[cyan][[vite]][/] Starting Vite dev server on port {settings.VitePort}..." @@ -641,4 +670,88 @@ private void OnProcessExit(object? sender, EventArgs e) // 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/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/tools/dev-orchestrator.mjs b/tools/dev-orchestrator.mjs index 066b2fb7..f87d5b76 100644 --- a/tools/dev-orchestrator.mjs +++ b/tools/dev-orchestrator.mjs @@ -270,11 +270,145 @@ process.on('uncaughtException', (err) => { }); // --------------------------------------------------------------------------- -// Start all processes +// Port checking // --------------------------------------------------------------------------- -log('startup', 'Starting development environment...'); -startDotnetRun(); -startClientAppWatch(); -modules.forEach((modulePath) => startModuleWatch(modulePath)); +import { execFileSync } from 'child_process'; +import * as readline from 'readline'; -log('startup', `All processes started. Press Ctrl+C to stop.`); +/** + * 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); + } + } + + startDotnetRun(); + startClientAppWatch(); + modules.forEach((modulePath) => startModuleWatch(modulePath)); + + log('startup', `All processes started. Press Ctrl+C to stop.`); +} + +main(); From 24024915e14d7294169da81576d02f4a9ed8c0f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 15:59:53 +0000 Subject: [PATCH 6/6] Simplify and fix issues found in code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - ViteEntryScripts: add CSP nonce to injected ", - ViteEntryScripts, + ViteEntryPlaceholder, StringComparison.Ordinal ); } @@ -57,32 +54,15 @@ public HtmlFileInertiaPageRenderer(IWebHostEnvironment env) public Task RenderPageAsync(HttpContext httpContext, string pageJson) { var nonce = httpContext.RequestServices.GetRequiredService().Value; + var useViteDev = + _isDevelopment && httpContext.Items.ContainsKey(DevToolsConstants.ViteDevServerKey); - // Detect Vite dev mode via request header set by ViteDevMiddleware - var useViteDev = _isDevelopment && httpContext.Items.ContainsKey("ViteDevServer"); - - string before; - string after; - string devScript; - - if (useViteDev) - { - before = _beforePlaceholderViteDev; - after = _afterPlaceholderViteDev; - devScript = ""; - } - else if (_isDevelopment) - { - before = _beforePlaceholder; - after = _afterPlaceholder; - devScript = ""; - } - else - { - before = _beforePlaceholder; - after = _afterPlaceholder; - devScript = ""; - } + 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( @@ -95,13 +75,8 @@ public Task RenderPageAsync(HttpContext httpContext, string pageJson) ); } - /// - /// Transforms HTML for Vite dev server mode by stripping import maps - /// and the pre-built CSS link. - /// private static string TransformForViteDev(string html) { - // Remove the import map script block (Vite handles module resolution) var importMapStart = html.IndexOf(" - - """; + private const string ViteEntryPlaceholder = + "\n" + + " "; - /// - /// Fallback live reload script for when Vite dev server is not running - /// (e.g. running just dotnet run with the file-watch service). - /// private const string LiveReloadClientScript = """ (function(){ var protocol=location.protocol==='https:'?'wss:':'ws:'; diff --git a/tools/dev-orchestrator.mjs b/tools/dev-orchestrator.mjs index f87d5b76..1d0aa824 100644 --- a/tools/dev-orchestrator.mjs +++ b/tools/dev-orchestrator.mjs @@ -1,8 +1,9 @@ -import { spawn, execSync } 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)); @@ -272,9 +273,6 @@ process.on('uncaughtException', (err) => { // --------------------------------------------------------------------------- // Port checking // --------------------------------------------------------------------------- -import { execFileSync } from 'child_process'; -import * as readline from 'readline'; - /** * Find the PID and process name listening on a TCP port. * Returns { pid, name } or null if port is free.