Publishing ASP.NET Core web application with frontend monorepo static assets

ASP.NET-Core-Logo_2colors_RGB_bitmap_MEDIUM.png

When you create new project using dotnet new react template, you'll get ASP.NET Core application with ClientApp folder for SPA frontend. This may be convenient if you full-stack developer, you have exactly one SPA and you write frontend from ground-up yourself.

Using dotnet new react has many downsides and you'll tend to extract ClientApp to dedicated folder:

  • If team has dedicated frontend developers, they may find more convenient to develop SPA in dedicated folder or dedicated git repository.
  • dotnet new react template's dependencies are outdated, and frontend developers forced to execute yar create react-app manually then diff/copy output to ClientApp folder.
  • dotnet new react template is javascript template, there is no easy way to use typescript. You need to follow guide manually every time. This can be automated with yarn create react-app --template typescript, then diff/copy output to ClientApp folder.
  • dotnet new react - is not the tool for frontend developers after all :)
  • It's scales poorly. Imagine you have more than one SPA with common shared components. You'll end up with monorepo in dedicated folder using yarn workspaces and lerna.

In this post I'll show how to publish ASP.NET Core web application along with SPA from dedicated monorepo folder.

Table of contents

This post has accompanying source code on github.

First of all, let's review folders structure:

+--frontend
|  +--packages
|  |  +-- browserlist-config
|  |  |  +-- index.js
|  |  |  \-- package.json
|  |  |
|  |  +--components
|  |  |  +--src...
|  |  |  |--packages.json
|  |  |  \--tsconfig.json
|  |  |
|  |  + eslint-config-common
|  |  |  +-- index.js
|  |  |  \-- package.json
|  |  |
|  |  +--web1
|  |  |  +--src...
|  |  |  |--packages.json
|  |  |  \--tsconfig.json
|  |  |
|  |  \--web2
|  |     +--src...
|  |     |--packages.json
|  |     \--tsconfig.json
|  |
|  |--lerna.json
|  |--packages.json
|  \--yarn.lock
|
+--backend
|  +--web1
|  |  +--Controllers...
|  |  |--Program.cs
|  |  \--web1.csproj
|  |
|  +--web2
|  |  +--Controllers...
|  |  |--Program.cs
|  |  \--web2.csproj
|  |
|  \--backend.sln
|
|--.editorconfig
|--.gitignore
|--changelog.md
|--LICENSE
\--readme.md

We have dedicated frontend folder which is monorepo. It uses yarn workspaces and lerna to build projects. Dedicated backend folder will contains solution file and other .NET projects (I use empty dotnet new web template for start).

Frontend folder details

Using yarn workspaces

Yarn workspaces and lerna works together and are great tools for monorepo development.

I've selected dedicated namespace for my frontend projects. I'll use @my namespace for this sample. This namespace will precede any project name in sample package.json files. You could select another namespace if you want.

You could add scripts to build all projects in root package.json file:

"scripts": {
    "bootstrap": "yarn install && lerna bootstrap && lerna run prepare",
    "build": "lerna run build",
    "eslint": "lerna run eslint",
    "eslint:fix": "lerna run eslint:fix"
}

Note that web1 and web2 both have @my/components dependency and they are importing SomeView component from dist output folder like:

import { SomeView } from '@my/components/dist'

It is necessary to build components package before web1 and web2. lerna run enforces the required order.

Proxy in development mode

In development, using webpack-dev-server with hot-reload is very convenient - you'll view your changes on-the-fly when edit source files. But webpack-dev-server served frontend static files on self port which is different from ASP.NET Core web host port. We need some kind of reverse proxy server like nginx in front of our application. But, to be honest, in development environment using reverse proxy is overkill.

create-react-app supports limited proxy support out-of-the-box. The limitation is not all requests are passed to backend - development server will only attempt to send requests without text/html in its Accept header to the proxy. Fortunately, create-react-app also supports setupProxy extension point where you can configure proxy manually:

