Wednesday, March 14, 2012

HTML5 Offline Web applications as an afterthought in ASP.NET MVC

Recently I prototyped a mobile web application using ASP.NET MVC, jQuery Mobile and some HTML5 features. One of the key goals was to find out how far you can push a web 'application' until the browser starts getting in the way. Working disconnected is one of these things that appear to be a major showstopper at first.

However - to my surprise honestly - the HTML5 Offline Web applications API seems to be widely implemented across modern browsers already. Not of all of them though. Looking into the specifics, the API itself is fairly straightforward. At his core, you will find the manifest file, which dictates which files should be cached by the browser. The API provides other useful events and methods for inspecting the status of the cache and swapping the cache for a newer version, but they are out of scope today. A useful resource to read up on the full API can be found here, and a working example implementation can be found here.

The manifest file

Back to the manifest file. A manifest file could look like this.


The first line in the file should say CACHE MANIFEST. If you want to write comments, you should prefix the lines with a number sign.

In the CACHE section you declare which files should be cached. An important and interesting note is that these files will be served from the cache, even if you're online.

In the NETWORK section you declare which files the browser should try to download from the server, regardless of whether the user is online or offline.

In the last section, the FALLBACK section, you can define fallback resources to be used when the user is offline.

Serving and generating the manifest file

Now that we got all this theory out of the way, let's look at generating and serving the manifest file using ASP.NET MVC.

I started by adding a ResourcesController with one action named Manifest.
public class ResourcesController : Controller
{             
    public ActionResult Manifest() { }
}
This action should serve a text file, using a specific cache-manifest MIME type. To accommodate this I created a new action result, which inherits from the FileResult class, and overwrites the content type.
public class ManifestResult : FileResult
{
    public ManifestResult(string version)
        : base("text/cache-manifest") { }    
}
I also made this same class (for the sake of example) responsible for formatting and writing the manifest file to the output stream. That's why I added a few extra properties to the manifest result, one for each section and one for versioning. Versioning the file comes in handy when you want to expire the cache, because it only expires when the manifest file changes.
public class ManifestResult : FileResult
{
    public ManifestResult(string version)
        : base("text/cache-manifest")
    {
        CacheResources = new List<string>();
        NetworkResources = new List<string>();
        FallbackResources = new Dictionary<string, string>();
        Version = version;
    }

    public string Version { get; set; }

    public IEnumerable<string> CacheResources { get; set; }

    public IEnumerable<string> NetworkResources { get; set; }       

    public Dictionary<string, string> FallbackResources { get; set; }        
}
To write the file to the output stream, I had to override the WriteFile method.
protected override void WriteFile(HttpResponseBase response)
{
    WriteManifestHeader(response);            
    WriteCacheResources(response);
    WriteNetwork(response);
    WriteFallback(response);
}

private void WriteManifestHeader(HttpResponseBase response)
{
    response.Output.WriteLine("CACHE MANIFEST");
    response.Output.WriteLine("#V" + Version ?? string.Empty);            
}

private void WriteCacheResources(HttpResponseBase response)
{
    response.Output.WriteLine("CACHE:");           
    foreach (var cacheResource in CacheResources)
        response.Output.WriteLine(cacheResource);
}

private void WriteNetwork(HttpResponseBase response)
{
    response.Output.WriteLine();
    response.Output.WriteLine("NETWORK:");            
    foreach (var networkResource in NetworkResources)
        response.Output.WriteLine(networkResource);
}

private void WriteFallback(HttpResponseBase response)
{
    response.Output.WriteLine();
    response.Output.WriteLine("FALLBACK:");
    foreach (var fallbackResource in FallbackResources)
        response.Output.WriteLine(fallbackResource.Key + " " + fallbackResource.Value);
}
In the CACHE section I wanted to include all my static resources, meaning the contents of the Scripts and Content folder. To do this in a simple and low-maintenace fashion I introduced the GetRelativePathsToRoot method. This method takes the path of a virtual folder, recursively scans its content and returns a list of relative paths for each file.
private IEnumerable<string> GetRelativePathsToRoot(string virtualPath)
{
    var physicalPath = Server.MapPath(virtualPath);
    var absolutePaths = Directory.GetFiles(physicalPath, "*.*",   SearchOption.AllDirectories);

    return absolutePaths.Select(
        x => Url.Content(virtualPath + x.Replace(physicalPath, ""))
    );
}
For the Content folder, the result could look something like this.


To add pages to the CACHE section, I used the Url.Action method.

