aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2020-06-16 16:39:16 +0800
committercrupest <crupest@outlook.com>2020-06-16 16:39:16 +0800
commitcb8ff68d5e3d5f286676673c530e3a2ee3719772 (patch)
tree6c00ccfea5602a6502b758c10c7a5a0f0a2607bd
parent441f8cdd6282a1d4d814b85d353c1553283411ec (diff)
downloadtimeline-cb8ff68d5e3d5f286676673c530e3a2ee3719772.tar.gz
timeline-cb8ff68d5e3d5f286676673c530e3a2ee3719772.tar.bz2
timeline-cb8ff68d5e3d5f286676673c530e3a2ee3719772.zip
refactor(back): Fix #108 .
-rw-r--r--Timeline/Configs/ApplicationConfiguration.cs2
-rw-r--r--Timeline/Properties/launchSettings.json2
-rw-r--r--Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs489
-rw-r--r--Timeline/Startup.cs11
4 files changed, 4 insertions, 500 deletions
diff --git a/Timeline/Configs/ApplicationConfiguration.cs b/Timeline/Configs/ApplicationConfiguration.cs
index 64ab6f6c..df281adb 100644
--- a/Timeline/Configs/ApplicationConfiguration.cs
+++ b/Timeline/Configs/ApplicationConfiguration.cs
@@ -7,7 +7,7 @@
public const string DatabaseFileName = "timeline.db";
public const string DatabaseBackupDirectoryName = "backup";
public const string DisableFrontEndKey = "DisableFrontEnd";
- public const string FrontEndProxyOnlyKey = "FrontEndProxyOnly";
public const string UseMockFrontEndKey = "UseMockFrontEnd";
+ public const string UseProxyFrontEndKey = "UseProxyFrontEnd";
}
}
diff --git a/Timeline/Properties/launchSettings.json b/Timeline/Properties/launchSettings.json
index d23d132f..73897158 100644
--- a/Timeline/Properties/launchSettings.json
+++ b/Timeline/Properties/launchSettings.json
@@ -4,7 +4,7 @@
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
- "ASPNETCORE_FRONTENDPROXYONLY": "true",
+ "ASPNETCORE_USEPROXYFRONTEND": "true",
"ASPNETCORE_WORKDIR": "D:\\timeline-development"
}
},
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<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);
- }
- }
-}
diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs
index 2eb08f96..11b6fa9e 100644
--- a/Timeline/Startup.cs
+++ b/Timeline/Startup.cs
@@ -143,16 +143,9 @@ namespace Timeline
{
spa.Options.SourcePath = useMockFrontEnd ? "MockClientApp" : "ClientApp";
- if (!useMockFrontEnd && Environment.IsDevelopment())
+ if (!useMockFrontEnd && (Configuration.GetValue<bool?>(ApplicationConfiguration.UseProxyFrontEndKey) ?? false))
{
- if (Configuration.GetValue<bool?>(ApplicationConfiguration.FrontEndProxyOnlyKey) ?? false)
- {
- spa.UseProxyToSpaDevelopmentServer(new UriBuilder("http", "localhost", 3000).Uri);
- }
- else
- {
- SpaServices.SpaDevelopmentServerMiddlewareExtensions.UseSpaDevelopmentServer(spa, packageManager: "yarn", npmScript: "install-and-start", port: 3000);
- }
+ spa.UseProxyToSpaDevelopmentServer(new UriBuilder("http", "localhost", 3000).Uri);
}
});
}