diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj index e263efb9..26375de6 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Microsoft.AspNetCore.SpaServices.Extensions.csproj @@ -15,4 +15,8 @@ + + + + diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs index 378ec5f9..04e5fc24 100644 --- a/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Npm/NpmScriptRunner.cs @@ -23,6 +23,46 @@ internal class NpmScriptRunner private static Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1)); + public NpmScriptRunner(string workingDirectory, string scriptName, string arguments) + { + 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)); + } + + var npmExe = "npm"; + var completeArguments = $"run {scriptName} {arguments ?? string.Empty}"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // On Windows, the NPM 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". + npmExe = "cmd"; + completeArguments = $"/c npm {completeArguments}"; + } + + var processStartInfo = new ProcessStartInfo(npmExe) + { + Arguments = completeArguments, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = workingDirectory + }; + + var process = LaunchNodeProcess(processStartInfo); + StdOut = new EventedStreamReader(process.StandardOutput); + //StdOut = new EventedStreamReader(process.StandardOutput); + + StdErr = new EventedStreamReader(process.StandardError); + } + public NpmScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary envVars) { if (string.IsNullOrEmpty(workingDirectory)) diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Vue/VueCliMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Vue/VueCliMiddleware.cs new file mode 100644 index 00000000..884b079d --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Vue/VueCliMiddleware.cs @@ -0,0 +1,159 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.NodeServices.Npm; +using Microsoft.AspNetCore.NodeServices.Util; +using Microsoft.AspNetCore.SpaServices.Extensions.Util; +using Microsoft.AspNetCore.SpaServices.Util; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.SpaServices.Extensions.Vue +{ + internal static class VueCliMiddleware + { + 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 + + 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 Vue CLI and attach to middleware pipeline + var appBuilder = spaBuilder.ApplicationBuilder; + var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName); + var angularCliServerInfoTask = StartVueCliServerAsync(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 Vue CLI middleware server) + // - given that, there's no reason to use https, and we couldn't even if we + // wanted to, because in general the Vue CLI server has no certificate + var targetUriTask = angularCliServerInfoTask.ContinueWith( + task => new UriBuilder("http", "localhost", task.Result.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 Vue CLI process 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 StartVueCliServerAsync( + string sourcePath, string npmScriptName, ILogger logger) + { + var portNumber = TcpPortFinder.FindAvailablePort(); + + + + + //logger.LogTrace($"Starting Vue/dev-server on port {portNumber}..."); + //logger.LogDebug($"Starting Vue/dev-server on port {portNumber}..."); + //logger.LogInformation($"Starting Vue/dev-server on port {portNumber}..."); + //logger.LogWarning($"Starting Vue/dev-server on port {portNumber}..."); + //logger.LogError($"Starting Vue/dev-server on port {portNumber}..."); + //logger.LogCritical($"Starting Vue/dev-server on port {portNumber}..."); + + + + + var npmScriptRunner = new NpmScriptRunner( + //sourcePath, npmScriptName, $"--port {portNumber}"); + sourcePath, npmScriptName, $"{portNumber}"); + + npmScriptRunner.AttachToLogger(logger); + + Match openBrowserLine; + using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr)) + { + try + { + //openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch( + // new Regex("open your browser on (http\\S+)", RegexOptions.None, RegexMatchTimeout)); + openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch( + new Regex("- Local: (http:\\S+/)", RegexOptions.None, RegexMatchTimeout)); + } + catch (EndOfStreamException ex) + { + throw new InvalidOperationException( + $"The NPM script '{npmScriptName}' exited without indicating that the " + + $"Angular CLI was listening for requests. The error output was: " + + $"{stdErrReader.ReadAsString()}", ex); + } + } + + var uri = new Uri(openBrowserLine.Groups[1].Value); + var serverInfo = new VueCliServerInfo { Port = uri.Port }; + + // Even after the Angular CLI claims to be listening for requests, there's a short + // period where it will give an error if you make a request too quickly + await WaitForVueCliServerToAcceptRequests(uri); + return serverInfo; + } + + private static async Task WaitForVueCliServerToAcceptRequests(Uri cliServerUri) + { + // To determine when it's actually ready, try making HEAD requests to '/'. If it + // produces any HTTP response (even if it's 404) then it's ready. If it rejects the + // connection then it's not ready. We keep trying forever because this is dev-mode + // only, and only a single startup attempt will be made, and there's a further level + // of timeouts enforced on a per-request basis. + var timeoutMilliseconds = 1000; + using (var client = new HttpClient()) + { + while (true) + { + try + { + // If we get any HTTP response, the CLI server is ready + await client.SendAsync( + new HttpRequestMessage(HttpMethod.Head, cliServerUri), + new CancellationTokenSource(timeoutMilliseconds).Token); + return; + } + catch (Exception) + { + await Task.Delay(500); + + // Depending on the host's networking configuration, the requests can take a while + // to go through, most likely due to the time spent resolving 'localhost'. + // Each time we have a failure, allow a bit longer next time (up to a maximum). + // This only influences the time until we regard the dev server as 'ready', so it + // doesn't affect the runtime perf (even in dev mode) once the first connection is made. + // Resolves https://github.com/aspnet/JavaScriptServices/issues/1611 + if (timeoutMilliseconds < 10000) + { + timeoutMilliseconds += 3000; + } + } + } + } + } + + class VueCliServerInfo + { + public int Port { get; set; } + } + } +} diff --git a/src/Microsoft.AspNetCore.SpaServices.Extensions/Vue/VueCliMiddlewareExtensions.cs b/src/Microsoft.AspNetCore.SpaServices.Extensions/Vue/VueCliMiddlewareExtensions.cs new file mode 100644 index 00000000..c755b66c --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices.Extensions/Vue/VueCliMiddlewareExtensions.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Builder; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.AspNetCore.SpaServices.Extensions.Vue +{ + /// + /// Extension methods for enabling Vue CLI middleware support. + /// + public static class VueCliMiddlewareExtensions + { + /// + /// Handles requests by passing them through to an instance of the Vue CLI server. + /// This means you can always serve up-to-date CLI-built resources without having + /// to run the Vue CLI server manually. + /// + /// This feature should only be used in development. For production deployments, be + /// sure not to enable the Vue CLI server. + /// + /// The . + /// The name of the script in your package.json file that launches the Vue CLI process. + public static void UseVueCliServer( + 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(UseVueCliServer)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); + } + + VueCliMiddleware.Attach(spaBuilder, npmScript); + } + } +}