Merge pull request #12775 from Reptarsrage/master

Adding optional PackageManagerName Option to use alternative Package Managers like Yarn
This commit is contained in:
Ryan Brandenburg 2019-08-02 09:57:06 -07:00 committed by GitHub
commit 27996712af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 84 additions and 53 deletions

View File

@ -38,6 +38,7 @@ namespace Microsoft.AspNetCore.SpaServices
public SpaOptions() { } public SpaOptions() { }
public Microsoft.AspNetCore.Http.PathString DefaultPage { get { throw null; } set { } } 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 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 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 { } } public System.TimeSpan StartupTimeout { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
} }

View File

@ -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. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNetCore.Builder; 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 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;
/// <summary> /// <summary>
/// Constructs an instance of <see cref="AngularCliBuilder"/>. /// Constructs an instance of <see cref="AngularCliBuilder"/>.
@ -34,12 +34,13 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
throw new ArgumentException("Cannot be null or empty.", nameof(npmScript)); throw new ArgumentException("Cannot be null or empty.", nameof(npmScript));
} }
_npmScriptName = npmScript; _scriptName = npmScript;
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task Build(ISpaBuilder spaBuilder) public async Task Build(ISpaBuilder spaBuilder)
{ {
var pkgManagerCommand = spaBuilder.Options.PackageManagerCommand;
var sourcePath = spaBuilder.Options.SourcePath; var sourcePath = spaBuilder.Options.SourcePath;
if (string.IsNullOrEmpty(sourcePath)) if (string.IsNullOrEmpty(sourcePath))
{ {
@ -49,32 +50,33 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
var logger = LoggerFinder.GetOrCreateLogger( var logger = LoggerFinder.GetOrCreateLogger(
spaBuilder.ApplicationBuilder, spaBuilder.ApplicationBuilder,
nameof(AngularCliBuilder)); nameof(AngularCliBuilder));
var npmScriptRunner = new NpmScriptRunner( var scriptRunner = new NodeScriptRunner(
sourcePath, sourcePath,
_npmScriptName, _scriptName,
"--watch", "--watch",
null); null,
npmScriptRunner.AttachToLogger(logger); pkgManagerCommand);
scriptRunner.AttachToLogger(logger);
using (var stdOutReader = new EventedStreamStringReader(npmScriptRunner.StdOut)) using (var stdOutReader = new EventedStreamStringReader(scriptRunner.StdOut))
using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr)) using (var stdErrReader = new EventedStreamStringReader(scriptRunner.StdErr))
{ {
try try
{ {
await npmScriptRunner.StdOut.WaitForMatch( await scriptRunner.StdOut.WaitForMatch(
new Regex("Date", RegexOptions.None, RegexMatchTimeout)); new Regex("Date", RegexOptions.None, RegexMatchTimeout));
} }
catch (EndOfStreamException ex) catch (EndOfStreamException ex)
{ {
throw new InvalidOperationException( 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" + $"Output was: {stdOutReader.ReadAsString()}\n" +
$"Error output was: {stdErrReader.ReadAsString()}", ex); $"Error output was: {stdErrReader.ReadAsString()}", ex);
} }
catch (OperationCanceledException ex) catch (OperationCanceledException ex)
{ {
throw new InvalidOperationException( 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" + $"Output was: {stdOutReader.ReadAsString()}\n" +
$"Error output was: {stdErrReader.ReadAsString()}", ex); $"Error output was: {stdErrReader.ReadAsString()}", ex);
} }

View File

@ -23,23 +23,24 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
public static void Attach( public static void Attach(
ISpaBuilder spaBuilder, ISpaBuilder spaBuilder,
string npmScriptName) string scriptName)
{ {
var pkgManagerCommand = spaBuilder.Options.PackageManagerCommand;
var sourcePath = spaBuilder.Options.SourcePath; var sourcePath = spaBuilder.Options.SourcePath;
if (string.IsNullOrEmpty(sourcePath)) if (string.IsNullOrEmpty(sourcePath))
{ {
throw new ArgumentException("Cannot be null or empty", nameof(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 // Start Angular CLI and attach to middleware pipeline
var appBuilder = spaBuilder.ApplicationBuilder; var appBuilder = spaBuilder.ApplicationBuilder;
var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName); 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: // Everything we proxy is hardcoded to target http://localhost because:
// - the requests are always from the local machine (we're not accepting remote // - 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<AngularCliServerInfo> StartAngularCliServerAsync( private static async Task<AngularCliServerInfo> StartAngularCliServerAsync(
string sourcePath, string npmScriptName, ILogger logger) string sourcePath, string scriptName, string pkgManagerCommand, ILogger logger)
{ {
var portNumber = TcpPortFinder.FindAvailablePort(); var portNumber = TcpPortFinder.FindAvailablePort();
logger.LogInformation($"Starting @angular/cli on port {portNumber}..."); logger.LogInformation($"Starting @angular/cli on port {portNumber}...");
var npmScriptRunner = new NpmScriptRunner( var scriptRunner = new NodeScriptRunner(
sourcePath, npmScriptName, $"--port {portNumber}", null); sourcePath, scriptName, $"--port {portNumber}", null, pkgManagerCommand);
npmScriptRunner.AttachToLogger(logger); scriptRunner.AttachToLogger(logger);
Match openBrowserLine; Match openBrowserLine;
using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr)) using (var stdErrReader = new EventedStreamStringReader(scriptRunner.StdErr))
{ {
try try
{ {
openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch( openBrowserLine = await scriptRunner.StdOut.WaitForMatch(
new Regex("open your browser on (http\\S+)", RegexOptions.None, RegexMatchTimeout)); new Regex("open your browser on (http\\S+)", RegexOptions.None, RegexMatchTimeout));
} }
catch (EndOfStreamException ex) catch (EndOfStreamException ex)
{ {
throw new InvalidOperationException( 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: " + $"Angular CLI was listening for requests. The error output was: " +
$"{stdErrReader.ReadAsString()}", ex); $"{stdErrReader.ReadAsString()}", ex);
} }

View File

@ -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. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;

View File

@ -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. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -16,14 +16,14 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
/// Executes the <c>script</c> entries defined in a <c>package.json</c> file, /// Executes the <c>script</c> entries defined in a <c>package.json</c> file,
/// capturing any output written to stdio. /// capturing any output written to stdio.
/// </summary> /// </summary>
internal class NpmScriptRunner internal class NodeScriptRunner
{ {
public EventedStreamReader StdOut { get; } public EventedStreamReader StdOut { get; }
public EventedStreamReader StdErr { get; } public EventedStreamReader StdErr { get; }
private static Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1)); private static Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1));
public NpmScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary<string, string> envVars) public NodeScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary<string, string> envVars, string pkgManagerCommand)
{ {
if (string.IsNullOrEmpty(workingDirectory)) if (string.IsNullOrEmpty(workingDirectory))
{ {
@ -35,18 +35,23 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
throw new ArgumentException("Cannot be null or empty.", nameof(scriptName)); 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}"; var completeArguments = $"run {scriptName} -- {arguments ?? string.Empty}";
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 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 // directly (except with UseShellExecute=true, but that's no good, because
// it prevents capturing stdio). So we need to invoke it via "cmd /c". // it prevents capturing stdio). So we need to invoke it via "cmd /c".
npmExe = "cmd"; exeToRun = "cmd";
completeArguments = $"/c npm {completeArguments}"; completeArguments = $"/c {pkgManagerCommand} {completeArguments}";
} }
var processStartInfo = new ProcessStartInfo(npmExe) var processStartInfo = new ProcessStartInfo(exeToRun)
{ {
Arguments = completeArguments, Arguments = completeArguments,
UseShellExecute = false, 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); StdOut = new EventedStreamReader(process.StandardOutput);
StdErr = new EventedStreamReader(process.StandardError); StdErr = new EventedStreamReader(process.StandardError);
} }
public void AttachToLogger(ILogger logger) 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 => StdOut.OnReceivedLine += line =>
{ {
if (!string.IsNullOrWhiteSpace(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) // those to loggers (because a logger isn't necessarily any kind of terminal)
logger.LogInformation(StripAnsiColors(line)); logger.LogInformation(StripAnsiColors(line));
} }
@ -106,7 +111,7 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
private static string StripAnsiColors(string line) private static string StripAnsiColors(string line)
=> AnsiColorRegex.Replace(line, string.Empty); => AnsiColorRegex.Replace(line, string.Empty);
private static Process LaunchNodeProcess(ProcessStartInfo startInfo) private static Process LaunchNodeProcess(ProcessStartInfo startInfo, string commandName)
{ {
try try
{ {
@ -119,8 +124,8 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
} }
catch (Exception ex) catch (Exception ex)
{ {
var message = $"Failed to start 'npm'. To resolve this:.\n\n" var message = $"Failed to start '{commandName}'. To resolve this:.\n\n"
+ "[1] Ensure that 'npm' is installed and can be found in one of the PATH directories.\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" + $" 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" + " 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."; + "[2] See the InnerException for further details of the cause.";

View File

@ -22,23 +22,24 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
public static void Attach( public static void Attach(
ISpaBuilder spaBuilder, ISpaBuilder spaBuilder,
string npmScriptName) string scriptName)
{ {
var pkgManagerCommand = spaBuilder.Options.PackageManagerCommand;
var sourcePath = spaBuilder.Options.SourcePath; var sourcePath = spaBuilder.Options.SourcePath;
if (string.IsNullOrEmpty(sourcePath)) if (string.IsNullOrEmpty(sourcePath))
{ {
throw new ArgumentException("Cannot be null or empty", nameof(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 // Start create-react-app and attach to middleware pipeline
var appBuilder = spaBuilder.ApplicationBuilder; var appBuilder = spaBuilder.ApplicationBuilder;
var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName); 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: // Everything we proxy is hardcoded to target http://localhost because:
// - the requests are always from the local machine (we're not accepting remote // - 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<int> StartCreateReactAppServerAsync( private static async Task<int> StartCreateReactAppServerAsync(
string sourcePath, string npmScriptName, ILogger logger) string sourcePath, string scriptName, string pkgManagerCommand, ILogger logger)
{ {
var portNumber = TcpPortFinder.FindAvailablePort(); var portNumber = TcpPortFinder.FindAvailablePort();
logger.LogInformation($"Starting create-react-app server on port {portNumber}..."); logger.LogInformation($"Starting create-react-app server on port {portNumber}...");
@ -71,11 +72,11 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
{ "PORT", portNumber.ToString() }, { "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 { "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( var scriptRunner = new NodeScriptRunner(
sourcePath, npmScriptName, null, envVars); sourcePath, scriptName, null, envVars, pkgManagerCommand);
npmScriptRunner.AttachToLogger(logger); scriptRunner.AttachToLogger(logger);
using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr)) using (var stdErrReader = new EventedStreamStringReader(scriptRunner.StdErr))
{ {
try 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 // 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 // no compiler warnings. So instead of waiting for that, consider it ready as soon
// as it starts listening for requests. // as it starts listening for requests.
await npmScriptRunner.StdOut.WaitForMatch( await scriptRunner.StdOut.WaitForMatch(
new Regex("Starting the development server", RegexOptions.None, RegexMatchTimeout)); new Regex("Starting the development server", RegexOptions.None, RegexMatchTimeout));
} }
catch (EndOfStreamException ex) catch (EndOfStreamException ex)
{ {
throw new InvalidOperationException( 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: " + $"create-react-app server was listening for requests. The error output was: " +
$"{stdErrReader.ReadAsString()}", ex); $"{stdErrReader.ReadAsString()}", ex);
} }

View File

@ -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. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;

View File

@ -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. // 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.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.FileProviders;
using System;
namespace Microsoft.AspNetCore.SpaServices namespace Microsoft.AspNetCore.SpaServices
{ {
@ -15,6 +14,7 @@ namespace Microsoft.AspNetCore.SpaServices
public class SpaOptions public class SpaOptions
{ {
private PathString _defaultPage = "/index.html"; private PathString _defaultPage = "/index.html";
private string _packageManagerCommand = "npm";
/// <summary> /// <summary>
/// Constructs a new instance of <see cref="SpaOptions"/>. /// Constructs a new instance of <see cref="SpaOptions"/>.
@ -30,6 +30,7 @@ namespace Microsoft.AspNetCore.SpaServices
internal SpaOptions(SpaOptions copyFromOptions) internal SpaOptions(SpaOptions copyFromOptions)
{ {
_defaultPage = copyFromOptions.DefaultPage; _defaultPage = copyFromOptions.DefaultPage;
_packageManagerCommand = copyFromOptions.PackageManagerCommand;
DefaultPageStaticFileOptions = copyFromOptions.DefaultPageStaticFileOptions; DefaultPageStaticFileOptions = copyFromOptions.DefaultPageStaticFileOptions;
SourcePath = copyFromOptions.SourcePath; SourcePath = copyFromOptions.SourcePath;
} }
@ -69,6 +70,26 @@ namespace Microsoft.AspNetCore.SpaServices
/// </summary> /// </summary>
public string SourcePath { get; set; } public string SourcePath { get; set; }
/// <summary>
/// Gets or sets the name of the package manager executible, (e.g npm,
/// yarn) to run the SPA.
///
/// The default value is 'npm'.
/// </summary>
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;
}
}
/// <summary> /// <summary>
/// Gets or sets the maximum duration that a request will wait for the SPA /// Gets or sets the maximum duration that a request will wait for the SPA
/// to become ready to serve to the client. /// to become ready to serve to the client.