From 751882cf3c45d0eb982f244d59bfc758b4b6c17b Mon Sep 17 00:00:00 2001 From: Justin Robb Date: Wed, 31 Jul 2019 12:56:08 -0700 Subject: [PATCH] Add optional packageManagerName to allow alternative package managers --- .../src/AngularCli/AngularCliBuilder.cs | 9 +++-- .../src/AngularCli/AngularCliMiddleware.cs | 9 ++--- ...NpmScriptRunner.cs => NodeScriptRunner.cs} | 33 +++++++++++-------- .../ReactDevelopmentServerMiddleware.cs | 9 ++--- ...ctDevelopmentServerMiddlewareExtensions.cs | 2 +- .../SpaServices.Extensions/src/SpaOptions.cs | 28 ++++++++++++++-- 6 files changed, 61 insertions(+), 29 deletions(-) rename src/Middleware/SpaServices.Extensions/src/Npm/{NpmScriptRunner.cs => NodeScriptRunner.cs} (77%) diff --git a/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliBuilder.cs b/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliBuilder.cs index 61dedd350a..0a6a320193 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; @@ -40,20 +40,23 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli /// public async Task Build(ISpaBuilder spaBuilder) { + var pkgManagerName = spaBuilder.Options.PackageManagerName; var sourcePath = spaBuilder.Options.SourcePath; if (string.IsNullOrEmpty(sourcePath)) { 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 = LoggerFinder.GetOrCreateLogger( spaBuilder.ApplicationBuilder, nameof(AngularCliBuilder)); - var npmScriptRunner = new NpmScriptRunner( + var npmScriptRunner = new NodeScriptRunner( sourcePath, _npmScriptName, "--watch", - null); + null, + pkgManagerName); npmScriptRunner.AttachToLogger(logger); using (var stdOutReader = new EventedStreamStringReader(npmScriptRunner.StdOut)) diff --git a/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddleware.cs b/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddleware.cs index 9090f7738b..1f1ba8cdc1 100644 --- a/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddleware.cs +++ b/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddleware.cs @@ -25,6 +25,7 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli ISpaBuilder spaBuilder, string npmScriptName) { + var pkgManagerName = spaBuilder.Options.PackageManagerName; var sourcePath = spaBuilder.Options.SourcePath; if (string.IsNullOrEmpty(sourcePath)) { @@ -39,7 +40,7 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli // 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, npmScriptName, pkgManagerName, 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,13 +63,13 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli } private static async Task StartAngularCliServerAsync( - string sourcePath, string npmScriptName, ILogger logger) + string sourcePath, string npmScriptName, string pkgManagerName, ILogger logger) { var portNumber = TcpPortFinder.FindAvailablePort(); logger.LogInformation($"Starting @angular/cli on port {portNumber}..."); - var npmScriptRunner = new NpmScriptRunner( - sourcePath, npmScriptName, $"--port {portNumber}", null); + var npmScriptRunner = new NodeScriptRunner( + sourcePath, npmScriptName, $"--port {portNumber}", null, pkgManagerName); npmScriptRunner.AttachToLogger(logger); Match openBrowserLine; 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..86e6497f19 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 pkgManagerName) { 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(pkgManagerName)) + { + throw new ArgumentException("Cannot be null or empty.", nameof(pkgManagerName)); + } + + var exeToRun = pkgManagerName; 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 {pkgManagerName} {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, pkgManagerName); 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..4dfe28457d 100644 --- a/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs +++ b/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs @@ -24,6 +24,7 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer ISpaBuilder spaBuilder, string npmScriptName) { + var pkgManagerName = spaBuilder.Options.PackageManagerName; var sourcePath = spaBuilder.Options.SourcePath; if (string.IsNullOrEmpty(sourcePath)) { @@ -38,7 +39,7 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer // 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, npmScriptName, pkgManagerName, 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 npmScriptName, string pkgManagerName, ILogger logger) { var portNumber = TcpPortFinder.FindAvailablePort(); logger.LogInformation($"Starting create-react-app server on port {portNumber}..."); @@ -71,8 +72,8 @@ 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); + var npmScriptRunner = new NodeScriptRunner( + sourcePath, npmScriptName, null, envVars, pkgManagerName); npmScriptRunner.AttachToLogger(logger); using (var stdErrReader = new EventedStreamStringReader(npmScriptRunner.StdErr)) 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..5b3d06a94d 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 _defaultPackageManagerName = "npm"; /// /// Constructs a new instance of . @@ -30,6 +30,7 @@ namespace Microsoft.AspNetCore.SpaServices internal SpaOptions(SpaOptions copyFromOptions) { _defaultPage = copyFromOptions.DefaultPage; + _defaultPackageManagerName = copyFromOptions.PackageManagerName; DefaultPageStaticFileOptions = copyFromOptions.DefaultPageStaticFileOptions; SourcePath = copyFromOptions.SourcePath; } @@ -69,6 +70,27 @@ 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. + /// + /// If not set, npm will be assumed as the default package manager + /// executable + /// + public string PackageManagerName + { + get => _defaultPackageManagerName; + set + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException($"The value for {nameof(PackageManagerName)} cannot be null or empty."); + } + + _defaultPackageManagerName = value; + } + } + /// /// Gets or sets the maximum duration that a request will wait for the SPA /// to become ready to serve to the client.