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:
- Supporting multiple entry points from .Net
- Fixing a 2-4s delay when loading webpack files through .Net
- Fixing HMR not working when you run .Net w/ HTTPS locally
- (Hack) Using
UseReactDevelopmentServer
w/ Webpack 5 or other tools - 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:
- Identify an unused port
- Use a Process to run your npm command with PORT set
- Intercept the StdOut and StdErr from the process:
- ILogger.Log each line from those pipes
- 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
{
// ...
"scripts": {
"start": "node ./scripts/devServer/devServer",
// ...
}
// ...
}
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.
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.
- Add an npm script task specifically to launch when debugging VS to set an ENV var for the ASP.Net port
- Add the overrides in the webpack configuration for the dev server
- 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.
// ...
clientWebSocketPort: process.env.ASPNET_PORT || process.env.PORT || 8881,
// ...
And then incorporate that in my webpack configuration
// ...
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:
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.
- ASP.NET Core 3.1 React template live reload is extremely slow
- Performance issue in UseProxyToSpaDevelopmentServer
- Root cause identified in this comment
- "We've moved this issue to the Backlog milestone."
- and there were are more, I've read all of these at one point or another
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.
// ...
const isHostedThroughVS = (!!process.env.ASPNET_PORT);
// ...
module.exports = {
// ...
devHost: process.env.HOST || (isHostedThroughVS ? "[::1]" : "localhost"),
// ...
};
// ...
const { devProtocol, devHost, devPort } = require("../env.config");
// ...
const server = new WebpackDevServer({
// ...
host: devHost,
// ...
}, compiler);
// ...
Fixed:
5. Correcting Webpack 5 output to not report as errors in the .Net console
So this is pretty terrible:
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.
//...
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.
{
// ...
"Logging": {
"LogLevel": {
// ...
"Microsoft.AspNetCore.SpaServices": "Information"
}
}
}
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.
//...
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.
<!-- ... -->
<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:
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:
- Make sure all of your "will really be static files and not behind auth" are in a particular sub-folder
- Be very careful about placement of UseEndpoints
- Use MapWhen calls to map server-side endpoints earlier in the routes
- 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.