App Manifest
To implement the offline manifest, I used a controller with a custom content Result
1: public ActionResult OfflineManifest ()
2: {
3: var contentFiles = Util.GetFilesRecursive(Request.PhysicalApplicationPath + "Content", "*.*");
4: StringBuilder contentFilesNotTest = new StringBuilder ();
5: foreach (string filePath in contentFiles)
6: {
7: if (!filePath.Contains("Content\\Test") && !filePath.Contains("\\.svn") && !filePath.Contains ("nocache"))
8: contentFilesNotTest.Append (filePath.Replace (Request.PhysicalApplicationPath, Util.GetAbsoluteBaseURL (Request) + "/" ).Replace ('\\', '/') + Environment.NewLine);
9: }
10: StringBuilder file = new StringBuilder("CACHE MANIFEST" + Environment.NewLine);
11: file.AppendLine("#REV: " + Util.ApplicationRev ());
12: file.AppendLine(Util.GetAbsoluteBaseURL(Request) + "/singlejs.js" + Environment.NewLine);
13: file.AppendLine(contentFilesNotTest.ToString ());
14: file.AppendLine("NETWORK:");//This is the dynamic section of what shouldn't be cached
15: file.AppendLine("*");
16: return new ManifestResult() { Content = file.ToString() };
17: }
18: ....
19: public class ManifestResult : ContentResult
20: {
21: public ManifestResult () : base() { ContentType = "text/cache-manifest"; }
22: public override void ExecuteResult (ControllerContext context)
23: {
24: context.HttpContext.Response.Cache.SetCacheability(HttpCacheability.NoCache);
25: base.ExecuteResult(context);
26: }
27: }
A couple of things to note. First, I have all of my assets in one folder and there is a statement that loops through all files to get their paths. Also, notice that I have an application revision for the manifest. I use this to trigger an update on the client, and this rev is auto incremented through my build process. I use a basic Nant task that increments a minor rev number in my web config with each cruise control build. I have another call that combines all my js into one file and uses Google Closure to minify it for each build. More on that later. Finally I have a catch-all for files I don't want to cache, like things that contain info about the logged in user. You don't want to cache that, because if someone else logs in on that browser funny things will happen because the old user is cached. One big gripe I have about HTML5 app cache is the inability to purge the cache via api. Big oversight. Finally, with the ManifestResult I set the ContentType and force this file to not get cached. In general I turn off all HTML caching because having 2 caching mechanisms just causes massive headaches.
JS Compile and Minify
1: public ContentResult SingleJS ()
2: {
3: bool isLocal = System.Web.HttpContext.Current.Request.Url.Host.ToLower() == "localhost";
4: string content = !isLocal? CheckAndCreateCompressedJS (): null;
5: if (content == null)//error creating compressed file
6: content = BuildSingleJS().ToString ();
7: var result = new ContentResult ();
8: result.ContentType = "text/javascript";
9: result.Content = content;
10: return result;
11: }
12: private static string CheckAndCreateCompressedJS ()
13: {
14: try
15: {
16: var directory = Path.Combine(System.Web.HttpContext.Current.Request.PhysicalApplicationPath, "scripts");
17: var compressedJSPath = Path.Combine(directory, "single_compressed_" + Util.ApplicationRev() + ".js");
18: if (!System.IO.File.Exists(compressedJSPath))
19: {
20: var filesToDelete = Directory.GetFiles(directory, "single_compressed_*");
21: foreach (var fileToDelete in filesToDelete)
22: System.IO.File.Delete(fileToDelete);
23: var wc = new WebClient();
24: var nvc = new NameValueCollection();
25: nvc.Add("js_code", Html5Controller.BuildSingleJS().ToString());
26: nvc.Add("compilation_level", "SIMPLE_OPTIMIZATIONS");
27: nvc.Add("output_format", "text");
28: nvc.Add("output_info", "compiled_code");
29: var responseBytes = wc.UploadValues("http://closure-compiler.appspot.com/compile", "POST", nvc);
30: if (responseBytes == null || responseBytes.Length / 1024 < 100)//basic sanity check. We got data and its more than 100K.
31: {
32: Util.LogError("Issue talking to google closure service");
33: return null;
34: }
35: using (var fileToCreate = System.IO.File.Create(compressedJSPath))
36: {
37: fileToCreate.Write(responseBytes, 0, responseBytes.Length);
38: }
39: }
40: using (var fileOnDisk = System.IO.File.OpenText(compressedJSPath))
41: {
42: return fileOnDisk.ReadToEnd ();
43: }
44: }
45: catch (Exception ex)
46: {
47: Util.LogError("Could not create compressed js", ex);
48: return null;
49: }
50: }
51: private static StringBuilder BuildSingleJS ()
52: {
53: var sb = new StringBuilder();
54: var jsFiles = Util.GetFilesRecursive(System.IO.Path.Combine(System.Web.HttpContext.Current.Request.PhysicalApplicationPath, "scripts"), "*.js");
55: jsFiles.Sort();
56: foreach (var jsFile in jsFiles)
57: {
58: if (!jsFile.Contains("uncompressed") && !jsFile.Contains("exclude-from-single"))
59: {
60: sb.AppendLine();
61: sb.AppendLine("// " + jsFile);
62: sb.AppendLine();
63: sb.AppendLine(System.IO.File.ReadAllText(jsFile));
64: }
65: }
66: return sb;
67: }
First let me point to SingleJs (). This method will return a single js file, either compressed or not depending on the environment. GetSingleJS () will cycle through my js directory and append all the files together. This is used by CheckAndCreateCompressedJS () to get the combined js and minify using Closure. Its a little ugly that all this is hardcoded, but it doesn't really bother me. After all that the file is saved to disk via the Application Rev number mentioned earlier. This means the first hit to the js file will be slow, but disk cached after that. Not a huge penalty, because we only update the app 1 or 2 times a month. Finally, I have a cheesy check that the response we got from Google was the actual file contents. They do return a formatted list of errors and whatnot, but this was simple and effective.
One nice little feature I implemented to help with debugging, is to stick a value in the query string to spit out all js files to the page, instead of the single js. This can be a big help when troubleshooting.
Other Optimizations
Another helpful trick we used was to implement a lot of the images using sprites. When dealing with mobile browsers you might be lucky to get 2 concurrent web threads to download material. So the less files the better.
I'm in the process of moving data calls to a simple "session caching" mechanism. All data calls are driven through a few classes, and those classes will determine whether data has already been queried. I'm lucky that I can limit the parameters to a date range or list of ids. Eventually the cache manager will move to store data in more permanent cache, so the user can drop their connection and still use the app in a "read only" state. I'd also like to move the app to a CDN, but I'm not sure how to deal with the app manifest potentially changing urls.





