Html5 Offline Cache Integration with ASP.NET MVC Bundle

One of the lesser known and misunderstood feature of Html5 is offline cache, with carefully crafted it can make your application blazingly fast. In this post, I will show you how can you implement offline cache with the new asp.net asset bundle, the same technique can be also applied to other asset managers like combres or cassette. Currently the browser support of offline cache is aligned towards mobile platform comparing to desktop(except IE all other has this support) but the nice thing of offline cache is the browser that does not support it will simply ignore it, on the other hand the mobile platform which is dominated by the mobile web-kit has first class support of it, so I will use the default jQuery Mobile project the comes with the ASP.NET MVC 4.

Before digging into the code, let me give you a brief description of the offline cache. There are two steps to enable offline caching, first you have to write a manifest file which contains the rules of the caching and next you have to include the file in your html page. The manifest file has three sections a) cache b)network and c) fallback. The cache section is used to specify the list of urls that would be downloaded by the browser. The network section contains the list urls that would always be fetched from  the server, which means they will not be downloaded for offline viewing. The last section fallback contains a map of online and offline urls, if the device is offline then the offline version of the url will be served for the give online url. In a typical cache manifest the fallback section is not specified and the network section contains a asterisk (*) which files that urls that are not specified in the cache will be consulted with the server. For example:

Typical Cache Manifest

              #Header
              CACHE MANIFEST
              
              #Cache Section
              CACHE:
              /Content/Images/icons-18-white.png
              /Content/Images/icons-36-white.png
              /Content/Images/ajax-loader.png
              /Content/css
              /Scripts/js
              
              # Network Section
              NETWORK:
              *
              

You can also include urls form another domain unless your application is not hosted under ssl. Next, you need to do is include the manifest file in the html, like the following:

Embedding Cache Manifest in Html

              <html lang="en" manifest="/offline.cache">
              <!-- rest of the file omitted ->
              </html>
              

Now, we have everything setup the next and most important thing is cache invalidation, whenever we update a file we need to inform the client to download it again. This is done by adding a version number in the manifest file as comment.

Lets see how we can implement it in ASP.NET MVC, well there are many way to implement it but I preferred to create a dedicated ActionResult. The action result should have argument for each section in the constructor, except the cached assets everything else should be optional. When executing the action result there are three important thing that we have to consider, first the manifest file mime-type must be text/cache-manifest, it should not be cached by the browser and last it's encoding must be in utf-8.