const proxy = require('http-proxy-middleware')
// create-react-app by default doesn't pass requests with `Accept: text/html` headers to API
// therefore we use custom proxy here
module.exports = function (app) {
    app.use(
        '/api',
        proxy({
            target: 'http://localhost:47001',
            secure: false,
            changeOrigin: false,
            onProxyReq: proxyReq => {
                const host = proxyReq.getHeader('host')
                if (host) {
                    proxyReq.setHeader('host', host)
                }
            }
        })
    )
}

This code will configure http-proxy-middleware to pass all /api*requests to our backend. Here we can configure proxying to multiple backends if necessary.

Using http-proxy-middleware effectively avoids CORS issues, must have in-development feature.

Create components in isolated environment

In this sample I'll use storybook to be able view shared components palette. components folder is not create-react-app-based, and need to be compiled before web1 and web2 can use the components. Therefore storybook is essential tool in development for components folder.

Storybook is also essential using with create-react-app-based SPAs. It helps to separate state from view right in development.

There is one subtle thing with storybook with create-react-app when you use "experimentalDecorators"=true typescript option. Storybook doesn't supports it out-of-the-box. You'll need to add .storybook/.babelrc file with following content:

{
    "plugins": [
        [
            "@babel/plugin-proposal-decorators",
            {
                "legacy": true
            },
            "override-decorators"
        ],
        [
            "@babel/plugin-proposal-class-properties",
            {
                "loose": true
            },
            "override-class-properties"
        ]
    ]
}

Single ESLint validation rules source

In monorepo we usually need to configure eslint validation rules for each build target: such as components, web1, web2 in our folder tree. It's a pain to configure eslint rules for each build target manually because when you change rules in one build target, you'll need to copy same changes to the rest build targets. It's tends to be forgotten and not consistent with time.

Fortunately, eslint supports shareable configurations defined as npm packages. In the sample I've defined @my/eslint-config-common package with single index.js. Here I use standard JavaScript rules and override some of them:

module.exports = {
    'extends': [
        'standard'
    ],
    'rules': {
        'indent': ['error', 4],
        'space-before-function-paren': ['error', {"anonymous": "always", "named": "never", "asyncArrow": "always"}],
        'object-curly-spacing': ['error', 'always'],
        'operator-linebreak': ['error', 'before']
    }
}

Each SPA then references to this config in .eslintrc.js. Unfortunately, we need to provide tsconfig.json location in parserOptions, file will not be located automatically:

module.exports = {
    'parserOptions': {
        'project': __dirname + '/tsconfig.json'
    },
    'extends': [
        '@my/eslint-config-common',
        'react-app'
    ]
}

Single browserlist configuration source

The same reasoning holds for browserlist. For all applications I've single source of true - @my/browserlist-config package with single index.js like here:

module.exports = [
    "> 0.2%",
    "ie 11",
    "firefox >= 55",
    "chrome >= 60",
    "safari >= 10",
    "last 2 years",
    "not dead",
    "not op_mini all",
    "not ie < 11"
];

Each SPA then references to browserlist-config in package.json like here:

{
    "devDependencies": {
        ...
        "@my/browserslist-config": "1.0.0",
        ...
    },
    "browserslist": {
        "production": [
            "extends @my/browserslist-config"
        ],
        "development": [
            "ie 11",
            "last 2 years"
        ]
    }
}

Importing styles in components package

Note that @my/components package is pure typescript package. If you want to use css modules, you need to provide some type definitions for your styles. if not, you'll get error TS2307: Cannot find module './SomeView.module.scss'. Put styles.d.ts to the components package source code:

declare module '*.scss' {
    const styles: { [className: string]: string };
    export default styles;
}

After that, typescript will build package without errors, but will not copy any *.scss files to the dist output folder. Typescript team has opinion it is not typescript task to copy files (even they were imported explicitly). Here is an issue.

But without *.scss files in dist folder you'll get error Cannot find file './SomeView.module.scss' when you'll build web1 or web2. To cope with that behavior you can use copyfiles package. Install it into components package with:

yarn add copyfiles --dev

and then add postbuild action to package.json scripts section:

"postbuild": "copyfiles -u 1 \"src/**/*.module.scss\" \"dist\"",

