From 2ffc07756ca7d50533e90fdac37d62a6c33a8a00 Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 30 May 2020 23:16:49 +0800 Subject: Just get everything works! --- .../SpaServices/SpaDevelopmentServerMiddleware.cs | 489 +++++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs (limited to 'Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs') diff --git a/Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs b/Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs new file mode 100644 index 00000000..059de989 --- /dev/null +++ b/Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs @@ -0,0 +1,489 @@ +// 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); + } + } +} -- cgit v1.2.3