Running npm tasks when building a .NET project

 
 
  • Gérald Barré

If you are building a web application using ASP.NET Core (MVC, Pages, Blazor), you may rely on npm to minify and bundle your css and js files. This means that to build your application, you need to run an npm script such as npm run build before executing dotnet build. That's not convenient! What you want is only to build your application using dotnet build or from Visual Studio.

.NET relies on MSBuild to orchestrate the build tasks. So, you can customize the build by adding new targets and executing them while building the .NET application. First, you need to add a target to run npm install or npm ci. You can use incremental builds to run this task only once or when the package.json file changed. After installing npm packages you can run the npm scripts. Let's see what it looks like:

csproj (MSBuild project file)
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

  <!--
      1. Install npm packages
      "Inputs" and "Outputs" are used for incremental builds. If all output items are up-to-date, MSBuild skips the target.
      The first time the task is executed. Then, it only runs when you change the package.json file.
      Documentation: https://learn.microsoft.com/en-us/visualstudio/msbuild/incremental-builds?WT.mc_id=DT-MVP-5003978
   -->
  <Target Name="NpmInstall" Inputs="package.json" Outputs="node_modules/.install-stamp">
    <!--
        Use npm install or npm ci depending on RestorePackagesWithLockFile value.
        Uncomment the following lines if you want to use this feature:

        <PropertyGroup>
          <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
        </PropertyGroup>
     -->
    <Exec Command="npm ci"      Condition="'$(RestorePackagesWithLockFile)' == 'true'" />
    <Exec Command="npm install" Condition="'$(RestorePackagesWithLockFile)' != 'true'" />

    <!-- Write the stamp file, so incremental builds work -->
    <Touch Files="node_modules/.install-stamp" AlwaysCreate="true" />
  </Target>

  <!--
      2. Run npm run build before building the .NET project.
      MSBuild runs NpmInstall before this task because of the DependsOnTargets attribute.
   -->
  <Target Name="NpmRunBuild" DependsOnTargets="NpmInstall" BeforeTargets="BeforeBuild">
    <Exec Command="npm run build" />
  </Target>
</Project>

When you build the project, you can see the npm install and npm run build log in the output window

The previous code was inspired from this pull request: https://github.com/terrajobst/themesof.net/pull/9/files. Now, we can improve it to support multiple package.json files!

#Advanced scenario: Multiple package.json files

If you have multiple package.json files, you can tweak the previous MSBuild file to run scripts from all of them:

csproj (MSBuild project file)
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <!-- List of package.json files -->
    <NpmPackageFile Include="package.json" />
    <NpmPackageFile Include="module1/package.json" />
    <NpmPackageFile Include="module2/package.json" />
  </ItemGroup>

  <!-- Compute additional metadata for the NpmPackageFile items -->
  <Target Name="ComputeNpmPackageMetadata">
    <ItemGroup>
      <NpmPackageFile>
        <StampFile>$([System.IO.Path]::Combine(`%(RootDir)%(Directory)`, 'node_modules', '.install-stamp'))</StampFile>
        <WorkingDirectory>%(RootDir)%(Directory)</WorkingDirectory>
        <Command Condition="'$(RestorePackagesWithLockFile)' != 'true'">npm install</Command>
        <Command Condition="'$(RestorePackagesWithLockFile)' == 'true'">npm ci</Command>
      </NpmPackageFile>
    </ItemGroup>
  </Target>

  <!-- Run npm install for each NpmPackageFile -->
  <Target Name="NpmInstall" DependsOnTargets="ComputeNpmPackageMetadata" Inputs="@(NpmPackageFile)" Outputs="%(NpmPackageFile.StampFile)">
    <Exec Command="@(NpmPackageFile->'%(Command)')" WorkingDirectory="%(WorkingDirectory)" />
    <Touch Files="@(NpmPackageFile->'%(StampFile)')" AlwaysCreate="true" />
  </Target>

  <!-- Run npm commands (be sure to set the right WorkingDirectory) -->
  <Target Name="NpmRunBuild" DependsOnTargets="NpmInstall" BeforeTargets="BeforeBuild">
    <Exec Command="npm run build" WorkingDirectory="module1" />
  </Target>
</Project>

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee💖 Sponsor on GitHub