// Copied and modified from https://github.com/dotnet/aspnetcore/blob/master/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs // TODO! Delete this after aspnetcore 5 is released. // I currently manually copy this because this is not merged into aspnetcore 3.1 . using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.SpaServices; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; #nullable disable #pragma warning disable namespace Timeline.SpaServices { internal static class TaskTimeoutExtensions { public static async Task WithTimeout(this Task task, TimeSpan timeoutDelay, string message) { if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay))) { task.Wait(); // Allow any errors to propagate } else { throw new TimeoutException(message); } } public static async Task WithTimeout(this Task task, TimeSpan timeoutDelay, string message) { if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay))) { return task.Result; } else { throw new TimeoutException(message); } } } /// /// Wraps a to expose an evented API, issuing notifications /// when the stream emits partial lines, completed lines, or finally closes. /// internal class EventedStreamReader { public delegate void OnReceivedChunkHandler(ArraySegment chunk); public delegate void OnReceivedLineHandler(string line); public delegate void OnStreamClosedHandler(); public event OnReceivedChunkHandler OnReceivedChunk; public event OnReceivedLineHandler OnReceivedLine; public event OnStreamClosedHandler OnStreamClosed; private readonly StreamReader _streamReader; private readonly StringBuilder _linesBuffer; public EventedStreamReader(StreamReader streamReader) { _streamReader = streamReader ?? throw new ArgumentNullException(nameof(streamReader)); _linesBuffer = new StringBuilder(); Task.Factory.StartNew(Run); } public Task WaitForMatch(Regex regex) { var tcs = new TaskCompletionSource(); var completionLock = new object(); OnReceivedLineHandler onReceivedLineHandler = null; OnStreamClosedHandler onStreamClosedHandler = null; void ResolveIfStillPending(Action applyResolution) { lock (completionLock) { if (!tcs.Task.IsCompleted) { OnReceivedLine -= onReceivedLineHandler; OnStreamClosed -= onStreamClosedHandler; applyResolution(); } } } onReceivedLineHandler = line => { var match = regex.Match(line); if (match.Success) { ResolveIfStillPending(() => tcs.SetResult(match)); } }; onStreamClosedHandler = () => { ResolveIfStillPending(() => tcs.SetException(new EndOfStreamException())); }; OnReceivedLine += onReceivedLineHandler; OnStreamClosed += onStreamClosedHandler; return tcs.Task; } private async Task Run() { var buf = new char[8 * 1024]; while (true) { var chunkLength = await _streamReader.ReadAsync(buf, 0, buf.Length); if (chunkLength == 0) { if (_linesBuffer.Length > 0) { OnCompleteLine(_linesBuffer.ToString()); _linesBuffer.Clear(); } OnClosed(); break; } OnChunk(new ArraySegment(buf, 0, chunkLength)); int lineBreakPos = -1; int startPos = 0; // get all the newlines while ((lineBreakPos = Array.IndexOf(buf, '\n', startPos, chunkLength - startPos)) >= 0 && startPos < chunkLength) { var length = (lineBreakPos + 1) - startPos; _linesBuffer.Append(buf, startPos, length); OnCompleteLine(_linesBuffer.ToString()); _linesBuffer.Clear(); startPos = lineBreakPos + 1; } // get the rest if (lineBreakPos < 0 && startPos < chunkLength) { _linesBuffer.Append(buf, startPos, chunkLength - startPos); } } } private void OnChunk(ArraySegment chunk) { var dlg = OnReceivedChunk; dlg?.Invoke(chunk); } private void OnCompleteLine(string line) { var dlg = OnReceivedLine; dlg?.Invoke(line); } private void OnClosed() { var dlg = OnStreamClosed; dlg?.Invoke(); } } /// /// Captures the completed-line notifications from a , /// combining the data into a single . /// internal class EventedStreamStringReader : IDisposable { private EventedStreamReader _eventedStreamReader; private bool _isDisposed; private StringBuilder _stringBuilder = new StringBuilder(); public EventedStreamStringReader(EventedStreamReader eventedStreamReader) { _eventedStreamReader = eventedStreamReader ?? throw new ArgumentNullException(nameof(eventedStreamReader)); _eventedStreamReader.OnReceivedLine += OnReceivedLine; } public string ReadAsString() => _stringBuilder.ToString(); private void OnReceivedLine(string line) => _stringBuilder.AppendLine(line); public void Dispose() { if (!_isDisposed) { _eventedStreamReader.OnReceivedLine -= OnReceivedLine; _isDisposed = true; } } } /// /// Executes the script entries defined in a package.json file, /// capturing any output written to stdio. /// internal class NodeScriptRunner : IDisposable { private Process _npmProcess; public EventedStreamReader StdOut { get; } public EventedStreamReader StdErr { get; } private static Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1)); public NodeScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary envVars, string pkgManagerCommand, DiagnosticSource diagnosticSource, CancellationToken applicationStoppingToken) { if (string.IsNullOrEmpty(workingDirectory)) { throw new ArgumentException("Cannot be null or empty.", nameof(workingDirectory)); } if (string.IsNullOrEmpty(scriptName)) { throw new ArgumentException("Cannot be null or empty.", nameof(scriptName)); } if (string.IsNullOrEmpty(pkgManagerCommand)) { throw new ArgumentException("Cannot be null or empty.", nameof(pkgManagerCommand)); } var exeToRun = pkgManagerCommand; var completeArguments = $"run {scriptName} -- {arguments ?? string.Empty}"; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // On Windows, the node executable is a .cmd file, so it can't be executed // directly (except with UseShellExecute=true, but that's no good, because // it prevents capturing stdio). So we need to invoke it via "cmd /c". exeToRun = "cmd"; completeArguments = $"/c {pkgManagerCommand} {completeArguments}"; } var processStartInfo = new ProcessStartInfo(exeToRun) { Arguments = completeArguments, UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = workingDirectory }; if (envVars != null) { foreach (var keyValuePair in envVars) { processStartInfo.Environment[keyValuePair.Key] = keyValuePair.Value; } } _npmProcess = LaunchNodeProcess(processStartInfo, pkgManagerCommand); StdOut = new EventedStreamReader(_npmProcess.StandardOutput); StdErr = new EventedStreamReader(_npmProcess.StandardError); applicationStoppingToken.Register(((IDisposable)this).Dispose); if (diagnosticSource.IsEnabled("Timeline.NodeServices.Npm.NpmStarted")) { diagnosticSource.Write( "Timeline.NodeServices.Npm.NpmStarted", new { processStartInfo = processStartInfo, process = _npmProcess }); } } public void AttachToLogger(ILogger logger) { // When the node task emits complete lines, pass them through to the real logger StdOut.OnReceivedLine += line => { if (!string.IsNullOrWhiteSpace(line)) { // Node tasks commonly emit ANSI colors, but it wouldn't make sense to forward // those to loggers (because a logger isn't necessarily any kind of terminal) logger.LogInformation(StripAnsiColors(line)); } }; StdErr.OnReceivedLine += line => { if (!string.IsNullOrWhiteSpace(line)) { logger.LogError(StripAnsiColors(line)); } }; // But when it emits incomplete lines, assume this is progress information and // hence just pass it through to StdOut regardless of logger config. StdErr.OnReceivedChunk += chunk => { var containsNewline = Array.IndexOf( chunk.Array, '\n', chunk.Offset, chunk.Count) >= 0; if (!containsNewline) { Console.Write(chunk.Array, chunk.Offset, chunk.Count); } }; } private static string StripAnsiColors(string line) => AnsiColorRegex.Replace(line, string.Empty); private static Process LaunchNodeProcess(ProcessStartInfo startInfo, string commandName) { try { var process = Process.Start(startInfo); // See equivalent comment in OutOfProcessNodeInstance.cs for why process.EnableRaisingEvents = true; return process; } catch (Exception ex) { var message = $"Failed to start '{commandName}'. To resolve this:.\n\n" + $"[1] Ensure that '{commandName}' is installed and can be found in one of the PATH directories.\n" + $" Current PATH enviroment variable is: { Environment.GetEnvironmentVariable("PATH") }\n" + " Make sure the executable is in one of those directories, or update your PATH.\n\n" + "[2] See the InnerException for further details of the cause."; throw new InvalidOperationException(message, ex); } } void IDisposable.Dispose() { if (_npmProcess != null && !_npmProcess.HasExited) { _npmProcess.Kill(entireProcessTree: true); _npmProcess = null; } } } internal static class LoggerFinder { public static ILogger GetOrCreateLogger( IApplicationBuilder appBuilder, string logCategoryName) { // If the DI system gives us a logger, use it. Otherwise, set up a default one var loggerFactory = appBuilder.ApplicationServices.GetService(); var logger = loggerFactory != null ? loggerFactory.CreateLogger(logCategoryName) : NullLogger.Instance; return logger; } } /// /// Extension methods for enabling React development server middleware support. /// internal class SpaDevelopmentServerMiddleware { private const string LogCategoryName = "Timeline.SpaServices.SpaServices"; private static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine public static void Attach(ISpaBuilder spaBuilder, string pkgManagerCommand, string scriptName, int port) { var sourcePath = spaBuilder.Options.SourcePath; if (string.IsNullOrEmpty(sourcePath)) { throw new ArgumentException("Cannot be null or empty", nameof(sourcePath)); } if (string.IsNullOrEmpty(scriptName)) { throw new ArgumentException("Cannot be null or empty", nameof(scriptName)); } // Start create-react-app and attach to middleware pipeline var appBuilder = spaBuilder.ApplicationBuilder; var applicationStoppingToken = appBuilder.ApplicationServices.GetRequiredService().ApplicationStopping; var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName); var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService(); var portTask = StartCreateReactAppServerAsync(sourcePath, scriptName, pkgManagerCommand, logger, diagnosticSource, applicationStoppingToken); // Everything we proxy is hardcoded to target http://localhost because: // - the requests are always from the local machine (we're not accepting remote // requests that go directly to the create-react-app server) // - given that, there's no reason to use https, and we couldn't even if we // wanted to, because in general the create-react-app server has no certificate var targetUriTask = portTask.ContinueWith( task => new UriBuilder("http", "localhost", port).Uri); SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, () => { // On each request, we create a separate startup task with its own timeout. That way, even if // the first request times out, subsequent requests could still work. var timeout = spaBuilder.Options.StartupTimeout; return targetUriTask.WithTimeout(timeout, $"The dev server did not start listening for requests " + $"within the timeout period of {timeout.Seconds} seconds. " + $"Check the log output for error information."); }); } private static async Task StartCreateReactAppServerAsync( string sourcePath, string scriptName, string pkgManagerCommand, ILogger logger, DiagnosticSource diagnosticSource, CancellationToken applicationStoppingToken) { logger.LogInformation($"Starting spa server."); var scriptRunner = new NodeScriptRunner( sourcePath, scriptName, null, new Dictionary(), pkgManagerCommand, diagnosticSource, applicationStoppingToken); scriptRunner.AttachToLogger(logger); using (var stdErrReader = new EventedStreamStringReader(scriptRunner.StdErr)) { try { // Although the dev server may eventually tell us the URL it's listening on, // it doesn't do so until it's finished compiling, and even then only if there were // no compiler warnings. So instead of waiting for that, consider it ready as soon // as it starts listening for requests. await scriptRunner.StdOut.WaitForMatch( new Regex("Project is running at", RegexOptions.None, RegexMatchTimeout)); } catch (EndOfStreamException ex) { throw new InvalidOperationException( $"The {pkgManagerCommand} script '{scriptName}' exited without indicating that the " + $"dev server was listening for requests. The error output was: " + $"{stdErrReader.ReadAsString()}", ex); } } } } /// /// Extension methods for enabling development server middleware support. /// public static class SpaDevelopmentServerMiddlewareExtensions { /// /// Handles requests by passing them through to an instance of the create-react-app server. /// This means you can always serve up-to-date CLI-built resources without having /// to run the create-react-app server manually. /// /// This feature should only be used in development. For production deployments, be /// sure not to enable the create-react-app server. /// /// The . /// The name of the script in your package.json file that launches the create-react-app server. public static void UseSpaDevelopmentServer( this ISpaBuilder spaBuilder, string packageManager, string npmScript, int port) { if (spaBuilder == null) { throw new ArgumentNullException(nameof(spaBuilder)); } if (packageManager == null) { throw new ArgumentNullException(nameof(packageManager)); } var spaOptions = spaBuilder.Options; if (string.IsNullOrEmpty(spaOptions.SourcePath)) { throw new InvalidOperationException($"To use {nameof(UseSpaDevelopmentServer)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); } SpaDevelopmentServerMiddleware.Attach(spaBuilder, packageManager, npmScript, port); } } }