From 96d7f853277788d52dc33027f31a8db4c4786cf1 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 13 Nov 2017 12:35:41 +0000 Subject: [PATCH] Add UseReactDevelopmentServer() middleware. Factor out common code. --- .../AngularCli/AngularCliBuilder.cs | 8 +- .../AngularCli/AngularCliMiddleware.cs | 45 ++------- .../Npm/NpmScriptRunner.cs | 16 ++- .../ReactDevelopmentServerMiddleware.cs | 97 +++++++++++++++++++ ...ctDevelopmentServerMiddlewareExtensions.cs | 43 ++++++++ .../{Npm => Util}/EventedStreamReader.cs | 0 .../EventedStreamStringReader.cs | 0 .../Util/LoggerFinder.cs | 25 +++++ .../Util/TcpPortFinder.cs | 25 +++++ 9 files changed, 218 insertions(+), 41 deletions(-) create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/ReactDevelopmentServer/ReactDevelopmentServerMiddlewareExtensions.cs rename src/Microsoft.AspNetCore.SpaServices.Extensions/{Npm => Util}/EventedStreamReader.cs (100%) rename src/Microsoft.AspNetCore.SpaServices.Extensions/{Npm => Util}/EventedStreamStringReader.cs (100%) create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Util/LoggerFinder.cs create mode 100644 src/Microsoft.AspNetCore.SpaServices.Extensions/Util/TcpPortFinder.cs diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs index 30dc206..9ba100b 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliBuilder.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.NodeServices.Npm; using Microsoft.AspNetCore.NodeServices.Util; using Microsoft.AspNetCore.SpaServices.Prerendering; +using Microsoft.AspNetCore.SpaServices.Util; using System; using System.IO; using System.Text.RegularExpressions; @@ -46,11 +47,14 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli throw new InvalidOperationException($"To use {nameof(AngularCliBuilder)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); } - var logger = AngularCliMiddleware.GetOrCreateLogger(spaBuilder.ApplicationBuilder); + var logger = LoggerFinder.GetOrCreateLogger( + spaBuilder.ApplicationBuilder, + nameof(AngularCliBuilder)); var npmScriptRunner = new NpmScriptRunner( sourcePath, _npmScriptName, - "--watch"); + "--watch", + null); npmScriptRunner.AttachToLogger(logger); using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr)) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs index 349deb6..89a5c37 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/AngularCli/AngularCliMiddleware.cs @@ -2,17 +2,14 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Builder; -using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.NodeServices.Npm; -using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Console; -using System.Net.Sockets; -using System.Net; -using System.IO; +using Microsoft.AspNetCore.NodeServices.Npm; using Microsoft.AspNetCore.NodeServices.Util; +using Microsoft.AspNetCore.SpaServices.Util; +using System; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading.Tasks; namespace Microsoft.AspNetCore.SpaServices.AngularCli { @@ -39,7 +36,7 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli // Start Angular CLI and attach to middleware pipeline var appBuilder = spaBuilder.ApplicationBuilder; - var logger = GetOrCreateLogger(appBuilder); + var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName); var angularCliServerInfoTask = StartAngularCliServerAsync(sourcePath, npmScriptName, logger); // Everything we proxy is hardcoded to target http://localhost because: @@ -53,24 +50,14 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, targetUriTask); } - internal static ILogger GetOrCreateLogger(IApplicationBuilder appBuilder) - { - // 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) - : new ConsoleLogger(LogCategoryName, null, false); - return logger; - } - private static async Task StartAngularCliServerAsync( string sourcePath, string npmScriptName, ILogger logger) { - var portNumber = FindAvailablePort(); + var portNumber = TcpPortFinder.FindAvailablePort(); logger.LogInformation($"Starting @angular/cli on port {portNumber}..."); var npmScriptRunner = new NpmScriptRunner( - sourcePath, npmScriptName, $"--port {portNumber}"); + sourcePath, npmScriptName, $"--port {portNumber}", null); npmScriptRunner.AttachToLogger(logger); Match openBrowserLine; @@ -109,20 +96,6 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli return serverInfo; } - private static int FindAvailablePort() - { - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - try - { - return ((IPEndPoint)listener.LocalEndpoint).Port; - } - finally - { - listener.Stop(); - } - } - class AngularCliServerInfo { public int Port { get; set; } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs index dfb8852..378ec5f 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs @@ -7,6 +7,7 @@ using System; using System.Diagnostics; using System.Runtime.InteropServices; using System.Text.RegularExpressions; +using System.Collections.Generic; // This is under the NodeServices namespace because post 2.1 it will be moved to that package namespace Microsoft.AspNetCore.NodeServices.Npm @@ -22,7 +23,7 @@ namespace Microsoft.AspNetCore.NodeServices.Npm private static Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1)); - public NpmScriptRunner(string workingDirectory, string scriptName, string arguments) + public NpmScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary envVars) { if (string.IsNullOrEmpty(workingDirectory)) { @@ -45,7 +46,7 @@ namespace Microsoft.AspNetCore.NodeServices.Npm completeArguments = $"/c npm {completeArguments}"; } - var process = LaunchNodeProcess(new ProcessStartInfo(npmExe) + var processStartInfo = new ProcessStartInfo(npmExe) { Arguments = completeArguments, UseShellExecute = false, @@ -53,8 +54,17 @@ namespace Microsoft.AspNetCore.NodeServices.Npm RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = workingDirectory - }); + }; + if (envVars != null) + { + foreach (var keyValuePair in envVars) + { + processStartInfo.Environment[keyValuePair.Key] = keyValuePair.Value; + } + } + + var process = LaunchNodeProcess(processStartInfo); StdOut = new EventedStreamReader(process.StandardOutput); StdErr = new EventedStreamReader(process.StandardError); } diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs new file mode 100644 index 0000000..48fc088 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs @@ -0,0 +1,97 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.NodeServices.Npm; +using Microsoft.AspNetCore.NodeServices.Util; +using Microsoft.AspNetCore.SpaServices.Util; +using System; +using System.IO; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer +{ + internal static class ReactDevelopmentServerMiddleware + { + private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices"; + private static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine + private static TimeSpan StartupTimeout = TimeSpan.FromSeconds(50); // Note that the HTTP request itself by default times out after 60s, so you only get useful error information if this is shorter + + public static void Attach( + ISpaBuilder spaBuilder, + string npmScriptName) + { + var sourcePath = spaBuilder.Options.SourcePath; + if (string.IsNullOrEmpty(sourcePath)) + { + throw new ArgumentException("Cannot be null or empty", nameof(sourcePath)); + } + + if (string.IsNullOrEmpty(npmScriptName)) + { + throw new ArgumentException("Cannot be null or empty", nameof(npmScriptName)); + } + + // Start create-react-app and attach to middleware pipeline + var appBuilder = spaBuilder.ApplicationBuilder; + var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName); + var portTask = StartCreateReactAppServerAsync(sourcePath, npmScriptName, logger); + + // 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", task.Result).Uri); + + SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, targetUriTask); + } + + private static async Task StartCreateReactAppServerAsync( + string sourcePath, string npmScriptName, ILogger logger) + { + var portNumber = TcpPortFinder.FindAvailablePort(); + logger.LogInformation($"Starting create-react-app server on port {portNumber}..."); + + var envVars = new Dictionary + { + { "PORT", portNumber.ToString() } + }; + var npmScriptRunner = new NpmScriptRunner( + sourcePath, npmScriptName, null, envVars); + npmScriptRunner.AttachToLogger(logger); + + Match openBrowserLine; + using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr)) + { + try + { + openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch( + new Regex("Local:\\s*(http\\S+)", RegexOptions.None, RegexMatchTimeout), + StartupTimeout); + } + catch (EndOfStreamException ex) + { + throw new InvalidOperationException( + $"The NPM script '{npmScriptName}' exited without indicating that the " + + $"create-react-app server was listening for requests. The error output was: " + + $"{stdErrReader.ReadAsString()}", ex); + } + catch (TaskCanceledException ex) + { + throw new InvalidOperationException( + $"The create-react-app server did not start listening for requests " + + $"within the timeout period of {StartupTimeout.Seconds} seconds. " + + $"Check the log output for error information.", ex); + } + } + + var uri = new Uri(openBrowserLine.Groups[1].Value); + return uri.Port; + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/ReactDevelopmentServer/ReactDevelopmentServerMiddlewareExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/ReactDevelopmentServer/ReactDevelopmentServerMiddlewareExtensions.cs new file mode 100644 index 0000000..f58a6d1 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/ReactDevelopmentServer/ReactDevelopmentServerMiddlewareExtensions.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; +using System; + +namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer +{ + /// + /// Extension methods for enabling React development server middleware support. + /// + public static class ReactDevelopmentServerMiddlewareExtensions + { + /// + /// 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 UseReactDevelopmentServer( + this ISpaBuilder spaBuilder, + string npmScript) + { + if (spaBuilder == null) + { + throw new ArgumentNullException(nameof(spaBuilder)); + } + + var spaOptions = spaBuilder.Options; + + if (string.IsNullOrEmpty(spaOptions.SourcePath)) + { + throw new InvalidOperationException($"To use {nameof(UseReactDevelopmentServer)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); + } + + ReactDevelopmentServerMiddleware.Attach(spaBuilder, npmScript); + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Util/EventedStreamReader.cs similarity index 100% rename from src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamReader.cs rename to src/Microsoft.AspNetCore.SpaServices.Extensions/Util/EventedStreamReader.cs diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamStringReader.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Util/EventedStreamStringReader.cs similarity index 100% rename from src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/EventedStreamStringReader.cs rename to src/Microsoft.AspNetCore.SpaServices.Extensions/Util/EventedStreamStringReader.cs diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Util/LoggerFinder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Util/LoggerFinder.cs new file mode 100644 index 0000000..54e57de --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Util/LoggerFinder.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; + +namespace Microsoft.AspNetCore.SpaServices.Util +{ + 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) + : new ConsoleLogger(logCategoryName, null, false); + return logger; + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Util/TcpPortFinder.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Util/TcpPortFinder.cs new file mode 100644 index 0000000..1682ff7 --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Util/TcpPortFinder.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Net; +using System.Net.Sockets; + +namespace Microsoft.AspNetCore.SpaServices.Util +{ + internal static class TcpPortFinder + { + public static int FindAvailablePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + } +}