Eli Weinstock-Herman

ASP.Net 6 with multiple react entry points

April 30, 2022 ▪ technical posts ▪ 13 min read

I've worked with React on .Net in a number of projects, and like many people have run into friction in a few places. In this post, I'm going to share some of the fixes and tips I've collected.


Contents:

  1. Supporting multiple entry points from .Net
  2. Fixing a 2-4s delay when loading webpack files through .Net
  3. Fixing HMR not working when you run .Net w/ HTTPS locally
  4. (Hack) Using UseReactDevelopmentServer w/ Webpack 5 or other tools
  5. Correcting Webpack 5 output to not report as errors in the .Net console

Sample code for this post: github: tarwn/blogexample-aspnetcore/.../multi-webpack-entrypoints


We will now document the points above in implementation order instead, for maximum confusion. 😁

4. Using UseReactDevelopmentServer w/ Webpack 5 or other tools

The SpaExtensions works like this:

  1. Identify an unused port
  2. Use a Process to run your npm command with PORT set
  3. Intercept the StdOut and StdErr from the process:
    1. ILogger.Log each line from those pipes
    2. Await a regex match to "Starting the development server", or timeout

Unfortunately that last part is hardcoded: ReactDevelopmentServerMiddleware.cs#L92

However, as long as we output that magic string we can plug in any runner we want.

Webpack 5 Example

package.json

{
    // ...
    "scripts": {
        "start": "node ./scripts/devServer/devServer",
        // ...
    }
    // ...
}

devServer.js

compiler.hooks.done.tap("done", (stats) => {
    // ...

    // this pretends to be an older version of WebPack because Microsoft hard-coded the string
    // it's expecting to see in UseReactDevelopmentServer
    console.log("Starting the development server");

    // ...
});

This is also much easier than building your own extension method, as it turns out Microsoft decided to make all of the dependent helper classes internal so you end up having to duplicate a lot.

3. Fixing HMR not working when you run .Net w/ HTTPS locally

I prefer to keep my local development as close to production as possible, which includes running ASP.Net with HTTPS.

In an earlier project, we noticed that switching to HTTP would suddenly allow HMR to work. Switching back to HTTPS would break it.

So the quickest fix is to run HTTP and add some tests or constraints to make sure you don't accidentally deploy that way, don't integrate with systems that get upset if you do HTTP (OIDC providers), etc. Ick.

Webpack HMR Websocket attempting to connect
Network: Webpack HMR Websocket attempting to connect, retrying, empty response
Webpack HMR Websocket console error
Console: Webpack HMR Websocket errors
WebPack injects a script in the entry point to connect to a websocket to receive notifications of updates. If we look at the console, we can see that the websocket is failing to connect (`59125` is the Webpack server in this example).

The problem is the wss:// on the address above. Webpack is running under HTTP and actually asked for a ws:// connection (you can see it in the source). But the browser recognizes it has loaded the page through HTTPS so automatically upgrades ws:// to wss://.

The fix is to recognize when we launch the Webpack app from Visual Studio and change the web socket port to the ASP.Net server port, so we can proxy the WSS call through to the webpack server the same we we do ASP.Net HTTPS => WebPack HTTP.

  1. Add an npm script task specifically to launch when debugging VS to set an ENV var for the ASP.Net port
  2. Add the overrides in the webpack configuration for the dev server
  3. Change ASP.Net to call this new npm script task

My ASP.Net app runs on post 7155: package.json

{
    // ...
    "scripts": {
        // ...
        "start:vs": "cross-env ASPNET_PORT=7155 node ./scripts/devServer/devServer",
        // ...
    }
    //...
}

I use an environment config file to set the dev server configs. I'll default to ASPNET_PORT if it's set, then PORT, then a hardcoded value.

env.config.js

  // ...
  clientWebSocketPort: process.env.ASPNET_PORT || process.env.PORT || 8881,
  // ...

And then incorporate that in my webpack configuration

