Node, Ghost, and Progressive Web Apps (PWA)

As the fist post on http://codeseo.io, I thought it would be interesting to document the process of setting up Ghost on digital ocean, and transforming it into a Progressive Web App (PWA). It would be interesting, but I am pretty much going to keep this to the parts I found useful. In particular, local device caching and home screen notifications. All the code is here to enable both.

For starters, here is some research that I found helpful:

Progressive Web Apps can be helpful to your website in the following ways:

  • They allow you to fine-tune the management of which assets should be cached locally or grabbed from the network.
  • They allow you to have an "Add to home screen" banner.
  • They also allow you to utilize push notifications.

In this post, I will go through the implementation of caching assets and provide code to make it easy to incorporate into your Ghost site. One particularly important thing to note is that to use service worker functionality, your site HAS to be served over SSL.

Add Service Worker Toolbox

  1. Create a folder in your theme directory/assets/ called dist.
  2. Within that folder, add the code found here.

Add Caching Scripts

  1. Create a file at the root of your theme called serviceworker-v1.js
  2. Include the following code in that file:

'use strict';

(function () {
  'use strict';



	/**
	* Service Worker Toolbox caching
	*/

	var cacheVersion = '-toolbox-v1';
	var dynamicVendorCacheName = 'dynamic-vendor' + cacheVersion;
	var staticVendorCacheName = 'static-vendor' + cacheVersion;
	var staticAssetsCacheName = 'static-assets' + cacheVersion;
	var contentCacheName = 'content' + cacheVersion;
	var maxEntries = 50;

	self.importScripts('assets/dist/sw-toolbox.js');

	self.toolbox.options.debug = false;

	// Cache own static assets
	self.toolbox.router.get('/assets/(.*)', self.toolbox.cacheFirst, {
		cache: {
		  name: staticAssetsCacheName,
		  maxEntries: maxEntries
		}
	});

	// cache dynamic vendor assets, things which have no other update mechanism like filename change/version hash
	self.toolbox.router.get('/css', self.toolbox.fastest, {
		origin: /fonts\.googleapis\.com/,
			cache: {
			  name: dynamicVendorCacheName,
			  maxEntries: maxEntries
			}
	});

	// Do not cache disqus
	self.toolbox.router.get('/(.*)', self.toolbox.networkOnly, {
		origin: /disqus\.com/
	});
	self.toolbox.router.get('/(.*)', self.toolbox.networkOnly, {
		origin: /disquscdn\.com/
	});


	// Cache all static vendor assets, e.g. fonts whose version is bind to the according url
	self.toolbox.router.get('/(.*)', self.toolbox.cacheFirst, {
		origin: /(fonts\.gstatic\.com|www\.google-analytics\.com)/,
		cache: {
		  name: staticVendorCacheName,
		  maxEntries: maxEntries
		}
	});

	self.toolbox.router.get('/content/(.*)', self.toolbox.fastest, {
		cache: {
		  name: contentCacheName,
		  maxEntries: maxEntries
		}
	});

	self.toolbox.router.get('/*', function (request, values, options) {
		if (!request.url.match(/(\/ghost\/|\/page\/)/) && request.headers.get('accept').includes('text/html')) {
			return self.toolbox.fastest(request, values, options);
		} else {
			return self.toolbox.networkOnly(request, values, options);
		}
		}, {
		cache: {
			name: contentCacheName,
			maxEntries: maxEntries
		}
	});

	// immediately activate this serviceworker
	self.addEventListener('install', function (event) {
		return event.waitUntil(self.skipWaiting());
	});

	self.addEventListener('activate', function (event) {
		return event.waitUntil(self.clients.claim());
	});	

})();
//# sourceMappingURL=serviceworker-v1.js.map

Notice how the service worker javascript uses Express-style code to target particular assets:

	// Cache own static assets
	self.toolbox.router.get('/assets/(.*)', self.toolbox.cacheFirst, {
		cache: {
		  name: staticAssetsCacheName,
		  maxEntries: maxEntries
		}
	});
  • cacheFirst: If the request matches a cache entry, respond with that. Otherwise try to fetch the resource from the network. If the network request succeeds, update the cache. This option is good for resources that don't change, or have some other update mechanism.
  • fastest: Request the resource from both the cache and the network in parallel. Respond with whichever returns first. Usually this will be the cached version, if there is one. On the one hand this strategy will always make a network request, even if the resource is cached. On the other hand, if/when the network request completes the cache is updated, so that future cache reads will be more up-to-date.
  • networkFirst: Try to handle the request by fetching from the network. If it succeeds, store the response in the cache. Otherwise, try to fulfill the request from the cache. This is the strategy to use for basic read-through caching. It's also good for API requests where you always want the freshest data when it is available but would rather have stale data than no data.
  • cacheOnly and networkOnly: Do what they sound like.

Since we don't want to cache comment information, we can specify that requests to files that originate from disqus.com and disquscdn.com are always network only:

	// Do not cache disqus
	self.toolbox.router.get('/(.*)', self.toolbox.networkOnly, {
		origin: /disqus\.com/
	});
	self.toolbox.router.get('/(.*)', self.toolbox.networkOnly, {
		origin: /disquscdn\.com/
	});

And we can cache Google fonts and Analytics:

	// Cache all static vendor assets, e.g. fonts whose version is bind to the according url
	self.toolbox.router.get('/(.*)', self.toolbox.cacheFirst, {
		origin: /(fonts\.gstatic\.com|www\.google-analytics\.com)/,
		cache: {
			name: staticVendorCacheName,
			maxEntries: maxEntries
		}
	});

Finally Making It Live

You just need to add the following code into an included javascript file or into your pages via <script> tags.


var serviceWorkerUri = '/serviceworker-v1.js';

if ('serviceWorker' in navigator) {
	navigator.serviceWorker.register(serviceWorkerUri).then(function() {
	  
	  // Registration was successful. Now, check to see whether the service worker is controlling the page.
	  if (navigator.serviceWorker.controller) {
		
		console.log('Assets cached by the controlling service worker.');
		  
	  } else {
		
		console.log('Please reload this page to allow the service worker to handle network operations.');
		
	  }
	}).catch(function(error) {
	  
	  console.log('ERROR: ' + error);
	  
	});
	
} else {
	
	// The current browser doesn't support service workers.
	console.log('Service workers are not supported in the current browser.');
	
}

Open up your site in Chrome and right-click on Inspect. There is a tab located here:

Service Worker Tab in Chrome

that will show if it is working. Also, you can debug for error messages via the console.

Finally, Getting the 'Add To Home Screen'

Google says that the following are necessary to get the Add to home screen banner.

  • Has a web app manifest file with:
    • a short_name (used on the home screen)
    • a name (used in the banner)
    • a 144x144 png icon (the icon declarations must include a mime type of image/png)
    • a start_url that loads
  • Has a service worker registered on your site.
  • Is served over HTTPS (a requirement for using Service Worker).
  • Is visited at least twice, with at least five minutes between visits.

Here is my manifest file (manifest.json) (it should go at the root of your theme to be served static):


{
  "short_name": "CodeSEO",
  "name": "CodeSEO: Hackers, coders, and developers in SEO",
  "icons": [
    {
      "src": "/assets/images/launcher-icon-codeseo-144.png",
      "sizes": "144x144",
      "type": "image/png"
    }
  ],
  "start_url": "/",
  "display": "standalone",
  "orientation": "portrait"
}

And you should add the following code into the <head> of your webpage.
<link rel="manifest" href="/manifest.json">

I hope this was helpful. Please add any suggestions or comments in the comment section below.