aboutsummaryrefslogtreecommitdiff
path: root/Timeline
diff options
context:
space:
mode:
Diffstat (limited to 'Timeline')
-rw-r--r--Timeline/ClientApp/package.json1
-rw-r--r--Timeline/ClientApp/src/config.ts13
-rw-r--r--Timeline/ClientApp/webpack.config.prod.dev.ts15
-rw-r--r--Timeline/Routes/ApiRoutePrefixConvention.cs3
-rw-r--r--Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs489
-rw-r--r--Timeline/Startup.cs4
6 files changed, 493 insertions, 32 deletions
diff --git a/Timeline/ClientApp/package.json b/Timeline/ClientApp/package.json
index 2c9f682d..11130ccb 100644
--- a/Timeline/ClientApp/package.json
+++ b/Timeline/ClientApp/package.json
@@ -27,7 +27,6 @@
},
"scripts": {
"start": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack-config.json\" webpack-dev-server --config ./webpack.config.dev.ts",
- "start-prod": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack-config.json\" webpack --config ./webpack.config.prod.dev.ts && http-server dist -p 3000",
"build": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack-config.json\" webpack --config ./webpack.config.prod.ts",
"lint": "eslint src/ --ext .js --ext .jsx --ext .ts --ext .tsx"
},
diff --git a/Timeline/ClientApp/src/config.ts b/Timeline/ClientApp/src/config.ts
index 2f088a10..d06c3332 100644
--- a/Timeline/ClientApp/src/config.ts
+++ b/Timeline/ClientApp/src/config.ts
@@ -1,12 +1 @@
-export const apiBaseUrl = (function () {
- if (process.env.TIMELINE_USEDEVAPI) {
- console.log('process.env.TIMELINE_USEDEVAPI is set, use dev api server!');
- return 'http://localhost:5000';
- } else if (process.env.NODE_ENV === 'production') {
- console.log('Production mode!');
- return 'https://api.crupest.xyz';
- } else {
- console.log('Development mode!');
- return 'http://localhost:5000';
- }
-})();
+export const apiBaseUrl = '/api';
diff --git a/Timeline/ClientApp/webpack.config.prod.dev.ts b/Timeline/ClientApp/webpack.config.prod.dev.ts
deleted file mode 100644
index 8acc2355..00000000
--- a/Timeline/ClientApp/webpack.config.prod.dev.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import webpack from 'webpack';
-
-import baseConfig from './webpack.config.prod';
-
-const config: webpack.Configuration = {
- ...baseConfig,
- plugins: [
- ...baseConfig.plugins,
- new webpack.DefinePlugin({
- 'process.env.TIMELINE_USEDEVAPI': true,
- }),
- ],
-};
-
-export default config;
diff --git a/Timeline/Routes/ApiRoutePrefixConvention.cs b/Timeline/Routes/ApiRoutePrefixConvention.cs
index 2653c2ca..ca38a0d9 100644
--- a/Timeline/Routes/ApiRoutePrefixConvention.cs
+++ b/Timeline/Routes/ApiRoutePrefixConvention.cs
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
+using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using System.Linq;
@@ -29,7 +30,7 @@ namespace Timeline.Routes
public void Apply(ApplicationModel application)
{
- foreach (var selector in application.Controllers.Where(c => c.RouteValues.TryGetValue("area", out var value) && value == "api").SelectMany(c => c.Selectors))
+ foreach (var selector in application.Controllers.Where(c => c.Filters.Any(f => f is IApiBehaviorMetadata)).SelectMany(c => c.Selectors))
{
if (selector.AttributeRouteModel != null)
{
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);
+ }
+ }
+}
diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs
index 8da09686..9651d58e 100644
--- a/Timeline/Startup.cs
+++ b/Timeline/Startup.cs
@@ -127,9 +127,7 @@ namespace Timeline
if (Environment.IsDevelopment())
{
- // TODO! I'll waiting for aspnetcore to support custom package manager and port.
- // It is already in master branch code but not published.
- spa.UseReactDevelopmentServer(npmScript: "start");
+ SpaServices.SpaDevelopmentServerMiddlewareExtensions.UseSpaDevelopmentServer(spa, packageManager: "yarn", npmScript: "start", port: 3000);
}
});
}