webpack.config.dev.js

// ...
config.devServer = {
  // ability to override if Visual Studio hosted so we can proxy through visual studio
  client: {
    webSocketURL: `ws://${buildConfig.clientWebSocketHost}:${buildConfig.clientWebSocketPort}/ws`,
  }
};
// ...

This doesn't change the ports anything is running on, only the address used in the injected script to start HMR.

Result:

Webpack HMR Websocket console success
Console: Webpack HMR is now enabled

When ASP.Net calls npm run start:vs, webpack will continue to serve up files from PORT and will open the websocket on PORT, but it will configure the script to open the websocket on ASPNET_PORT (which ASP.Net will then proxy through to the real websocket on webpack).

We also still have the original task, so we can run webpack directly with npm run start without proxying through .Net.

2. Fixing a 2-4s delay when loading webpack files through .Net

You may notice there is a 2 or 4s delay in files loaded from webpack. This becomes especially noticeable when you enable HMR and it's less than snappy.

Request delay for proxied webpack file
Request Timing: waiting to download proxied webpack file for 2.03s
There are numerous github issues on this:

So to be clear, if Microsoft had exposed a host address as an option instead of hardcoding localhost, we all would have fixed this for ourselves years ago.

I know this because when I created a modified version of their runner for Parcel years ago, I stumbled on the "localhost" vs "127.0.0.1" issue without fully understanding why it fixed things.

So if HttpClient is going to try ipv6 first, and we can't change the server behavior, the quick fix is to make webpack devserver listen on ipv6.

There's a command line flag for this, but since we already need to launch from a devServer script for the HMR fix, we can use the environment config and pass the host config in. We also already have an environment variable that we only set when we're debugging from Visual Studio, which is handy.

env.config.js

// ...
const isHostedThroughVS = (!!process.env.ASPNET_PORT);
// ...
module.exports = {
  // ...
  devHost: process.env.HOST || (isHostedThroughVS ? "[::1]" : "localhost"),
  // ...
};

devServer.js

// ...
const { devProtocol, devHost, devPort } = require("../env.config");
// ...
const server = new WebpackDevServer({
  // ...
  host: devHost,
  // ...
}, compiler);
// ...

Fixed:

Request delay for proxied webpack file
Request Timing: was 2.03s and now is 5ms

5. Correcting Webpack 5 output to not report as errors in the .Net console

So this is pretty terrible:

DotNet output for WebPack shows as FAIL messages
Underlying WebPack output is displayed as FAIL/error messages in console
Everyone claims it's not their problem. Microsoft points at webpack (rightly in this case). There's a github issue for webpack where they claim that this is the appropriate use of stderr because someone once said that stderr could be used this way 10,000 years ago, etc.

It's relatively easy to override/fix once you have a custom webpack config.

I configure this in my base webpack, not only dev, because there is no environment where I want to mask real errors with operational messages on stderr and I haven't bothered to look for a porcelain flag for webpack.

webpack.config.base.js

//...
  infrastructureLogging: {
    stream: process.stdout,
    colors: false,
    level: "info"
  }
};

