Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Using jQuery and Bootstrap from a CDN with fallback scripts in ASP.NET Core 3.0

$
0
0

In this post, I show how to use the Link Tag Helper and Script Tag Helper in Razor with the asp-fallback attribute to serve files from a Content Delivery Network (CDN), falling back to local scripts if the CDN is unavailable.

Using a CDN with a fallback was the default approach in the ASP.NET Core templates for .NET Core 2.x, but in 3.x the templates were significantly simplified and now only serve from local files.

Using a CDN for common libraries

The first thing to discuss is why you might want to use a CDN for serving your application's client-side dependencies.

A CDN is just another server that hosts common files, often used for client-side assets like CSS stylesheets, JavaScript libraries, or images. Using a CDN can speed up your applications for several reasons:

  • CDNs are typically globally distributed, so can give very low latencies for downloading files, wherever in the world your users are. That can make a big difference if your application is only hosted in one region, and users are sending requests from the other side of the world!
  • It offloads network traffic from your servers, reducing the load on your server.
  • By sending requests for client-side assets to a CDN, you may see higher overall network throughput for your application. Browsers limit the number of simultaneous connections they make to a server (commonly 6). If you host your files on a CDN, the connections to the CDN don't count towards your server limit, leaving more connections to download in parallel from your app.
  • Other applications may have already downloaded common libraries from the CDN. If the file is already cached by the browser, it may not need to make a request at all, significantly speeding up your application.

If you need to include common libraries such as Bootstrap or jQuery, then it can make a lot of sense to serve these from a CDN. These libraries are publicly hosted on many different CDNs, so using any of the common ones can be a big win for your application's performance.

There are a couple of downsides or considerations when using CDNs

  • By using a CDN you're trusting them to deliver code to your user's browser. You need to be careful that if a CDN is compromised with malicious JavaScript, your website doesn't run it on your page. That can put both you and your users at risk.
  • If a CDN is unavailable, you should fallback to serving the scripts from your own website, as otherwise a CDN going down could break your application, as shown below.

Application broken because CDN failed

I'm going to describe how to tackle that second point in this post, but the solutions will also cover the first point too. For more details on the security side, see this post by Scott Helme on adding a Content-Security Policy (CSP) to your application, and using Sub Resource Integrity (SRI) checks.

Whether you consider adding a fallback worthwhile will depend very much on the application you're building. Using a fallback adds complexity to your site that you may not need. The Tag Helper approach I show here also requires injecting inline-JavaScript, which may be at-odds with your CSP.

The current ASP.NET Core templates - no CDN for you

As part of the ASP.NET Core 3.x updates, the default templates were updated to use Bootstrap 4 (instead of version 3). They were also simplified significantly, and as part of that, CDN support was removed. If you look at the default _Layout.cshtml for a Razor Pages or MVC application in ASP.NET Core 3.0, you'll see something like the following (I've only kept the pertinent <link> and <script> tags in the example below):

<!DOCTYPE html>
<html lang="en">
<head>
    <!-- other head tags -->
    <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
    <link rel="stylesheet" href="~/css/site.css" />
</head>
<body>
    <!-- other body content -->
    @RenderBody()
    <!-- other body content -->

    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
    <script src="~/js/site.js" asp-append-version="true"></script>
    
    @RenderSection("Scripts", required: false)
</body>
</html>

As you can see, the default layout references the following files:

  • bootstrap.min.css - The core Bootstrap CSS files. Version 4.3.1 as of .NET Core 3.1
  • site.css - The custom CSS for your website.
  • jquery.min.js - jQuery version 3.3.1 - required by Bootstrap.
  • bootstrap.bundle.min.js - The bootstrap jQuery plugins (bundled with Popper.js)
  • site.js - The custom JavaScript for your website.

In addition, for client-side validation you need to add the jQuery validation libraries. These are specified in the separate _ValidationScriptsPartial.cshtml file:

<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

All these libraries are included in the default templates in the wwwroot/lib folders, but if you'd rather serve these files from a CDN, then you should consider keeping these files as a fallback.

