In which we play around with mobile.
I’ve been playing around with mobile websites for the last two months — learning about app caches and localStorage, hacking around with jQuery mobile, discovering the limits of mobile devices. It’s a huge, complicated, fascinating wilderness, and along my way I’ve come up with or stumbled upon a few solutions that might be useful anyone else heading the same direction.
Let’s start with the app cache and Rack::Offline, one of my first forays into mobile.
How the App Cache works
Anything I could write would just be a rephrasing of Yehuda Katz’s description in the Rack::Offline readme:
The App Cache allows you to specify that the browser should cache certain files, and ensure that the user can access them even if the device is offline.
[snip]
In short, the App Cache is a much stickier, atomic cache. After storing an App Cache, the browser takes the following (simplified) steps in subsequent requests:
- Immediately serve the HTML file and its assets from the App Cache. This happens whether or not the device is online
- If the device is offline, treat any resources not specified in the App Cacheas 404s. This means that images will appear broken, for instance, unless you make sure to include them in the App Cache.
- Asynchronously try to download the file specified in the manifest attribute
- If it successfully downloads the file, compare the manifest byte-for-byte with the stored manifest.
- If it is identical, do nothing.
- If it is not identical, download (again, asynchronously), all assets specified in the manifest.
Step #4 is the critical step for today’s post. After loading content from a previously-stored manifest, the browsers checks to see if the old manifest is identical to the new one downloaded from the server; if not, it updates all the files in the cache.
The Problem
Critically, this comparison doesn’t just happen with locally-stored manifests — after the browser downloads an updated cache, it downloads the manifest a second time to verify nothing’s changed. If the manifest was updated during the download, the whole process starts over. If the manifest is changed every time, the app cache never works.
This is exactly the problem in Rack::Offline’s “uncached” mode (on by default when using Rails::Offline in development mode). To keep the cache fresh, the middleware calculates a new hash each time:
Digest::SHA2.hexdigest(Time.now.to_s + Time.now.usec.to_s)
Since the browser downloads the cache twice (once at the beginning, and once at the end for verification), the use of usec (microseconds) means the manifest will always be different, meaning you have no app cache.
(In cached mode, by contrast, the gem calculates a hash of the static files in the manifest, ensuring that the cache is reusable until files change. If you only want to provide static files offline, you can avoid this problem by initializing Rack::Offline with :cache => true; if you want to serve dynamic assets, such as Rails-generated pages, though, this won’t work for you. See note at the bottom.)
The Solution
Fortunately, it’s fairly straightforward to patch this behavior, as shown in the embedded gist below (look at the uncached_key method in particular). The cache-breaking comment in the manifest will now will only change after a configurable interval (by default 10 seconds) to allow the browser to download the cache first. There’s always a risk that the download will start too close to the boundary, but since this is development only, you can lengthen or shorten the period as you want by passing :cache_interval => your_interval when initializing your Rack::Offline block.
I’ve submitted this to the Rack::Offline project as a pull request, so hopefully soon this behavior will be built-in. In the meanwhile, you can throw this into your initializers folder.
If you’ve been working with mobile and offline content, I’d be glad to hear from you — it’s a fascinating subject, and one I’m just starting on.
require "rack/offline/config"
require "rack/offline/version"
require "digest/sha2"
require "logger"
require "pathname"
require 'uri'
module Rack
class Offline
def self.configure(*args, &block)
new(*args, &block)
end
# interval in seconds used to compute the cache key when in uncached mode
# which can be set by passing in options[:cache_interval]
# note: setting it to 0 or a low value will change the cache key every request
# which means the manifest will never successfully download
# (since it gets downloaded again at the end)
UNCACHED_KEY_INTERVAL = 10
def initialize(options = {}, &block)
@cache = options[:cache]
@logger = options[:logger] || begin
::Logger.new(STDOUT).tap {|logger| logger.level = 1 }
end
@root = Pathname.new(options[:root] || Dir.pwd)
if block_given?
@config = Rack::Offline::Config.new(@root, &block)
end
if @cache
raise "In order to run Rack::Offline in cached mode, " \
"you need to supply a root so Rack::Offline can " \
"calculate a hash of the files." unless @root
precache_key!
else
@cache_interval = (options[:cache_interval] || UNCACHED_KEY_INTERVAL).to_i
end
end
def call(env)
key = @key || uncached_key
body = ["CACHE MANIFEST"]
body << "# #{key}"
@config.cache.each do |item|
body << URI.escape(item.to_s)
end
unless @config.network.empty?
body << "" << "NETWORK:"
@config.network.each do |item|
body << URI.escape(item.to_s)
end
end
unless @config.fallback.empty?
body << "" << "FALLBACK:"
@config.fallback.each do |namespace, url|
body << "#{namespace} #{URI.escape(url.to_s)}"
end
end
@logger.debug body.join("\n")
[200, {"Content-Type" => "text/cache-manifest"}, body.join("\n")]
end
private
def precache_key!
hash = @config.cache.map do |item|
Digest::SHA2.hexdigest(@root.join(item).read)
end
@key = Digest::SHA2.hexdigest(hash.join)
end
def uncached_key
now = Time.now.to_i - Time.now.to_i % @cache_interval
Digest::SHA2.hexdigest(now.to_s)
end
end
end
Promised side note: it may seem contradictory to serve dynamically-generated assets in an offline cache; it may even be so. I’m not sure yet. How to serve a dynamic, interesting mobile site offline, especially one using jQuery Mobile, seems to be a big blank area in the map — one I’m excited to explore
Also: there are some issues caching Rails pages in Rack::Offline — something I’ll cover in another post / pull request.