I'm not sure if I want to see them in my .Net console or not. I don't like the output being piped to individual ILogger messages (it's noisier this way), but I want the content.

To make it visible in the Console when we're debugging, we add a Logging configuration to appsettings.

appsettings.Development.json

{
  // ...
  "Logging": {
    "LogLevel": {
      // ...
      "Microsoft.AspNetCore.SpaServices": "Information"
    }
  }
}
DotNet output for WebPack shows as INFO messages
Underlying WebPack output is now displayed as INFO messages in console

1. Supporting multiple entry points from .Net

Now we get to the good part, the first level of supporting multiple react entry points from ASP.Net.

At this level, we are going to have both a React SPA and we are going to output shared CSS for Razor pages, and have live HMR for that CSS in the Razor pages.

This means we can edit variables and a subset of shared CSS in the React app, and the changes will instantly propagate to either the React App or an open Razor page, without refreshing. When we deploy, it will simply be a CSS file like any other.

webpack.config.base.js

//...
module.exports = {
  context: buildConfig.rootPath,
  entry: {
    index: ["./src/index.tsx"],
    site: ["./styles/site.scss"]
  },
 // ...

We now have an entry point for the SPA and one for a SCSS file that will be the shared CSS. All other CSS within the react app can continue to live with the components, this is only shared styles.

In the Razor _Layout.cshtml file, we can now conditionally reference either the JS version of the site entry point, which will have HMR injected, or the CSS output from the entry point, when outside development.

_Layout.cshtml

    <!-- ... -->

    <environment include="Development">
        <script src="~/site.js" asp-append-version="true"></script>
    </environment>
    <environment exclude="Development">
        <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
    </environment>

    <!-- ... -->

If we view source for the ASP.Net page, we get the nice source mapping we would expect:

SCSS source mapping from Razor Page
SCSS source mapping from Razor Page
And while we have that Razor Page loaded (Login in this example), updates to SCSS files are immediately updated via HMR, just like if we were in the React SPA.
Network log with HTML page and HMR js calls
HMR loading happening from the Login HTML page

Warnings and Even More Entry Points

So a couple warnings.

MapRazorPages - inside a UseEndpoints call

I don't typically use Razor Pages, I've also see a lot of unexpected behavior from UseEndpoints since it came out. I have not spent enough time digging deep into it, but I think there may be subtle differences between my mental model of how routing works (I've used and upgraded all the .Net Core versions in production, across multiple apps).

This was necessary:

app.UseEndpoints(endpoints =>
{
    endpoints.MapRazorPages();
});

Without that, the Razor Page routes wouldn't be routed and the UseSpa would terminate/serve routes I expected MapRazorPages to catch.

Warning: UseEndpoints operate on a shared store

Next warning. This comes from a complex application with 4 webpack entry points, 2 actually routed from Program.cs depending on the logged in user's permissions and requiring authentication.

I am used to looking at the bottom of the Program.cs as if it's a series of middleware registrations, where they are executed in that order and can be finalized and return early. But that's an over simplification of what's actually happening, which is where UseEndpoints bites us.

UseEndpoints calls register against a shared underlying store. What this means is that you may have a request come in and you would expect it to hit each middleware from top to bottom. What actually happens is all of the UseEndpoints calls register their routes in a shared manner, so that when the request is processed it is matched at the level of the very first UseEndpoints call. Possibly before things like UseAuthentication, for instance.

So, beware. If you're doing complex MapWhens and such, be very careful about the location of UseEndpoints calls.

There's a github issue in aspnet somewhere that talks about this in more detail and explains why this is by design (I don't have it handy, I found it back in january or february and forgot to bookmark it).

Difference between Development and Production

Even though SpaExtensions is more similar to a production environment than using the front-end devserver to route/proxy calls to the backend, there is still one really big difference.

In production we will expect the SPA files to be served earlier in the request pipeline, via a UseSpaStaticFiles registration (or similar). In local development, those files are served at the bottom as part of UseSpa.

We may also want to serve different SPA entry points based on a user's permissions (rewrite Request Path in middleware).

When you start getting this complex, you will likely have to add in a couple checks to let known paths or file types to flow through or be unaltered. I would suggest doing the following:

  1. Make sure all of your "will really be static files and not behind auth" are in a particular sub-folder
  2. Be very careful about placement of UseEndpoints
  3. Use MapWhen calls to map server-side endpoints earlier in the routes
  4. Create a method that checks the path against your static exclusions above, so you can conditionally require authentication or rewrite non-matching paths to the correct SPA entry point filename

I recall that the "conditionally authenticate" was a bit difficult, because only some extensions in Program.cs support specifying an auth policy, plus there was funkiness around UseEndpoints and a couple others, but unfortunately have not had time to extend the example code for this post to include all of that.

Share:
Related Posts