AppCacheResult

              public class AppCacheResult : ActionResult
              {
                  public AppCacheResult(
                      IEnumerable<string> cacheAssets,
                      IEnumerable<string> networkAssets = null,
                      IDictionary<string, string> fallbackAssets = null,
                      string fingerprint = null)
                  {
                      if (cacheAssets == null)
                      {
                          throw new ArgumentNullException("cacheAssets");
                      }
              
                      CacheAssets = cacheAssets.ToList();
              
                      if (!CacheAssets.Any())
                      {
                          throw new ArgumentException(
                              "Cached url cannot be empty.", "cacheAssets");
                      }
              
                      NetworkAssets = networkAssets ?? new List<string>();
                      FallbackAssets = fallbackAssets ?? new Dictionary<string, string>();
                      Fingerprint = fingerprint;
                  }
              
                  protected IEnumerable<string> CacheAssets { get; private set; }
              
                  protected IEnumerable<string> NetworkAssets { get; private set; }
              
                  protected IDictionary<string, string> FallbackAssets
                  {
                      get; private set;
                  }
              
                  protected string Fingerprint { get; private set; }
              
                  public override void ExecuteResult(ControllerContext context)
                  {
                      if (context == null)
                      {
                          throw new ArgumentNullException("context");
                      }
              
                      var response = context.HttpContext.Response;
              
                      response.Cache.SetMaxAge(TimeSpan.Zero);
                      response.ContentType = "text/cache-manifest";
                      response.ContentEncoding = Encoding.UTF8; //  needs to be utf-8
                      response.Write(GenerateContent());
                  }
              
                  protected virtual string GenerateHeader()
                  {
                      return "CACHE MANIFEST" + Environment.NewLine;
                  }
              
                  protected virtual string GenerateFingerprint()
                  {
                      return string.IsNullOrWhiteSpace(Fingerprint) ?
                          string.Empty :
                          Environment.NewLine +
                          "# " + Fingerprint +
                          Environment.NewLine;
                  }
              
                  protected virtual string GenerateCache()
                  {
                      var result = new StringBuilder();
              
                      result.AppendLine();
                      result.AppendLine("CACHE:");
                      CacheAssets.ToList().ForEach(a => result.AppendLine(a));
              
                      return result.ToString();
                  }
              
                  protected virtual string GenerateNetwork()
                  {
                      var result = new StringBuilder();
              
                      result.AppendLine();
                      result.AppendLine("NETWORK:");
              
                      var networkAssets = NetworkAssets.ToList();
              
                      if (networkAssets.Any())
                      {
                          networkAssets.ForEach(a => result.AppendLine(a));
                      }
                      else
                      {
                          result.AppendLine("*");
                      }
              
                      return result.ToString();
                  }
              
                  protected virtual string GenerateFallback()
                  {
                      if (!FallbackAssets.Any())
                      {
                          return string.Empty;
                      }
              
                      var result = new StringBuilder();
              
                      result.AppendLine();
                      result.AppendLine("FALLBACK:");
              
                      foreach (var pair in FallbackAssets)
                      {
                          result.AppendLine(pair.Key + " " + pair.Value);
                      }
              
                      return result.ToString();
                  }
              
                  private string GenerateContent()
                  {
                      var content = new StringBuilder();
              
                      content.Append(GenerateHeader());
                      content.Append(GenerateFingerprint());
                      content.Append(GenerateCache());
                      content.Append(GenerateNetwork());
                      content.Append(GenerateFallback());
              
                      var result = content.ToString();
              
                      return result;
                  }
              }
              

Next, we have to create a controller which returns this action result:

OfflineController

              public class OfflineController : Controller
              {
                  public ActionResult Index()
                  {
                      return new AppCacheResult(new[]
                      {
                          Url.Content("~/Content/images/icons-18-white.png"),
                          Url.Content("~/Content/images/icons-36-white.png"),
                          Url.Content("~/Content/images/ajax-loader.png"),
                          BundleTable.Bundles.ResolveBundleUrl("~/Content/css", false),
                          BundleTable.Bundles.ResolveBundleUrl("~/Scripts/js", false)
                      },
                      fingerprint: BundleTable.Bundles
                                              .FingerprintsOf(
                                              "~/Content/css", "~/Scripts/js"));
                  }
              }
              

There are two important things in the above code, first I am specifying false as the second argument of the ResolveBundleUrl call, which means it should not generate the hash as query string of the bundle url, caching based upon query string is not a good practice (I hope the ASP.NET bundle will change the implementation prior the final release), next I am using a custom extension method which extracts the hash of the bundle as use it as versioning of the cache manifest file. The implementation of this extension method is trivial:

Fingerprint Extension

              public static class BundleCollectionExtensions
              {
                  public static string FingerprintsOf(
                      this BundleCollection instance,
                      params string[] virtualPaths)
                  {
                      if (!virtualPaths.Any())
                      {
                          return null;
                      }
              
                      var list = virtualPaths
                          .Select(path => instance.ResolveBundleUrl(path, true))
                          .Select(ExtractFingerprint)
                          .Where(f => !string.IsNullOrWhiteSpace(f));
              
                      var result = string.Join("|", list);
              
                      return result;
                  }
              
                  private static string ExtractFingerprint(string url)
                  {
                      var index = url.IndexOf('?');
              
                      if (index < 1)
                      {
                          return null;
                      }
              
                      var queryString = url.Substring(index + 1);
              
                      var parts = queryString.Split(new[] { '=' },
                          StringSplitOptions.RemoveEmptyEntries);
              
                      return parts.Length > 0 ? parts[1] : queryString;
                  }
              }
              

Next, change the view to include the manifest:

Including Manifest in Layout

              <html lang="en" manifest="@Url.Action("Index", "Offline")">
              <!-- ommitted -->
              </html>
              

