diff options
author | crupest <crupest@outlook.com> | 2020-05-30 23:41:31 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-05-30 23:41:31 +0800 |
commit | 85360e8e755b4ce7fd1bd8531d78c06a3b9ffbdf (patch) | |
tree | 710a731990f9dc9a9475baebc5867298e6df33c4 /Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs | |
parent | fc0521d81aa2293b94ea40b79ec0df80966c0278 (diff) | |
parent | 05c104022f71c3b19949205d5d23f07b4cd9a598 (diff) | |
download | timeline-85360e8e755b4ce7fd1bd8531d78c06a3b9ffbdf.tar.gz timeline-85360e8e755b4ce7fd1bd8531d78c06a3b9ffbdf.tar.bz2 timeline-85360e8e755b4ce7fd1bd8531d78c06a3b9ffbdf.zip |
Merge pull request #72 from crupest/merge-frontend
Merge frontend repo into this repo.
Diffstat (limited to 'Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs')
-rw-r--r-- | Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs | 489 |
1 files changed, 489 insertions, 0 deletions
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<T> WithTimeout<T>(this Task<T> task, TimeSpan timeoutDelay, string message)
+ {
+ if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay)))
+ {
+ return task.Result;
+ }
+ else
+ {
+ throw new TimeoutException(message);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Wraps a <see cref="StreamReader"/> to expose an evented API, issuing notifications
+ /// when the stream emits partial lines, completed lines, or finally closes.
+ /// </summary>
+ internal class EventedStreamReader
+ {
+ public delegate void OnReceivedChunkHandler(ArraySegment<char> 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<Match> WaitForMatch(Regex regex)
+ {
+ var tcs = new TaskCompletionSource<Match>();
+ 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<char>(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<char> 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();
+ }
+ }
+
+ /// <summary>
+ /// Captures the completed-line notifications from a <see cref="EventedStreamReader"/>,
+ /// combining the data into a single <see cref="string"/>.
+ /// </summary>
+ 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;
+ }
+ }
+ }
+
+ /// <summary>
+ /// Executes the <c>script</c> entries defined in a <c>package.json</c> file,
+ /// capturing any output written to stdio.
+ /// </summary>
+ 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<string, string> 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<ILoggerFactory>();
+ var logger = loggerFactory != null
+ ? loggerFactory.CreateLogger(logCategoryName)
+ : NullLogger.Instance;
+ return logger;
+ }
+ }
+
+ /// <summary>
+ /// Extension methods for enabling React development server middleware support.
+ /// </summary>
+ 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<IHostApplicationLifetime>().ApplicationStopping;
+ var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName);
+ var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService<DiagnosticSource>();
+ 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<string, string>(), 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);
+ }
+ }
+ }
+ }
+
+ /// <summary>
+ /// Extension methods for enabling development server middleware support.
+ /// </summary>
+ public static class SpaDevelopmentServerMiddlewareExtensions
+ {
+ /// <summary>
+ /// 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.
+ /// </summary>
+ /// <param name="spaBuilder">The <see cref="ISpaBuilder"/>.</param>
+ /// <param name="npmScript">The name of the script in your package.json file that launches the create-react-app server.</param>
+ 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);
+ }
+ }
+}
|