case study: taking a webapp offline

Overview

I recently created an AngularJS-based framework and webapp for a client. The main requirement for this app was that the user be able to have a workflow like this:

  1. while at work, log in against a server
  2. download documents to fill out
  3. quit the browser
  4. relaunch the browser in an area with no internet connectivity
  5. open the webapp
  6. authenticate (i.e. offline authentication)
  7. fill out the downloaded documents
  8. save the filled out documents locally
  9. quit the browser
  10. relaunch the browser when back in the office
  11. log in, select the completed documents, and upload to the server

Quite a bit to consider, especially that offline authentication. Oh, and the saved data needs to encrypted, as well.

With permission, I targeted newer browsers (read as, “Chrome”) and deferred compatibility with older browsers. This made it easier to focus on newer, emerging standards. I chose IndexedDB for storage, and application cache for the app’s assets.

IndexedDB

IndexedDB is an object store that offers large amounts of storage, is durable (i.e. the app and/or user can delete it; the browser itself doesn’t delete it “for you”), and is an emerging standard vs. the now-deprecated WebSQL. One downside is that Safari is sticking with WebSQL for now. By working as an object store, saves are done as a key/value pair, where the value is typically a JSON object.

By creating an Angular service, Storage, to wrap IndexedDB, I was able to define a simple api that served my needs. (Side note: It also allowed me to create two additional Storage objects, with the same api but very different implementations, to target other deployment environments). This api includes basic CRUD operations, plus the ability to:

  • select a value by specifying its key
  • get a list of all objects in a store
  • get a list of all matching objects in a store, where a reference field name and a matching value are specified
  • specify a comparison function to support matching

As an example, imagine a school tracking grades for students. One object store could be called “students”, and another called “grades”. Let’s assume each student has an id. If each JSON object for a grade included that student id, given that student id, we could select all the grades objects that matched the student.

Building on that, I created a service called CryptoStorage. This service has the same api as Storage, and decorates it by encrypting data on the way in, and decrypting it on the way out. By supplying a comparison function that decrypts data prior to comparison, it allows the Storage service to work as-is.

As a nice side-effect, Angular allows me to easily enable/disable the crypto decorator. By protecting the Angular code from minification like this:

.service('Students', ['CryptoStorage', '$q', function (Storage, $q) {

I can reference ‘Storage’ throughout the code using the service, and need change only ‘CryptoStorage’ to ‘Storage’ to skip the encryption and make it easy to see the contents of the IndexedDB resource in the browser.

Offline Authentication

For offline auth, the requirement was that the user be able to authenticate online prior to authentication offline. Upon a successful online auth, I simply wrote to a user object to a dedicated object store in the IndexedDB going through the Crypto layer. A hash of the user’s password is used as the encryption secret, so to successfully decrypt, the user must enter the same password (and, thus, getting the same hashed value). Google’s CryptoJS library, which is what I used for hashing and encryption, throws an error if encryption fails. Therefore, I could use the very success and failure of that process to determine if the auth was considered a success. This saved me from having to compare the result to a known value, as having that known value introduced other complexity and security issues.

So what constitutes a webapp being online vs offline? One can query window.navigator.onLine, and add listeners for the ‘online’ and ‘offline’ events, but different browsers handle this differently. For example, Chrome looks for a network connection (which has its own issues; one can have a network connection, but not have access to the internet), while Firefox is offline only when the user puts the browser into an offline mode. To solve this, when the user enters their credentials and tries to log in, I try to connect to the authentication service, as if we’re connected. If that fails because the GET fails, then I try an offline auth. Once the online/offline-ness is established, the app stays in that mode until logout.

Application Cache

The HTML5 application cache works via a manifest. This manifest lists all the assets (views, JS, css, images, et. al.) needed by the webapp to function offline, and ensures those assets are cached by the browser when it is online. This works as advertised, but I needed to ensure:

  • the correct ‘dist’ JS files were specified
  • the manifest’s revision was updated each time the codebase changed

I am using a grunt process to build the app, and that includes a fairly-standard ‘dist’ target, which includes concatenation, minification, and rev’ving the names of the concatenated files. I used a grunt plugin, grunt-appcache, to generate that manifest file. By running the appcache rule after other rules, and specifying the JS files via a descendent search (‘<%= yeoman.dist %>/scripts/**/*.js’), the manifest not only had the correct files, but the plugin automatically revs the CACHE MANIFEST section when the build process produces new generated files.

Once the browser has the webapp cached properly, and the manifest correctly constructed, the webapp will fully load even when the webserver is unavailable.

Note: by default, yeoman adds a cdnify rule to its dist target. This rule will see what JS libraries are included in the app, like jQuery, find CDN replacements, and update the index.html with those CDN urls. This will not work in offline mode, so I simply removed the cndify rule from the dist target.

Detecting Browser Capabilities

Though older/incapable browsers are not supported, it was still important to be able to detect that lack of capability and handle it gracefully. Though caniuse.com is a great tool for determining browser compatibility, it does not work terribly well at runtime. I used the excellent Modernizr, instead of user-agent sniffing, to ask the browser if it supported IndexedDB and application cache. Of course, I wrapped it in an Angular service, and integrated it into Angular’s route provider. If the test fails, the app can route the user to a page explaining the lack of capability.

Summary

Together, the approaches above worked well and, for capable browsers, supported the use case described in the overview. Not-so-new browsers that were incapable, like Safari and IE9, recovered gracefully to the explanation page. Older browsers, like IE6, didn’t fare so well with Modernizr.

One improvement could be to use a WebSQL shim for Safari, but that path was unexplored.

There are no comments.

Leave a Reply