Disposition - a team produces an enterprise solution with Single-Page Application (SPA) front-end and .NET Web API back-end. What are the options to host the two beasts?
1. Hosting options
For just a Web API it’d be a no-brainer - use solutions like Azure App Service. Open-and-shut case. But when you bring a SPA into the picture there are usually two options:
Option 1. Hosting SPA independently from the Web API.
Preferably, on a Content Delivery Network (CDN) that lets you use own domain name, has configurable caching policies and all other modern attributes like 100% SLA, etc.
If you need to scale the front-end independently from the back-end then it’s a go-to option. But hold your horses for a moment. We’re talking about an enterprise app with a significant number of returned users where the SPA assets would often come from the browser cache.
Still need to scale independently? OK, then what about the downsides? Though, none of them is a deal-breaker, they all add up and may overweigh pros of CDN.
The biggest one is non-immutable front-end bundle.
As any part of the solution, SPA relies on environment variables (e.g. the URL of the Web API) that are different between your staging environment and production.
Of course, you may decouple the variables to a separate JSON file and have round trips to resolve them. Suboptimal though. Rebuilding the front-end for each environment for the sake of injecting variables is even less appealing (think of additional points of failure it introduces).
We can add a bunch of minor concerns like
- desynchronised deployment of the two essential parts of a single solution can be sometimes tricky and if not done right then leads to intermittent errors when the two get out of sync;
- extra resources (human-hours) to maintain the distributed infrastructure (CDN brings one more point of failure that sooner or later will require attention);
- a need in additional
OPTIONS
request each time to satisfy the browser’sCORS
feature (though, latency over HTTP/2 is negligible).
These concerns may lead us to consider another option.
Option 2. Hosting SPA from the Web API
Kestrel web server can host the front-end along with the .NET Web API. That would require a bit of code in your back-end project.
This solution easily eliminates the last 3 concerns:
- not splitting vitals part of one solution simplifies deployment;
- less moving part leads to easier maintenance;
- CORS is not involved as the front-end is hosted on the same domain.
However, achieving the two goals below need a deeper analysis:
- immutability of the front-end bundle;
- custom caching policy for various parts of the front-end.
2. SPA hosted by .NET Web API
Starting from .NET 2.x (still the case for .NET 5), there are UseStaticFiles, UseSpa and UseSpaStaticFiles extensions that do all the heavy lifting but the difference between them isn’t all clear from the official docs.
A brief summary would be:
UseSpa
serves the default page (index.html
) and redirects all requests there.UseStaticFiles
serves other thanindex.html
static files under the web root folder (wwwroot
, by default). Without this method Kestrel would returnindex.html
in response on all requests for static content.UseSpaStaticFiles
does a similar thing but it requires ISpaStaticFileProvider to be registered to resolve location of the static files. You need it if the static files are NOT under the default web root folderwwwroot
.
For simplicity, let’s host SPA from wwwroot
and leveraging UseSpa
and UseStaticFiles
methods to setup caching and inject environment variables into an already built front-end bundle.
2.1. Caching
SPA caching rules boil down to
- Cache static assets (JavaScript files, images, etc.) forever, as those files have unique names generated on each build.
- Prevent caching of the
index.html
as it’s usually a tiny file keeping all the references to other static assets with unique names. This way on releasing a new version the client would always download a new bundle of JS & CSS files.
Nowadays, the minimum set of HTTP response headers to disable caching requires:
Cache-Control: no-cache, no-store, must-revalidate, max-age=0
If you need to support old browsers then check out this debate on StackOverflow to adjust the rules. The key evolution points were:
Cache-Control
supersedesPragma
header (Mozilla docs);max-age=0
overtakesExpires: 0
(Mozilla docs).
As index.html
is hosted by UseSpa
method, so disabling cache happens this way:
app.UseSpa(c => c.Options.DefaultPageStaticFileOptions = new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
var headers = ctx.Context.Response.GetTypedHeaders();
headers.CacheControl = new CacheControlHeaderValue
{
NoCache = true,
NoStore = true,
MustRevalidate = true,
MaxAge = TimeSpan.Zero
};
}
})
Then to force caching other static assets we need to add this header to the HTTP response:
Cache-Control: max-age=31104000
where max-age
is set to an arbitrary large number (e.g. a year, as per the example above).
Remember that hosting of non-index.html
files is controlled by UseStaticFiles
method, so the code would look like:
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
var headers = ctx.Context.Response.GetTypedHeaders();
headers.CacheControl = new CacheControlHeaderValue { MaxAge = TimeSpan.FromDays(12*30) };
}
})
2.2. Passing parameters to immutable front-end bundle
As pointed above, preserving a well-tested and reliable front-end bundle is worth the efforts. Then how hosting the front-end from the .NET may help injecting environment variables? Of course, if we still hate doing a round trip to fetch variables in a JSON
file when the SPA has been bootstrapped.
Option 0. Injecting during deployment (preferable)
It would be the preferable option but the implementation depends on the deployment tools. Some (e.g. Octopus Deployment) can replace variables in index.html
and minimised JavaScript files.
Strictly speaking, injecting variables during deployment would break immutability of the bundle. Just a little bit, all the JS/CSS/etc. files remain untouched. But I’d turn a blind eye on it.
Option 1. Transforming the ‘index.html’ served by .NET
I’m not a fan of run-time index.html
manipulation in .NET, so feel free to jump over to ”Option 2” below.
However, we can define the environment variables in a JavaScript block in the index.html
. There are multiple ways of doing it, e.g.
<script type='text/javascript'>
window.importantVariable = '{#importantVariable}';
</script>
Then the index.html
is served by .NET where the {#importantVariable}
marker gets transferred to the environment value (e.g. taken from appsettings.json
).
The most common implementations of modifying the index.html
in .NET is adding a fallback MVC controller that would read and modify the file:
public class FallbackController : Controller
{
public async Task<IActionResult> Index()
{
string content = string.Empty;
using (var indexHtmlReader = System.IO.File.OpenText(_indexHtmlPath))
{ // Reads 'index.html'
content = await indexHtmlReader.ReadToEndAsync();
}
return new ContentResult
{
Content = content.Replace("{#importantVariable}", "Value"), // Replaces the variable
ContentType = "text/html"
};
}
}
and configuring the fallback in Startup.cs
:
app.UseWhen(context => !context.Request.Path.Value.StartsWith("/api"),
builder => {
app.UseSpa(c => {...});
app.UseMvc(routes => {
routes.MapSpaFallbackRoute(
name: "spa-fallback",
defaults: new { controller = "Fallback", action = "Index" });
});
});
Alternatively, you could do it all right in UseSpa
method and write a modified index.html
into the output… But let’s jump to a more interesting solution.
Option 2. Return ‘index.html’ along with a short-lived cookie
A less invasive solution would be returning a short-lived cookie along with index.html
that gets read when the SPA application starts.
Setting a small expiration period is a neat addition, as cookies get sent to the back-end on each XHR request. Expiration period (e.g. 30 sec) should be big enough to bootstrap and kick off the SPA to read the cookie and small enough to prevent adding unnecessary cookie to most of the server-side requests. Though it would introduce some fragility, negligible but still.
Just add 1 line to the above UseSpa
:
app.UseSpa(c => c.Options.DefaultPageStaticFileOptions = new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
var response = ctx.Context.Response;
response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
{
NoCache = true,
NoStore = true,
MustRevalidate = true,
MaxAge = TimeSpan.Zero
};
// Here is our cookie:
response.Cookies.Append("importantVariable", "Value", new CookieOptions { MaxAge = TimeSpan.FromSeconds(30) });
}
})
Then reading the cookie in SPA on starting the app:
this.getCookie('importantVariable', 'default value');
private getCookie(name: string, defaultValue: string): string {
const value = ('; ' + document.cookie).split(`; ${name}=`).pop()?.split(';').shift();
return !!value ? decodeURIComponent(value) : defaultValue;
}
Note that cookies do not provide isolation by port. Not a biggie, but can cause raised eyebrows when in the local environment devs swap between running the front-end under Kestrel and the framework’s CLI / NodeJs.
Cool or not? Have your say in the comments below, on Reddit thread, Twitter or LinkedIn.
And check out YABT, my open source project at GitHub that does the trick with hosting SPA and passing parameters in cookies (direct link to Startup configuration and reading cookies in Angular).