From cb8ff68d5e3d5f286676673c530e3a2ee3719772 Mon Sep 17 00:00:00 2001 From: crupest Date: Tue, 16 Jun 2020 16:39:16 +0800 Subject: refactor(back): Fix #108 . --- .../SpaServices/SpaDevelopmentServerMiddleware.cs | 489 --------------------- 1 file changed, 489 deletions(-) delete mode 100644 Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs (limited to 'Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs') diff --git a/Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs b/Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs deleted file mode 100644 index 059de989..00000000 --- a/Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs +++ /dev/null @@ -1,489 +0,0 @@ -// 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