For the NETWORK resources, I added an asterisk, which basically means that the cache shouldn't be used when the user is online. I didn't specify any fallback resources in this example.
public ActionResult Manifest()
{
    var pages = new List<string>();
    pages.Add(Url.Action("SomeAction", "ControllerName"));    

    var scriptsPaths = GetRelativePathsToRoot("~/Scripts/");
    var contentPaths = GetRelativePathsToRoot("~/Content/");

    var cacheResources = new List<string>();
    cacheResources.AddRange(pages);
    cacheResources.AddRange(contentPaths);
    cacheResources.AddRange(scriptsPaths);
    
    var manifestResult = new ManifestResult("1.0")
    {
        NetworkResources = new string[] { "*" },
        CacheResources = cacheResources
    };            

    return manifestResult;
}
Setting up a route and including the manifest

Now that we are able to generate and serve a manifest file, we should set up a specific route for the manifest file; some browsers aren't very forgiving and expect it to have a specific name and location: /cache.manifest.
routes.MapRoute("cache.manifest", "cache.manifest", new { controller = "Resources", action = "Manifest" });
The last step I had to take was include a reference to the manifest file in the html element.
<html manifest="@Url.RouteUrl("cache.manifest")")/>
Poor man's testing

To verify if all of this works, you can look at the console of the Chrome developer tools. You should see something like this.


That console logging has proven to be extremely useful when debugging the manifest file.

You could also just browse to the manifest file to inspect its content. Don't mind this screenshot too much, obviously there's plenty of cleaning up to do in my Scripts folder.


Summary

In this post I showed you a technique I came up with to take advantage of ASP.NET MVC to easily generate, maintain and serve an HTML5 Offline Webappliction manifest file:
  • Create a controller and action that can serve the file
  • Create a new action result, which returns the correct MIME type and formats the file
  • Set up a specific route
  • Include a reference to the manifest in the html tag

Remember, this is a proof of concept, it's not perfect. I look forward to any feedback you might have!

13 comments:

  1. Would you be interested in writing an educational article on this topic for InfoQ? If so, please contact me at jonathan@infoq.com and we can discuss the details.

    ReplyDelete
  2. This is a very inventive idea, thanks for sharing it!

    I know you said this is only POC, but my main concern is that "cache all the things" could leave mobile users surprised by their data usage.

    IMHO, the purpose of the cache manifest is to instruct the browser which resources are critical for the app to run while offline, not to download the entire backlog of comics for offline review. This is exacerbated by the instruction to ignore all cached resources when online. Will the comics really have gone stale?

    This might be helped by having a /cacheable directory to limit what is downloaded to the client and a /volatile directory to signal what is subject to change (don't use cache). We could also apply some caching to the Manifest action itself to prevent scanning the directories repeatedly on every request.

    I got a little verbose there, so I'll reiterate that I do think this was a good and novel idea and appreciate it. Despite my data usage concerns, I like where it could go.

    ReplyDelete
    Replies
    1. This comment has been removed by the author.

      Delete
    2. To be clear, having /cacheable and /volatile directories was only half-baked from the top of my head. It could very well have its own problems :-p The heart of it for me is to be very deliberate about what you choose to cache.

      Delete
    3. Absolutely, I thought about those things as well. This example needs to be optimized very much depending on the requirements.

      Thanks for your thoughts!

      Delete
  3. Nice idea. I was only wondering, couldn't you combine this with the new bundling and minification? Instead of looping trough all the single script and css files, just point it to the directory.

    ReplyDelete
    Replies
    1. Yep, I don't see any reason why not.

      Just use something like System.Web.Optimization.BundleTable.Bundles.ResolveBundleUrl("~/Content/css") to add the bundle to the resources.

      Delete
  4. Nice article, and explanation.
    Offline Webapplication manifest file is a requirement mostly in all website applications, and this information comes very handy there.
    Thanks for sharing.

    ReplyDelete
  5. Thanks a lot, I used that in my application.

    ReplyDelete
  6. Leave downloadable code would be nice...

    ReplyDelete
  7. Hello, I'm just a starter and this is my code: http://jsfiddle.net/BWM6x/

    Routing and Reference to manifest file are done as you mentioned. Sadly, it doesn't seem to work. These messages are displayed in chrome console:

    - Creating Application Cache with manifest http://localhost:5912/cache.manifest
    - Application Cache Checking event
    - Application Cache Error event: Manifest fetch failed (404) http://localhost:5912/cache.manifest

    I would really appreciate your help. Thanks.

    ReplyDelete
    Replies
    1. Its me, again. Just wanted to say I am using MVC 4 with Razor.

      Delete
    2. Strange.. There must be something wrong with your routing. Can you double-check controller and action etc?

      Delete