and a route:

Route in global.asax

              routes.MapRoute(
                  "appcache", 
                  "offline.appcache", 
                  new { controller = "offline", action = "index" });
              

And finally lets add some JavaScript code so that we get an clear idea what is happening the client side behind the scene:

Clientside Interaction

              var offlineCache = window.applicationCache;
              
              if (!offlineCache) {
                  showMessage('offline cache is not supported.');
                  hideMessage();
                  return;
              }
              
              $(offlineCache).bind('checking', function () {
                  showMessage('checking cache...');
              });
              
              $(offlineCache).bind('noupdate', function () {
                  showMessage('cache is up to date.');
                  hideMessage();
              });
              
              $(offlineCache).bind('downloading', function () {
                  showMessage('started downloading...');
              });
              
              $(offlineCache).bind('progress', function (e) {
                  var msg = 'downloading assets...';
                  if (e.originalEvent.total) {
                      msg = 'downloading ' +
                            e.originalEvent.loaded + 
                            ' of ' + e.originalEvent.total + '...';
                  }
                  showMessage(msg);
              });
              
              $(offlineCache).bind('updateready', function () {
                  showMessage('downloaded new assets');
                  if (confirm('New updates are available, would you like to to reload?')) {
                      window.location.reload();
                  }
              });
              

Now, if you run it you may not be able to follow some of the status message because it will happen so fast, so lets add some artificial delay in the code:

Artificial delay

              public ActionResult Index()
              {
                  return new AppCacheResult(new[]
                  {
                      Url.Action("Delay", "Home", new { id = 1 }),
                      Url.Content("~/Content/images/icons-18-white.png"),
                      Url.Content("~/Content/images/icons-36-white.png"),
                      Url.Content("~/Content/images/ajax-loader.png"),
                      Url.Action("Delay", "Home", new { id = 2 }),
                      BundleTable.Bundles.ResolveBundleUrl("~/Content/css", false),
                      BundleTable.Bundles.ResolveBundleUrl("~/Scripts/js", false),
                      Url.Action("Delay", "Home", new { id = 3 })
                  },
                  fingerprint: BundleTable.Bundles
                                          .FingerprintsOf(
                                          "~/Content/css", "~/Scripts/js"));
              }
              

And the controller that does the delay:

Delay

              public ActionResult Delay(int id)
              {
                  Thread.Sleep(new Random().Next(3, 6) * 1000);
              
                  return Content("<div>This is delayed content</div>", "text/html");
              }
              

Now when you run it with you will find the following status messages:

Status messages
offine cache loading offine cache loaded offine cache up to date

If you open the chorme dev tools and navigate to the resource section, you will find the cached resources:

Chorme Dev Tools
loading

Lets see how it would behave when the manifest file is update, lets add another delay which modifies the manifest file:

Adding another delay

              public ActionResult Index()
              {
                  return new AppCacheResult(new[]
                  {
                      Url.Action("Delay", "Home", new { id = 1 }),
                      Url.Content("~/Content/images/icons-18-white.png"),
                      Url.Content("~/Content/images/icons-36-white.png"),
                      Url.Content("~/Content/images/ajax-loader.png"),
                      Url.Action("Delay", "Home", new { id = 2 }),
                      BundleTable.Bundles.ResolveBundleUrl("~/Content/css", false),
                      BundleTable.Bundles.ResolveBundleUrl("~/Scripts/js", false),
                      Url.Action("Delay", "Home", new { id = 3 }),
                      Url.Action("Delay", "Home", new { id = 4 })
                  },
                  fingerprint: BundleTable.Bundles
                                          .FingerprintsOf(
                                          "~/Content/css", "~/Scripts/js"));
              }
              

Now, if you run it again you will find the following behavior:

Cache updated
offine cache checking offine cache update available offine cache up to date

If you run it FireFox, it will prompt you whether to store data in the offline storage, just click allow, this is weird behavior of FireFox, I hope this will get addressed in future version of Firefox. I find chorme gives the best experience when it comes to develop html5 related features.

 

That’s it for today.

Download: Html5OfflineCache.zip

Shout it

Comments

blog comments powered by Disqus