{"id":37183,"date":"2021-11-01T08:03:40","date_gmt":"2021-11-01T15:03:40","guid":{"rendered":"https:\/\/devblogs.microsoft.com\/dotnet\/?p=37183"},"modified":"2023-03-01T14:56:53","modified_gmt":"2023-03-01T22:56:53","slug":"build-client-web-assets-for-your-razor-class-library","status":"publish","type":"post","link":"https:\/\/devblogs.microsoft.com\/dotnet\/build-client-web-assets-for-your-razor-class-library\/","title":{"rendered":"Build client web assets for your Razor Class Library"},"content":{"rendered":"<p>Razor Class Libraries are .NET class libraries setup with the Razor SDK to enable building Razor files (.razor, cshtml) and to include client web assets (.js, .css, images, etc.). Client web assets often need to be built or processed using tools like npm and webpack. But integrating a client web asset build pipeline into the build process of a Razor Class Library can be challenging. It&#8217;s error prone and sometimes results in fragile solutions, like relying on the ordering of tasks in the build process that can change between releases.<\/p>\n<p>In this post we&#8217;ll show you how to integrate npm and webpack into the build process for your Razor Class Library. For folks who want a solution that just works, we&#8217;ll look at the new experimental <a href=\"https:\/\/www.nuget.org\/packages\/Microsoft.AspNetCore.ClientAssets\/\">Microsoft.AspNetCore.ClientAssets<\/a> package that handles this integration for you automatically provided you have the right tools installed. We&#8217;ll also walk through all the details of how to set this up using MSBuild for folks that want to be able to control and customize the process. While the focus of this post is npm and webpack, the solution that we provide here can easily be adapted to other tools like yarn, rollup, etc.<\/p>\n<h2>Adding a client web assets build process<\/h2>\n<p>There are two things that we want to happen as part of the build process to integrate handling of client web assets:<\/p>\n<ol>\n<li>Restore client web assets managed by other package managers (npm, yarn).<\/li>\n<li>Build\/generate client web assets and include the results as part of the build output as static web assets.<\/li>\n<\/ol>\n<p>In our examples, we&#8217;ll put all the client web asset sources into an <em>assets<\/em> folder within the root of the project.<\/p>\n<p>For the output of the build process we have a couple of options:<\/p>\n<ul>\n<li>Put the outputs in the <em>dist<\/em> subfolder. This is common in many tools and makes it easy to glance at the output. However, if you&#8217;re using Git (or some other version control system), you may be forced to add an entry into the <em>.gitignore<\/em> file to avoid accidentally adding the outputs to source control, and it makes cleaning the workspace harder.<\/li>\n<li>Put the outputs in the <em>obj<\/em> folder. This is more inline with how builds work in .NET and doesn&#8217;t require any additional setup. We&#8217;ll use this approach.<\/li>\n<\/ul>\n<p>Ideally, we want these steps to be incremental, meaning that they only run when any of the inputs have changed and don&#8217;t run when everything is up to date. For example, we only want to run <code>npm install<\/code> when the packages are not available locally or when the <em>package.json<\/em> file has been updated.<\/p>\n<h2>Using the MicrosoftAspNetCore.ClientAssets package<\/h2>\n<p>All of this build logic can be included in a NuGet package and reused across multiple projects. In fact, that&#8217;s what we&#8217;ve done in the experimental <a href=\"https:\/\/nuget.org\/packages\/Microsoft.AspNetCore.ClientAssets\">Microsoft.AspNetCore.ClientAssets<\/a> package, which will automatically add support for integrating npm, webpack, or other tools to your build pipeline.<\/p>\n<p>To use the MicrosoftAspNetCore.ClientAssets package:<\/p>\n<ul>\n<li>Add a package reference to Microsoft.AspNetCore.ClientAssets from your project.<\/li>\n<li>Add an <em>assets<\/em> folder with a <em>package.json<\/em> file.<\/li>\n<li>In <em>assets\/package.json<\/em> specify your dependencies and defines the <code>build:Debug<\/code> and <code>build:Release<\/code> scripts you want to run for Debug and Release builds.<\/li>\n<\/ul>\n<p>That&#8217;s it! You&#8217;re ready to restore and build your client web assets. You can find a sample that shows using this package to build TypeScript files in a Razor Class Library <a href=\"https:\/\/github.com\/aspnet\/AspLabs\/tree\/main\/src\/ClientAssets\/samples\">here<\/a>.<\/p>\n<p>We think this package will fit the needs of most users, but give it a try and let us know what you think by submitting any issues you find on <a href=\"https:\/\/github.com\/dotnet\/aspnetcore\/issues\/new\">GitHub<\/a>. If for some reason this doesn&#8217;t work for a tool you&#8217;re using, you can add the MSBuild logic directly to your project and update and adapt it to fit your needs. We&#8217;ll cover that next.<\/p>\n<h2>Add a client web assets build process using MSBuild<\/h2>\n<p>The MicrosoftAspNetCore.ClientAssets package provides custom MSBuild targets to add a client web assets build process to your project. In this section, we&#8217;ll look at how these targets work, so that you can customize them.<\/p>\n<h3>Integrating <code>npm install<\/code> with Razor Class Libraries<\/h3>\n<p>To restore client web assets from npm, we&#8217;ll create a task to invoke <code>npm install<\/code>. MSBuild tasks allow you to specify <code>Inputs<\/code> and <code>Outputs<\/code>, which are used to determine if the target needs to run by examining whether an input is newer than one of the outputs. Since we&#8217;re putting all our client sources in the <em>assets<\/em> folder, we can use the <em>assets\/package.json<\/em> file as an input to determine if we need to run the <code>npm install<\/code> command or not. When <code>npm install<\/code> is run, a <em>node_modules\/.package-lock.json<\/em> file is created, which we can use for the output. If <em>package.json<\/em> is changed, it will be newer than <em>node_modules\/.package-lock.json<\/em>, which will trigger a new install the next time the build is triggered.<\/p>\n<p>The complete target is displayed below:<\/p>\n<pre><code class=\"language-xml\">&lt;Target Name=\"NpmInstall\" Inputs=\"assetspackage.json\" Outputs=\"assetsnode_modules.package-lock.json\"&gt;\r\n  &lt;Message Importance=\"high\" Text=\"Running npm install...\" \/&gt;\r\n  &lt;Exec Command=\"npm install\" WorkingDirectory=\"assets\" \/&gt;\r\n&lt;\/Target&gt;<\/code><\/pre>\n<h3>Running an npm command and collecting the outputs as static web assets<\/h3>\n<p>Next we&#8217;ll create a target that runs an npm command and collects the outputs as static web assets. In our example, we&#8217;ll create an <code>NpmRunBuild<\/code> target that runs the <code>build:$(Configuration)<\/code> script in <em>package.json<\/em> with every build (where <code>$(Configuration)<\/code> is Release or Debug).<\/p>\n<p>The <code>NpmRunBuild<\/code> target will do the following:<\/p>\n<ul>\n<li>Run the given npm command.<\/li>\n<li>Collect the outputs from the command.<\/li>\n<li>Add the outputs as <code>Content<\/code> items, and link them to where they would be in the <em>wwwroot<\/em> folder.<\/li>\n<li>Write a file to disk with the list of generated files to use as an output for incrementalism.<\/li>\n<\/ul>\n<p>We&#8217;ll start by defining the <code>NpmRunBuild<\/code> target so that it runs after <code>NpmInstall<\/code> but before <code>AssignTargetPaths<\/code>. The target executes the specified npm command, and the command output is directed to <em>$(IntermediateOutputPath)\\npm<\/em>, which normally points to <em>obj\\Debug|Release\\net6.0<\/em>.<\/p>\n<pre><code class=\"language-xml\">&lt;Target Name=\"NpmRunBuild\" DependsOnTargets=\"NpmInstall\" BeforeTargets=\"AssignTargetPaths\"&gt;\r\n  &lt;Exec Command=\"npm run build:$(Configuration) -- -o $(IntermediateOutputPath)npm\" WorkingDirectory=\"assets\" \/&gt;\r\n&lt;\/Target&gt;<\/code><\/pre>\n<p>Next we&#8217;ll add the build outputs as <code>Content<\/code> items and link them to the <em>wwwroot<\/em> folder. We can do so by declaring three item groups after invoking the <code>Exec<\/code> task as follows:<\/p>\n<pre><code class=\"language-xml\">&lt;ItemGroup&gt;\r\n  &lt;_NpmOutput Include=\"$(IntermediateOutputPath)npm**\" \/&gt;\r\n  &lt;Content Include=\"@(_NpmOutput)\" Link=\"wwwroot%(_NpmOutput.RecursiveDir)%(_NpmOutput.FileName)%(_NpmOutput.Extension)\" \/&gt;\r\n  &lt;FileWrites Include=\"@(_NpmOutput)\" \/&gt;\r\n&lt;\/ItemGroup&gt;<\/code><\/pre>\n<p>The <code>_NpmOutput<\/code> item group captures all the files in the output content. We then include these items as <code>Content<\/code> and link them to the <em>wwwroot<\/em> folder. Finally, we add these items to the list of <code>FileWrites<\/code> so they can be cleaned up property.<\/p>\n<p>The last step is to make this target run only when the sources are changed. To do so, we need to declare <code>Inputs<\/code> and <code>Outputs<\/code> on the target in the same way we did for the <code>NpmInstall<\/code> target. MSBuild will check the outputs, and execute the target in two situations:<\/p>\n<ul>\n<li>The outputs don&#8217;t exist.<\/li>\n<li>Any outputs are older than any of the inputs.<\/li>\n<\/ul>\n<p>Given that we don&#8217;t know what the outputs of running the npm command are going to be, we&#8217;ll generate a file with the list of output files. The output file listing will always have a well-known file name (<em>npmbuild.complete.txt<\/em>), so we&#8217;ll be able to use it as the output for our target. We can produce the output file listing like this:<\/p>\n<pre><code class=\"language-xml\">&lt;WriteLinesToFile File=\"$(IntermediateOutputPath)npmbuild.complete.txt\" Lines=\"@(_NpmOutput)\" \/&gt;<\/code><\/pre>\n<p>Since we&#8217;re generating a file, we also need to add it to the list of <code>FileWrites<\/code> as we did with the other outputs:<\/p>\n<pre><code class=\"language-xml\">&lt;FileWrites Include=\"$(IntermediateOutputPath)npmbuild.complete.txt\" \/&gt;<\/code><\/pre>\n<p>For the inputs, we can define an item group to capture them as follows:<\/p>\n<pre><code class=\"language-xml\">&lt;ItemGroup&gt;\r\n  &lt;NpmBuildInputs Include=\"assets**\" Exclude=\"assetsnode_modules**\" \/&gt;\r\n&lt;\/ItemGroup&gt;<\/code><\/pre>\n<p>With all that in place, we can define the inputs and outputs on our <code>NpmRunBuild<\/code> target as follows:<\/p>\n<pre><code class=\"language-xml\">&lt;ItemGroup&gt;\r\n  &lt;NpmBuildInputs Include=\"assets**\" Exclude=\"assetsnode_modules**\" \/&gt;\r\n&lt;\/ItemGroup&gt;\r\n\r\n&lt;Target Name=\"NpmRunBuild\" DependsOnTargets=\"NpmInstall\" BeforeTargets=\"AssignTargetPaths\" Inputs=\"@(NpmBuildInputs)\" Outputs=\"$(IntermediateOutputPath)npmbuild.complete.txt\"&gt;\r\n  &lt;Exec Command=\"$(NpmCommand) -- -o $(IntermediateOutputPath)npm\" WorkingDirectory=\"assets\" \/&gt;\r\n\r\n  &lt;ItemGroup&gt;\r\n    &lt;_NpmOutput Include=\"$(IntermediateOutputPath)npm**\"&gt;&lt;\/_NpmOutput&gt;\r\n    &lt;Content Include=\"@(_NpmOutput)\" Link=\"wwwroot%(_NpmOutput.RecursiveDir)%(_NpmOutput.FileName)%(_NpmOutput.Extension)\" \/&gt;\r\n    &lt;FileWrites Include=\"@(_NpmOutput)\" \/&gt;\r\n    &lt;FileWrites Include=\"$(IntermediateOutputPath)npmbuild.complete.txt\" \/&gt;\r\n  &lt;\/ItemGroup&gt;\r\n\r\n  &lt;WriteLinesToFile File=\"$(IntermediateOutputPath)npmbuild.complete.txt\" Lines=\"@(_NpmOutput)\" \/&gt;\r\n&lt;\/Target&gt;<\/code><\/pre>\n<p>This target will only run when you change something in your sources, like your TypeScript files or <em>package.json<\/em>.<\/p>\n<h3>Adding scripts in <em>package.json<\/em><\/h3>\n<p>With these targets the build pipeline will invoke <code>npm install<\/code> and <code>npm run build:Debug<\/code> or <code>npm run build:Release<\/code> when necessary, passing the output folder as the <code>-o<\/code> parameter. We need to define these scripts in our <em>package.json<\/em> to invoke the appropriate tool. To use webpack, the scripts look like this:<\/p>\n<pre><code class=\"language-json\">\"scripts\": {\r\n  \"build:Debug\": \"webpack --mode development\",\r\n  \"build:Release\": \"webpack --mode production\"\r\n}<\/code><\/pre>\n<p>The parameters passed from the <code>Exec<\/code> task to <code>npm run<\/code> will transparently flow into the invoked command and override the default output path.<\/p>\n<h3>Reusing the MSBuild code<\/h3>\n<p>We&#8217;ve seen in detail how to put together all the pieces. However, we&#8217;ve been very strict in what command to run, where the files should be located, and so on. Next we&#8217;ll generalize the solution by using MSBuild to parameterize the process. Here&#8217;s how:<\/p>\n<pre><code class=\"language-xml\">&lt;PropertyGroup&gt;\r\n  &lt;ClientAssetsDirectory Condition=\"'$(ClientAssetsDirectory)' == ''\"&gt;assets&lt;\/ClientAssetsDirectory&gt;\r\n  &lt;ClientAssetsRestoreInputs Condition=\"'$(ClientAssetsRestoreInputs)' == ''\"&gt;$(ClientAssetsDirectory)package-lock.json;$(ClientAssetsDirectory)package.json&lt;ClientAssetsRestoreInputs&gt;\r\n  &lt;ClientAssetsRestoreOutputs Condition=\"'$(ClientAssetsRestoreOutputs)' == ''\"&gt;$(ClientAssetsDirectory)node_modules.package-lock.json&lt;ClientAssetsRestoreOutputs&gt;\r\n  &lt;ClientAssetsRestoreCommand Condition=\"'$(ClientAssetsRestoreCommand)' == ''\"&gt;npm install&lt;ClientAssetsRestoreCommand&gt;\r\n  &lt;ClientAssetsBuildCommand Condition=\"'$(ClientAssetsBuildCommand)' == ''\"&gt;npm run build:$(Configuration)&lt;ClientAssetsBuildCommand&gt;\r\n  &lt;ClientAssetsBuildOutputParameter Condition=\"'$(ClientAssetsBuildOutputParameter)' == ''\"&gt;-o&lt;ClientAssetsBuildOutputParameter&gt;\r\n  &lt;ClientAssetsRestoreInputs&gt;$(MSBuildProjectFile);$(ClientAssetsRestoreInputs)&lt;\/ClientAssetsRestoreInputs&gt;\r\n&lt;\/PropertyGroup&gt;\r\n\r\n&lt;ItemGroup&gt;\r\n  &lt;ClientAssetsInputs Include=\"$(ClientAssetsDirectory)**\" Exclude=\"$(DefaultItemExcludes)\" \/&gt;\r\n&lt;\/ItemGroup&gt;\r\n\r\n&lt;Target Name=\"NpmInstall\" Inputs=\"$(ClientAssetsRestoreInputs)\" Outputs=\"$(ClientAssetsRestoreOutputs)\"&gt;\r\n  &lt;Message Importance=\"high\" Text=\"Running $(ClientAssetsRestoreCommand)...\" \/&gt;\r\n  &lt;Exec Command=\"$(ClientAssetsRestoreCommand)\" WorkingDirectory=\"$(ClientAssetsDirectory)\" \/&gt;\r\n&lt;\/Target&gt;\r\n\r\n&lt;Target Name=\"NpmRunBuild\" DependsOnTargets=\"NpmInstall\" BeforeTargets=\"AssignTargetPaths\" Inputs=\"@(ClientAssetsInputs)\" Outputs=\"$(IntermediateOutputPath)clientassetsbuild.complete.txt\"&gt;\r\n  &lt;Exec Command=\"$(ClientAssetsBuildCommand) -- $(ClientAssetsBuildOutputParameter) $(IntermediateOutputPath)clientassets\" WorkingDirectory=\"$(ClientAssetsDirectory)\" \/&gt;\r\n\r\n  &lt;ItemGroup&gt;\r\n    &lt;_ClientAssetsBuildOutput Include=\"$(IntermediateOutputPath)clientassets**\"&gt;&lt;\/_ClientAssetsBuildOutput&gt;\r\n    &lt;Content Include=\"@(_ClientAssetsBuildOutput)\" Link=\"wwwroot%(_ClientAssetsBuildOutput.RecursiveDir)%(_ClientAssetsBuildOutput.FileName)%(_ClientAssetsBuildOutput.Extension)\" \/&gt;\r\n    &lt;FileWrites Include=\"@(_ClientAssetsBuildOutput)\" \/&gt;\r\n    &lt;FileWrites Include=\"$(IntermediateOutputPath)clientassetsbuild.complete.txt\" \/&gt;\r\n  &lt;\/ItemGroup&gt;\r\n\r\n  &lt;WriteLinesToFile File=\"$(IntermediateOutputPath)clientassetsbuild.complete.txt\" Lines=\"@(_ClientAssetsBuildOutput)\" \/&gt;\r\n&lt;\/Target&gt;<\/code><\/pre>\n<p>With the changes we made above, we can adapt the build process by changing the values for the different properties in our project file. For example, we can specify that our client web assets are in the <em>js<\/em> folder instead of the <em>assets<\/em>, or change the list of default exclusions:<\/p>\n<pre><code class=\"language-xml\">&lt;PropertyGroup&gt;\r\n  &lt;ClientAssetsDirectory&gt;js&lt;\/ClientAssetsDirectory&gt;\r\n  &lt;DefaultItemExcludes&gt;$(ClientAssetsDirectory)dist**&lt;\/DefaultItemExcludes&gt;\r\n&lt;\/PropertyGroup&gt;<\/code><\/pre>\n<h2>Summary<\/h2>\n<p>Adding a client web assets build process to your .NET projects is now easy using the new experimental <a href=\"https:\/\/www.nuget.org\/packages\/Microsoft.AspNetCore.ClientAssets\/\">Microsoft.AspNetCore.ClientAssets<\/a> package. Or you can create a custom build process using similar MSBuild logic to fit your needs. We hope you&#8217;ll give this functionality a try and let us know what you think on <a href=\"https:\/\/github.com\/dotnet\/aspnetcore\/issues\/new\">GitHub<\/a>.<\/p>\n<p>Happy coding!<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Learn how to integrate a build process for client web assets using tools like npm and webpack into the builds of your Razor Class Libraries.<\/p>\n","protected":false},"author":1866,"featured_media":37184,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"footnotes":""},"categories":[685,197,7509],"tags":[],"class_list":["post-37183","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-dotnet","category-aspnet","category-aspnetcore"],"acf":[],"blog_post_summary":"<p>Learn how to integrate a build process for client web assets using tools like npm and webpack into the builds of your Razor Class Libraries.<\/p>\n","_links":{"self":[{"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/posts\/37183","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/users\/1866"}],"replies":[{"embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/comments?post=37183"}],"version-history":[{"count":0,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/posts\/37183\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/media\/37184"}],"wp:attachment":[{"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/media?parent=37183"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/categories?post=37183"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/tags?post=37183"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}