Alex KlausDeveloper  |  Architect  |  Leader
Hosting SPA + .NET API solution. Caching and environment variables
21 March 2021

.NET + SPA hosting

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’s CORS 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:

  1. immutability of the front-end bundle;
  2. 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 than index.html static files under the web root folder (wwwroot, by default). Without this method Kestrel would return index.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 folder wwwroot.

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

  1. Cache static assets (JavaScript files, images, etc.) forever, as those files have unique names generated on each build.
  2. 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:

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).