Running npm tasks when building a .NET project

 
 
  • Gérald Barré

If you are building a web application with 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! Ideally, you want a single command, dotnet build or a Visual Studio build, to handle everything.

.NET uses MSBuild to orchestrate build tasks, so you can customize the build by adding new targets that run alongside the .NET build. First, add a target to run npm install or npm ci. You can use incremental builds so this target only runs on the first build or when the package.json file changes. After installing npm packages, you can run the npm scripts. Here is 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 output in the build log.

The previous code was inspired by this pull request: https://github.com/terrajobst/themesof.net/pull/9/files. You can also extend it to support multiple package.json files!

#Advanced scenario: Multiple package.json files

If you have multiple package.json files, you can extend the previous MSBuild configuration to run scripts for 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?