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);
+ }
+ }
+}