I've done a deep-dive into how this works recently and I thought I'd explain in detail here, because I don't think it's that well documented. I've investigated how it works with Visual Studio's React template, but it'll work similarly for other SPA frameworks too.
An important point to understand is that, perhaps unlike a more traditional ASP.NET web application: the way your ASP.NET SPA site runs in development is radically different to how it runs in production.
The above is very important to remember because much of the internal implementation of this stuff only applies in production, when you're not routing requests through to a Webpack development webserver. Most of the relevant ASP.NET SPA code is located at this location in the source repo, which I'll be referencing occasionally below. If you want to really dig into the implementation, look there.
IMPORTANT UPDATE:
With .NET 6, Microsoft have completely flipped how this works and now put the Webpack/Vite/whatever SPA dev server in front of the ASP.NET dev server. Frankly, this irrirtates me and I don't like what they've done. I was perfectly happy with the proxying method before. That code is still there, but presumably they will gradually stop supporting it. This sucks. Whatever. I'll leave the guide below, but it applies to the recommended .NET 5 approach, not the .NET 6 one.
How SPA works in ASP.NET
So, to go through the various SPA calls in the order in which they're called by default in the template:
// In production, the React files will be served from this directory
services.AddSpaStaticFiles(configuration => {
configuration.RootPath = "ClientApp/build";
});
The comment is slightly misleading, as I'll explain below. This is called in ConfigureServices
, and adds in the DefaultSpaStaticFileProvider
as an ISpaStaticFileProvider
for DI. This is required if you're going to call UseSpaStaticFiles
later on (which you probably are). RootPath
is specified here (see later on for what it does). Next (remaining calls are made in Configure
):
app.UseStaticFiles();
This is good old UseStaticFiles
, tried-and-tested way of serving static files from the wwwroot
directory. This is called before the others, meaning that if a requested path exists in wwwroot
, it will be served immediately from there, and not looked for in the SPA directory (which is ClientApp/public
by default during development, or ClientApp/build
by default during production - see 'Where do the static assets get served from?' below). If it doesn't exist there, it will fall through to the next middleware, which is:
app.UseSpaStaticFiles();
This is similar to app.UseStaticFiles
, but serves static files from a different directory from wwwroot
- your 'SPA' directory, which defaults to ClientApp
. However, although it can technically work during development, it is only intended to do anything during production. It looks in the above-mentioned RootPath
directory - something like ClientApp/build
- and tries to serve files from there if they exist. This directory will exist in a published production site, and will contain the SPA content copied from ClientApp/public
, and also what was generated by Webpack. However, even though UseSpaStaticFiles
is still registered when the site's running in development, it will probably fall through, because ClientApp/build
doesn't exist during development. Why not? If you publish your app, the ClientApp/build
directory will indeed be created under your project's root directory. But the SPA templates rely on it being deleted during development, because when you run app.UseSpa
later on, it eventually runs npm run start
, which (if you look in package.json
) will run something like:
"start": "rimraf ./build && react-scripts start",
Notice the destruction of the build
directory. UseSpaStaticFiles
is relying on a npm
script being triggered by a later piece of middleware to delete the build
directory and effectively stop it from hijacking the pipeline during development! If you manually restore that build
directory after starting the site, this middleware will serve files from it even during development. Rather Heath Robinson. As I mentioned above, the comment about React files being served from this directory 'in Production' is slightly misleading because, well, they'll be served from here during development too. It's just that there is an assumption that this directory won't exist during development. Why they didn't just put something like this in the template I'm not sure:
if (!env.IsDevelopment()) {
app.UseSpaStaticFiles();
}
And indeed I'd recommend you put that if
clause in so you're not relying on build
being deleted from the filesystem.
UPDATE: I've just noticed that this middleware isn't quite as odd as I'd first thought. The DefaultSpaStaticFileProvider
actually checks to see whether ClientApp/build
exists upon instantiation, and if it doesn't, it doesn't create a file provider, meaning that this will reliably fall through during development when ClientApp/build
doesn't exist, even if you restore the directory later. Except that my above description of the behaviour still applies the first time you run the site after publishing, because it's still true that on the first run, ClientApp/build
will exist, so this check is a bit of a questionable way of detecting whether static files should never be served (like in a dev environment proxying through to an internal Webpack dev server) or not. I still think my above wrapping of UseSpaStaticFiles
in a clause along the lines of if (!env.IsDevelopment()) { ... }
is a more reliable and simpler way to do it, and I'm puzzled that they didn't take that approach.
But anyway, this firmware is intended to fall through 100% of the time during development, because the directory should be deleted when the request is made to ASP.NET, to the final middleware:
app.UseSpa(spa => {
//spa.Options.SourcePath = "ClientApp";
// ^ as this is only used in development, it's misleading to put it here. I've moved
// it inside the following 'if' clause.
if (env.IsDevelopment()) {
spa.Options.SourcePath = "ClientApp";
// This is called for the React SPA template, but beneath the surface it, like all
// similar development server proxies, calls
// 'SpaProxyingExtensions.UseProxyToSpaDevelopmentServer', which causes all
// requests to be routed through to a Webpack development server for React, once
// it's configured and started that server.
spa.UseReactDevelopmentServer(npmScript: "start");
}
});
This 'catch-all' middleware rewrites all requests to the 'default page' for the SPA framework you're using, which is determined by spa.Options.DefaultPage
, which defaults to /index.html
. So all requests will by default render /index.html
during production, allowing the client app to always load its page and deal appropriately with requests to different URLs.
However, this middleware is NOT intended to get hit during development, because of what's inside the above if (env.IsDevelopment()) { ... }
clause. That UseReactDevelopmentServer
(or whatever eventually calls UseProxyToSpaDevelopmentServer
, or maybe a direct call to UseProxyToSpaDevelopmentServer
) adds a terminal middleware that proxies all requests to a Webpack development server, and actually prevents requests from going through to the UseSpa
middleware. So, during production, this middleware runs and acts as a "catch-all" to render index.html
. But during development, it is not indended to run at all, and requests should be intercepted first by a proxying middleware that forwards to a Webpack development server and returns its responses back. The Webpack development server is started in the working directory specified by spa.Options.SourcePath
, so it will serve ClientApp/public/index.html
as the catch-all webpage during development (/public/
??? See below.) The spa.Options.SourcePath
option is not used during production.
Where do the static assets get served from?
Take a given request for a file /logo.png
. wwwroot
will first be checked, and if it exists there, it will be served. During production, ClientApp/build
will then be checked for the file, as that was the path configured in services.AddSpaStaticFiles
. During development, however, this path is effectively not checked (it is, but it should always have been deleted before development starts; see above), and the path that will get checked for static assets instead is ClientApp/public
in your project's root directory. This is because the request will be proxied through to an internal Webpack development server. The Webpack development server serves both dynamically-generated Webpack assets, but also static assets like /logo.png
, from its "static directory" option, which defaults to public
. Since the server was started in the ClientApp
working directory (thanks to the spa.Options.SourcePath
option), it will try to serve static assets from ClientApp/public
.
In summary - execution flow
Basically, ASP.NET's SPA methods are trying to roughly emulate at production what the Webpack development server does at development - serve static assets first (although ASP.NET also tries wwwroot
first, which the Webpack dev server obviously doesn't do), then fall through to a default index.html
for the SPA app (the Webpack dev server doesn't do this by default, but things like react-scripts
have a default setup which adds a plugin causing this to happen).
However, at development, ASP.NET actually does proxy requests through to that Webpack dev server, so its SPA middleware is basically meant to get bypassed. Here's a summary of the intended flow in both cases:
Production:
Request
-> Check in /wwwroot
-> Check in /ClientApp/build
-> Rewrite request path to /index.html and serve that from /ClientApp/build
Development:
Request
-> Check in /wwwroot
-> Proxy through to internal Webpack dev web server, which:
-> ... serves static assets from /ClientApp/public
-> ... serves dynamically-generated Webpack assets from their locations
-> ... failing that, rewrites request path to /index.html and serves that from /ClientApp/public
An additional WTF
Microsoft made a few design decisions I think are a bit questionable with how they did this, but one behaviour makes no sense to me and I have no idea of its use-case, but it's worth mentioning.
app.UseSpa
ends up calling app.UseSpaStaticFilesInternal
with the allowFallbackOnServingWebRootFiles
option set to true
. The only time that has an effect is if you didn't previously add an ISpaStaticFileProvider
to DI (eg. by calling services.AddSpaStaticFiles
), and you call app.UseSpa
anyway. In that case, instead of throwing an exception, it will serve files from wwwroot
. I honestly have no idea what the point of this is. The template already calls app.UseStaticFiles
before anything else, so files from wwwroot
already get served as top priority. If you remove that, and remove services.AddSpaStaticFiles
, and don't call app.UseSpaStaticFiles
(because that'll throw the exception), then app.UseSpa
will serve files from wwwroot
. If that has a use-case, I don't know what it is.
Further thoughts
This setup works OK, but the 'on-demand' setup of the Webpack development server seems to spin up a ton of node instances, which seems rather inefficient to me. As suggested under 'Updated development setup' in this blog (which also provides some good insights into how this SPA stuff works), it might be a better idea to manually run the Webpack development server (or Angular/React/whatever) at the beginning of your development session, and change the on-demand creation of a dev server (spa.UseReactDevelopmentServer(npmScript: "start")
) to on-demand creation of a proxy to the existing dev server (spa.UseProxyToSpaDevelopmentServer("http://localhost:4200")
), which should avoid spinning up 101 node instances. This also avoids some unnecessary slowness of rebuilding the JS each time something in the ASP.NET source changes.
Hmm... MS went to so much effort to allow that proxying through to a node-backed Webpack development web server, it would almost be simpler if they just recommended that in production, you deploy a node-based web server to proxy all SPA requests through to. That would avoid all the extra code which acts differently in production and development. Oh well, I guess that this way, at least when you get to production, you're not reliant on node. But you pretty much are at development. Probably unavoidable given how all the SPA ecosystems are designed to work with node. When it comes to SPA applications, node has effectively become a necessary build tool, akin to the likes of gcc
. You don't need it to run the compiled application, but you sure need it to transpile it from source. I guess hand-crafting the JavaScript to run in the browser, in this analogy, would be like hand-writing the assembly. Technically possible, but not really done.