diff --git a/src/Middleware/SpaServices.Extensions/ref/Microsoft.AspNetCore.SpaServices.Extensions.netcoreapp3.0.cs b/src/Middleware/SpaServices.Extensions/ref/Microsoft.AspNetCore.SpaServices.Extensions.netcoreapp3.0.cs
index be28dc70e2..9dfedd1041 100644
--- a/src/Middleware/SpaServices.Extensions/ref/Microsoft.AspNetCore.SpaServices.Extensions.netcoreapp3.0.cs
+++ b/src/Middleware/SpaServices.Extensions/ref/Microsoft.AspNetCore.SpaServices.Extensions.netcoreapp3.0.cs
@@ -38,6 +38,7 @@ namespace Microsoft.AspNetCore.SpaServices
public SpaOptions() { }
public Microsoft.AspNetCore.Http.PathString DefaultPage { get { throw null; } set { } }
public Microsoft.AspNetCore.Builder.StaticFileOptions DefaultPageStaticFileOptions { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
+ public string PackageManagerCommand { get { throw null; } set { } }
public string SourcePath { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public System.TimeSpan StartupTimeout { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
}
diff --git a/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliBuilder.cs b/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliBuilder.cs
index 61dedd350a..c78e194726 100644
--- a/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliBuilder.cs
+++ b/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliBuilder.cs
@@ -1,4 +1,4 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// 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;
@@ -21,7 +21,7 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
{
private static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine
- private readonly string _npmScriptName;
+ private readonly string _scriptName;
///
/// Constructs an instance of .
@@ -34,12 +34,13 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
throw new ArgumentException("Cannot be null or empty.", nameof(npmScript));
}
- _npmScriptName = npmScript;
+ _scriptName = npmScript;
}
///
public async Task Build(ISpaBuilder spaBuilder)
{
+ var pkgManagerCommand = spaBuilder.Options.PackageManagerCommand;
var sourcePath = spaBuilder.Options.SourcePath;
if (string.IsNullOrEmpty(sourcePath))
{
@@ -49,32 +50,33 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
var logger = LoggerFinder.GetOrCreateLogger(
spaBuilder.ApplicationBuilder,
nameof(AngularCliBuilder));
- var npmScriptRunner = new NpmScriptRunner(
+ var scriptRunner = new NodeScriptRunner(
sourcePath,
- _npmScriptName,
+ _scriptName,
"--watch",
- null);
- npmScriptRunner.AttachToLogger(logger);
+ null,
+ pkgManagerCommand);
+ scriptRunner.AttachToLogger(logger);
- using (var stdOutReader = new EventedStreamStringReader(npmScriptRunner.StdOut))
- using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr))
+ using (var stdOutReader = new EventedStreamStringReader(scriptRunner.StdOut))
+ using (var stdErrReader = new EventedStreamStringReader(scriptRunner.StdErr))
{
try
{
- await npmScriptRunner.StdOut.WaitForMatch(
+ await scriptRunner.StdOut.WaitForMatch(
new Regex("Date", RegexOptions.None, RegexMatchTimeout));
}
catch (EndOfStreamException ex)
{
throw new InvalidOperationException(
- $"The NPM script '{_npmScriptName}' exited without indicating success.\n" +
+ $"The {pkgManagerCommand} script '{_scriptName}' exited without indicating success.\n" +
$"Output was: {stdOutReader.ReadAsString()}\n" +
$"Error output was: {stdErrReader.ReadAsString()}", ex);
}
catch (OperationCanceledException ex)
{
throw new InvalidOperationException(
- $"The NPM script '{_npmScriptName}' timed out without indicating success. " +
+ $"The {pkgManagerCommand} script '{_scriptName}' timed out without indicating success. " +
$"Output was: {stdOutReader.ReadAsString()}\n" +
$"Error output was: {stdErrReader.ReadAsString()}", ex);
}
diff --git a/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddleware.cs b/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddleware.cs
index 9090f7738b..c4e109b8f7 100644
--- a/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddleware.cs
+++ b/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddleware.cs
@@ -23,23 +23,24 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
public static void Attach(
ISpaBuilder spaBuilder,
- string npmScriptName)
+ string scriptName)
{
+ var pkgManagerCommand = spaBuilder.Options.PackageManagerCommand;
var sourcePath = spaBuilder.Options.SourcePath;
if (string.IsNullOrEmpty(sourcePath))
{
throw new ArgumentException("Cannot be null or empty", nameof(sourcePath));
}
- if (string.IsNullOrEmpty(npmScriptName))
+ if (string.IsNullOrEmpty(scriptName))
{
- throw new ArgumentException("Cannot be null or empty", nameof(npmScriptName));
+ throw new ArgumentException("Cannot be null or empty", nameof(scriptName));
}
// Start Angular CLI and attach to middleware pipeline
var appBuilder = spaBuilder.ApplicationBuilder;
var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName);
- var angularCliServerInfoTask = StartAngularCliServerAsync(sourcePath, npmScriptName, logger);
+ var angularCliServerInfoTask = StartAngularCliServerAsync(sourcePath, scriptName, pkgManagerCommand, logger);
// Everything we proxy is hardcoded to target http://localhost because:
// - the requests are always from the local machine (we're not accepting remote
@@ -62,27 +63,27 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
}
private static async Task StartAngularCliServerAsync(
- string sourcePath, string npmScriptName, ILogger logger)
+ string sourcePath, string scriptName, string pkgManagerCommand, ILogger logger)
{
var portNumber = TcpPortFinder.FindAvailablePort();
logger.LogInformation($"Starting @angular/cli on port {portNumber}...");
- var npmScriptRunner = new NpmScriptRunner(
- sourcePath, npmScriptName, $"--port {portNumber}", null);
- npmScriptRunner.AttachToLogger(logger);
+ var scriptRunner = new NodeScriptRunner(
+ sourcePath, scriptName, $"--port {portNumber}", null, pkgManagerCommand);
+ scriptRunner.AttachToLogger(logger);
Match openBrowserLine;
- using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr))
+ using (var stdErrReader = new EventedStreamStringReader(scriptRunner.StdErr))
{
try
{
- openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch(
+ openBrowserLine = await scriptRunner.StdOut.WaitForMatch(
new Regex("open your browser on (http\\S+)", RegexOptions.None, RegexMatchTimeout));
}
catch (EndOfStreamException ex)
{
throw new InvalidOperationException(
- $"The NPM script '{npmScriptName}' exited without indicating that the " +
+ $"The {pkgManagerCommand} script '{scriptName}' exited without indicating that the " +
$"Angular CLI was listening for requests. The error output was: " +
$"{stdErrReader.ReadAsString()}", ex);
}
diff --git a/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddlewareExtensions.cs b/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddlewareExtensions.cs
index 28e63c8e35..8f8176447b 100644
--- a/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddlewareExtensions.cs
+++ b/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddlewareExtensions.cs
@@ -1,4 +1,4 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// 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;
diff --git a/src/Middleware/SpaServices.Extensions/src/Npm/NpmScriptRunner.cs b/src/Middleware/SpaServices.Extensions/src/Npm/NodeScriptRunner.cs
similarity index 77%
rename from src/Middleware/SpaServices.Extensions/src/Npm/NpmScriptRunner.cs
rename to src/Middleware/SpaServices.Extensions/src/Npm/NodeScriptRunner.cs
index 378ec5f9fa..f08abeb19c 100644
--- a/src/Middleware/SpaServices.Extensions/src/Npm/NpmScriptRunner.cs
+++ b/src/Middleware/SpaServices.Extensions/src/Npm/NodeScriptRunner.cs
@@ -1,4 +1,4 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// 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.Extensions.Logging;
@@ -16,14 +16,14 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
/// Executes the script entries defined in a package.json file,
/// capturing any output written to stdio.
///
- internal class NpmScriptRunner
+ internal class NodeScriptRunner
{
public EventedStreamReader StdOut { get; }
public EventedStreamReader StdErr { get; }
private static Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1));
- public NpmScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary envVars)
+ public NodeScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary envVars, string pkgManagerCommand)
{
if (string.IsNullOrEmpty(workingDirectory))
{
@@ -35,18 +35,23 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
throw new ArgumentException("Cannot be null or empty.", nameof(scriptName));
}
- var npmExe = "npm";
+ 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 NPM executable is a .cmd file, so it can't be executed
+ // 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".
- npmExe = "cmd";
- completeArguments = $"/c npm {completeArguments}";
+ exeToRun = "cmd";
+ completeArguments = $"/c {pkgManagerCommand} {completeArguments}";
}
- var processStartInfo = new ProcessStartInfo(npmExe)
+ var processStartInfo = new ProcessStartInfo(exeToRun)
{
Arguments = completeArguments,
UseShellExecute = false,
@@ -64,19 +69,19 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
}
}
- var process = LaunchNodeProcess(processStartInfo);
+ var process = LaunchNodeProcess(processStartInfo, pkgManagerCommand);
StdOut = new EventedStreamReader(process.StandardOutput);
StdErr = new EventedStreamReader(process.StandardError);
}
public void AttachToLogger(ILogger logger)
{
- // When the NPM task emits complete lines, pass them through to the real logger
+ // When the node task emits complete lines, pass them through to the real logger
StdOut.OnReceivedLine += line =>
{
if (!string.IsNullOrWhiteSpace(line))
{
- // NPM tasks commonly emit ANSI colors, but it wouldn't make sense to forward
+ // 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));
}
@@ -106,7 +111,7 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
private static string StripAnsiColors(string line)
=> AnsiColorRegex.Replace(line, string.Empty);
- private static Process LaunchNodeProcess(ProcessStartInfo startInfo)
+ private static Process LaunchNodeProcess(ProcessStartInfo startInfo, string commandName)
{
try
{
@@ -119,8 +124,8 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
}
catch (Exception ex)
{
- var message = $"Failed to start 'npm'. To resolve this:.\n\n"
- + "[1] Ensure that 'npm' is installed and can be found in one of the PATH directories.\n"
+ 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.";
diff --git a/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs b/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs
index 78a7b4f03f..6566fef706 100644
--- a/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs
+++ b/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs
@@ -22,23 +22,24 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
public static void Attach(
ISpaBuilder spaBuilder,
- string npmScriptName)
+ string scriptName)
{
+ var pkgManagerCommand = spaBuilder.Options.PackageManagerCommand;
var sourcePath = spaBuilder.Options.SourcePath;
if (string.IsNullOrEmpty(sourcePath))
{
throw new ArgumentException("Cannot be null or empty", nameof(sourcePath));
}
- if (string.IsNullOrEmpty(npmScriptName))
+ if (string.IsNullOrEmpty(scriptName))
{
- throw new ArgumentException("Cannot be null or empty", nameof(npmScriptName));
+ throw new ArgumentException("Cannot be null or empty", nameof(scriptName));
}
// 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);
+ var portTask = StartCreateReactAppServerAsync(sourcePath, scriptName, pkgManagerCommand, logger);
// Everything we proxy is hardcoded to target http://localhost because:
// - the requests are always from the local machine (we're not accepting remote
@@ -61,7 +62,7 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
}
private static async Task StartCreateReactAppServerAsync(
- string sourcePath, string npmScriptName, ILogger logger)
+ string sourcePath, string scriptName, string pkgManagerCommand, ILogger logger)
{
var portNumber = TcpPortFinder.FindAvailablePort();
logger.LogInformation($"Starting create-react-app server on port {portNumber}...");
@@ -71,11 +72,11 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
{ "PORT", portNumber.ToString() },
{ "BROWSER", "none" }, // We don't want create-react-app to open its own extra browser window pointing to the internal dev server port
};
- var npmScriptRunner = new NpmScriptRunner(
- sourcePath, npmScriptName, null, envVars);
- npmScriptRunner.AttachToLogger(logger);
+ var scriptRunner = new NodeScriptRunner(
+ sourcePath, scriptName, null, envVars, pkgManagerCommand);
+ scriptRunner.AttachToLogger(logger);
- using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr))
+ using (var stdErrReader = new EventedStreamStringReader(scriptRunner.StdErr))
{
try
{
@@ -83,13 +84,13 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
// 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 npmScriptRunner.StdOut.WaitForMatch(
+ await scriptRunner.StdOut.WaitForMatch(
new Regex("Starting the development server", RegexOptions.None, RegexMatchTimeout));
}
catch (EndOfStreamException ex)
{
throw new InvalidOperationException(
- $"The NPM script '{npmScriptName}' exited without indicating that the " +
+ $"The {pkgManagerCommand} script '{scriptName}' exited without indicating that the " +
$"create-react-app server was listening for requests. The error output was: " +
$"{stdErrReader.ReadAsString()}", ex);
}
diff --git a/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddlewareExtensions.cs b/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddlewareExtensions.cs
index f58a6d1a9d..346e839046 100644
--- a/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddlewareExtensions.cs
+++ b/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddlewareExtensions.cs
@@ -1,4 +1,4 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// 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;
diff --git a/src/Middleware/SpaServices.Extensions/src/SpaOptions.cs b/src/Middleware/SpaServices.Extensions/src/SpaOptions.cs
index b2823396dc..59ccc1eda4 100644
--- a/src/Middleware/SpaServices.Extensions/src/SpaOptions.cs
+++ b/src/Middleware/SpaServices.Extensions/src/SpaOptions.cs
@@ -1,11 +1,10 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// 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;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.FileProviders;
-using System;
namespace Microsoft.AspNetCore.SpaServices
{
@@ -15,6 +14,7 @@ namespace Microsoft.AspNetCore.SpaServices
public class SpaOptions
{
private PathString _defaultPage = "/index.html";
+ private string _packageManagerCommand = "npm";
///
/// Constructs a new instance of .
@@ -30,6 +30,7 @@ namespace Microsoft.AspNetCore.SpaServices
internal SpaOptions(SpaOptions copyFromOptions)
{
_defaultPage = copyFromOptions.DefaultPage;
+ _packageManagerCommand = copyFromOptions.PackageManagerCommand;
DefaultPageStaticFileOptions = copyFromOptions.DefaultPageStaticFileOptions;
SourcePath = copyFromOptions.SourcePath;
}
@@ -69,6 +70,26 @@ namespace Microsoft.AspNetCore.SpaServices
///
public string SourcePath { get; set; }
+ ///
+ /// Gets or sets the name of the package manager executible, (e.g npm,
+ /// yarn) to run the SPA.
+ ///
+ /// The default value is 'npm'.
+ ///
+ public string PackageManagerCommand
+ {
+ get => _packageManagerCommand;
+ set
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ throw new ArgumentException($"The value for {nameof(PackageManagerCommand)} cannot be null or empty.");
+ }
+
+ _packageManagerCommand = value;
+ }
+ }
+
///
/// Gets or sets the maximum duration that a request will wait for the SPA
/// to become ready to serve to the client.