Using fallback Tag Helpers to test for failed file loading from a CDN

The Link and Script Tag Helpers support the concept of configuring a fallback test for files loaded from a CDN. You can add asp-fallback-* attributes to a link, and the tag helper automatically generates some JavaScript to check if the file was downloaded from the CDN correctly.

For example, lets just take the first <link> from _layout.cshtml:

<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />

You could update this link to load the Bootstrap CSS file from a CDN (cdnjs in this example) by changing the href:

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.1.3/css/bootstrap.min.css" />

However, if the CDN is unavailable, your site will look very broken. You can provide a fallback for a CSS stylesheet link, by adding the following attributes:

  • asp-fallback-test-class - The CSS class to apply to a test element. Should be a class specified in the linked stylesheet, that won't exist otherwise.
  • asp-fallback-test-property - The CSS property to check on the test element.
  • asp-fallback-test-value - The value of the CSS property that the test element should have, if the linked stylesheet didn't load correctly.
  • asp-fallback-href - The URL of the file to load if the test fails.

For the Bootstrap example, you could apply the .sr-only class, and check that the position property has the value absolute using the following:

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.1.3/css/bootstrap.min.css"
    asp-fallback-href="~/lib/bootstrap/dist/css/bootstrap.min.css"
    asp-fallback-test-class="sr-only" 
    asp-fallback-test-property="position" 
    asp-fallback-test-value="absolute" />