Now you'll copy *.scss files to dist folder on each build.

Backend folder details

Publishing static assets to wwwroot folder

Generated by dotnet new react project file will contains following Target element:

<PropertyGroup>
    ...
    <SpaRoot>ClientApp\</SpaRoot>>
    ...
</PropertyGroup>

<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
    <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />

    <!-- Include the newly-built files in the publish output -->
    <ItemGroup>
        <DistFiles Include="$(SpaRoot)build\**" />
        <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
            <RelativePath>%(DistFiles.Identity)</RelativePath>
            <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
            <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
        </ResolvedFileToPublish>
    </ItemGroup>
</Target>

It means that ClientApp is located directly in the project folder and its build folder will be published to build folder in the output publish folder, not the wwwroot.

Note also, if you decided to develop frontend in dedicated folder and you just change $(SpaRoot) property to ..\..\frontend\packages\$(MSBuildProjectName)\ relative path, you'll get wrong RelativePath. To debug relative files locations we could add following Message to target temporarily:

<Message Text="%(ResolvedFileToPublish.Identity) => %(ResolvedFileToPublish.RelativePath)" Importance="High" />

Publishing will output paths like:

/path-to-root-folder-here/frontend/packages/web1/build/index.html => ../../frontend/packages/web1/build/index.html

Relative path points final file location in publish folder. If relative path starts with ../.. that actually points two folder upper than publish folder.

It is necessary to point frontend build output from frontend\packages\web1\build into wwwroot folder of publish folder. Standard ASP.NET Core UseStaticFiles middleware will serve wwwroot folder automatically.

To point static files to wwwroot folder we need to change RelativePath calculation to following:

<RelativePath>wwwroot/$([MSBuild]::MakeRelative($(MSBuildThisFileDirectory)$(SpaRoot)build, %(DistFiles.FullPath)))</RelativePath>

This will publish static files to right location:

/path-to-root-folder-here/frontend/packages/web1/build/index.html => wwwroot/index.html

Full correct source code for target:

<PropertyGroup>
    ...
    <SpaRoot>..\..\frontend\packages\$(MSBuildProjectName)</SpaRoot>>
    ...
</PropertyGroup>

<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
    <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />

    <!-- Include the newly-built files in the publish output -->
    <ItemGroup>
        <DistFiles Include="$(SpaRoot)build\**" />
        <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
            <RelativePath>wwwroot/$([MSBuild]::MakeRelative($(MSBuildThisFileDirectory)$(SpaRoot)build, %(DistFiles.FullPath)))</RelativePath>
            <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
            <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
        </ResolvedFileToPublish>
    </ItemGroup>
</Target>

Map fallback to index.html

We also needs to add UseStaticFiles and MapFallbackToFile in our Startup.Configure method like here:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseStaticFiles();
    app.UseAuthentication();
    app.UseRouting();
    app.UseAuthorization();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
        endpoints.MapFallbackToFile("index.html");
    });
}

Now if HTTP request will not find an action, content of our SPA's index.html will be returned.

Developers workflow

Backend developer may develop API separate from the UI and write tests. Frontend developer works in storybook and develop components in isolation. When they needs to test the UI/backend integration, they'll need to run all the stuff.

Both are could run the UI with yarn start command. Webpack-dev-server will be started. Frontend developer will make changes, static assets will be recompiled and appears in the browser automatically. HTTP requests to backend API will be passed to backend with http-proxy-middleware.

Frontend developer could use dotnet run command to run C# projects, because he does not changes the backend files and doesn't need recompile them.

Backend developer needs to recompile C# projects each time files are changing. It could be done with dotnet watch run command.

Conclusion

With this setup I've got:

  • monorepo frontend folder with two distinct SPA applications with shared eslint rules, browserlist-config and components packages
  • compiled SPA static assets will be published to wwwroot folder of ASP.NET Core web application for production use
  • storybook and webpack-dev-server with hot reload used in UI development
  • dotnet watch run could be used to recompile C# projects in development

Happy coding!


liberapay link If you like this post, consider supporting me on liberapay