When it renders, this generates the following markup and inline JavaScript (the JavaScript is minified in practice, I've de-mangled and simplified it to make it a bit easier to understand below):

<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" />
<meta name="x-stylesheet-fallback-test" content="" class="sr-only" />
<script>
    function checkValid(property, value, localLink, attributes) {
        var elements = document.getElementsByTagName("SCRIPT");
        var style = getStyle(elements);
        if (style && style[property] !== value) {
            document.write('<link href="' + localLink + '" ' + attributes + "/>")
        }
    }

    function getStyle(elements){
        var previousEl = elements[elements.length - 1].previousElementSibling;
        return document.defaultView && document.defaultView.getComputedStyle
            ? document.defaultView.getComputedStyle(previousEl)
            : previousEl.currentStyle;      
    }
    checkValid("position", "absolute", "/lib/bootstrap/dist/css/bootstrap.min.css", "rel=\u0022stylesheet\u0022");
</script>

As you can see, that's a lot of extra JavaScript to check for a fallback. The version for <script> tags is a lot simpler. You just need two attributes for that:

  • asp-fallback-test - the JavaScript code to run that should evaluate to a "truthy" value if the script was loaded correctly.
  • asp-fallback-src - The URL of the file to load if the test fails.

For this script file reference:

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"
    asp-fallback-src="~/lib/jquery/dist/jquery.min.js"
    asp-fallback-test="window.jQuery">
</script>

You'll get the following generated HTML:

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script>(window.jQuery||document.write("\u003Cscript src=\u0022/lib/jquery/dist/jquery.min.js\u0022\u003E\u003C/script\u003E"));</script>

The JavaScript generated is pretty simple - run the test, and if it fails, add a new <script> tag with the correct URL.

That gives us everything we need to update our layout files to use a CDN with a local falback.

Updating the templates to use a CDN with a fallback

I'll start with _Layout.cshtml first, from the start of this post.

<!DOCTYPE html>
<html lang="en">
<head>
    <!-- other head tags -->
    
    <environment include="Development">
        <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.css" />
        <link rel="stylesheet" href="~/css/site.css" />
    </environment>
    
    <environment exclude="Development">
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
              asp-fallback-href="~/lib/bootstrap/dist/css/bootstrap.min.css"
              asp-fallback-test-class="sr-only" asp-fallback-test-property="position" asp-fallback-test-value="absolute"
              integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous" />
        <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
    </environment>
</head>
<body>
    <!-- other body content -->
    @RenderBody()
    <!-- other body content -->

    <environment include="Development">
        <script src="~/lib/jquery/dist/jquery.js"></script>
        <script src="~/lib/bootstrap/dist/js/bootstrap.js"></script>
        <script src="~/js/site.js" asp-append-version="true"></script>
    </environment>

    <environment exclude="Development">
        <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"
                asp-fallback-src="~/lib/jquery/dist/jquery.min.js"
                asp-fallback-test="window.jQuery"
                crossorigin="anonymous"
                integrity="sha384-tsQFqpEReu7ZLhBV2VZlAu7zcOV+rXbYlF2cqB8txI/8aZajjp4Bqd+V6D5IgvKT">
        </script>
        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.bundle.min.js"
                asp-fallback-src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"
                asp-fallback-test="window.jQuery && window.jQuery.fn && window.jQuery.fn.modal"
                crossorigin="anonymous"
                integrity="sha384-xrRywqdh3PHs8keKZN+8zzc5TX0GRTLCcmivcbNJWm2rs5C8PRhcEn3czEjhAO9o">
        </script>
        <script src="~/js/site.js" asp-append-version="true"></script>
    </environment>
    
    @RenderSection("Scripts", required: false)
</body>
</html>

There's a lot in there, but here are the highlights:

Next up is the _ValidationScriptsPartial.cshtml file, which uses a similar approach:

<environment include="Development">
    <script src="~/lib/jquery-validation/dist/jquery.validate.js"></script>
    <script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js"></script>
</environment>
<environment exclude="Development">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.1/jquery.validate.min.js"
            asp-fallback-src="~/lib/jquery-validation/dist/jquery.validate.min.js"
            asp-fallback-test="window.jQuery && window.jQuery.validator"
            crossorigin="anonymous"
            integrity="sha384-rZfj/ogBloos6wzLGpPkkOr/gpkBNLZ6b6yLy4o+ok+t/SAKlL5mvXLr0OXNi1Hp">
    </script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validation-unobtrusive/3.2.11/jquery.validate.unobtrusive.min.js"
            asp-fallback-src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"
            asp-fallback-test="window.jQuery && window.jQuery.validator && window.jQuery.validator.unobtrusive"
            crossorigin="anonymous"
            integrity="sha384-ifv0TYDWxBHzvAk2Z0n8R434FL1Rlv/Av18DXE43N/1rvHyOG4izKst0f2iSLdds">
    </script>
</environment>

With that in place, lets try it out.

Testing the fallbacks

The easiest way to test that your fallback behaviour is working correctly, is to actively block the CDN files from loading. You can achieve that in Chrome or Edge by opening dev-tools (F12) and right-clicking the network file in question. From that you can choose "Block Request URL":

Blocking request URLs

If you go through and block all the CDN URLs (or the domains) and reload the page, it should load fine. The blocked URLs are shown as blocked in the network tab, but the fallback tests wil fail, and use the local URLs instead:

Files blocked

Success!

There's one thing to watch out for though - for the integrity attribute to work correctly, the local file must be exactly the same as the CDN version. When I tested blocking CDN files initially, the fallback tests failed, but so did loading the local files:

Invalid SRI causing failed file load

SRI requires referenced files to be byte-for-byte identical. In my case, the local files used CRLF instead of the LF used in the CDN. I fixed it by overwriting the local files with the ones from the CDN, and ensuring that git preserved the LF, by adding this to the project .gitattributes file:

**/wwwroot/lib/** text eol=lf

That ensures that the files in wwwroot/lib are always checked-out with LF line endings, even on windows, and should help avoid SRI issues!

Summary

In this post I showed how you could update the default ASP.NET templates to load CSS stylesheets and JavaScript libraries from a CDN. I showed how to use Tag Helpers to add fallback tests, so that if the CDN is unreachable, then your library files will be loaded from the local files instead.

As part of the update, I added SRI hashes to ensure that if the CDN files are compromised (as has happened in several high-profile cases), your application will refuse to run the compromised files. With the fallbacks configured, your application will be protected and will continue to function. Win win!


Viewing all articles
Browse latest Browse all 743

Trending Articles