Using a Service Worker with Jekyll

This is the third piece in a series of posts about improving my blog built on Jekyll. In this post I’m handling implementation of Service Workers, and enhancing my Google AMP setup further with them.

The Service Worker API is a somewhat new browser feature which gives you a lot more control over how your website behaves while offline or in a bad connection area. The basic premise of a Service Worker is that when someone visits your site for the first time your Service Worker will begin caching the content and assets in the background, in other words installing it on to the device. This means that when they return to your site while offline they are still provided with content. Not every browser supports Service Workers right now, but they are getting increasingly popular.

HTTPS with Github Pages

The Service Worker API requires a valid SSL certificate (HTTPS), and at the time of writing this GitHub only supports HTTPS for pages who don’t utilize their own domain name for their project, which unfortunately excluded me. However after reading several blog posts and issue logs I was able to locate a solution using CloudFlare, a CDN provider I talked about in a previous post of mine. They are able to host DNS for free, and with them you can utilize an SSL certificate to satisfy the browser requirements of HTTPS, allowing you to use the Service Worker API.

Montezuma listening to a video on Service Workers

As a disclaimer this implementation might not be an appropriate solution depending on the type of site you’re maintaining.

Creating the Service Worker

First things first, I created a sw.js file in the root directory of my project and added some boilerplate code provided by Google. The following snippet installs the Service Worker and caches the assets provided in the urlsToCache array.

var urlsToCache = [];

var CACHE_NAME = 'james-ives-cache-v1';

self.addEventListener('install', function(event) {
  // Perform install steps
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

I then added some fetch events into the mix as outlined in the Offline Cookbook by Jake Archibald. My Service Worker will now check to see if there’s a cache available, and if not it will fall back to the network. In addition, if it finds something on the network that doesn’t match the cache, it will attempt to obtain it, add it to the page, and then add it to the cache.

// Cache name: adjust version number to invalidate service worker cachce.
var CACHE_NAME = 'james-ives-cache-v2';

self.addEventListener('install', function(event) {
  // Perform install steps
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open(CACHE_NAME).then(function(cache) {
      return cache.match(event.request).then(function(response) {
        return response || fetch(event.request).then(function(response) {
          cache.put(event.request, response.clone());
          return response;
        });
      });
    })
  );
});

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open(CACHE_NAME).then(function(cache) {
      return fetch(event.request).then(function(response) {
        cache.put(event.request, response.clone());
        return response;
      });
    })
  );
});

The last part of this puzzle is telling the Service Worker what it should be looking for when it begins caching, and that means filling out the urlsToCache array. Fortunately Jekyll allows you to use front-matter inside a JavaScript file, all I had to do was add the following to the top of the file.

---
layout: null
---

Then I was able to loop over all of my posts, pages, and assets.

var urlsToCache = [];

// Cache assets
{% for asset in site.static_files %}
    {% if asset.path contains '/assets/images' or asset.path contains '/assets/posts' or asset.extname == '.js' %}
    urlsToCache.push("{{ file.path }}")
    {% endif %}
{% endfor %}

// Cache posts
{% for post in site.posts %}
  urlsToCache.push("{{ post.url }}")
{% endfor %}

// Cache pages
{% for page in site.html_pages %}
  urlsToCache.push("{{ page.url }}")
{% endfor %}

When the page is now built by Jekyll, it pushes all of my pages assets, post and page paths to the urlsToCache array, meaning my Service Worker is now ready to be deployed.

If your site has a lot of content or assets you might want to limit how much gets cached and instead display an offline fallback. In my case I decided to limit the amount of content that gets cached to my homepage and my three most recent posts. I extended the loops in the previous example to look like the following.

// Cache posts
// Limits the number of posts that gets cached to 3
// Reads a piece of front-matter in each post that directs the second loop to the folder where the assets are held
{% for post in site.posts limit:3 %}
  urlsToCache.push("{{ post.url }}")
  {% for file in site.static_files %}
    {% if file.path contains post.assets %}
      urlsToCache.push("{{ file.path }}")
    {% endif %}
  {% endfor %}
{% endfor %}

// Cache pages
// Do nothing if it's either an AMP page (as these are served via Googles cache) or the blog page
// Fallback to the offline pages for these
{% for page in site.html_pages %}
  {% if page.path contains 'amp-html' or page.path contains 'blog' %}
  {% else if %}
    urlsToCache.push("{{ page.url }}")
  {% endif %}
{% endfor %}

// Cache assets
// Removed assets/posts because I only want assets from the most recent posts getting cached
{% for file in site.static_files %}
    {% if file.extname == '.js' or file.path contains '/assets/images' %}
    urlsToCache.push("{{ file.path }}")
    {% endif %}
{% endfor %}

I then added an offline.html file and populated it with an offline message and provided links to the posts which are available in the cache. Afterwards I updated my sw.js file to fall back to this if it experiences an error in either fetch events.

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open(CACHE_NAME).then(function(cache) {
      return cache.match(event.request).then(function(response) {
        return response || fetch(event.request).then(function(response) {
          cache.put(event.request, response.clone());
          return response;
        });
      });
    }).catch(function() {
      // Fallback to the offline page if not available in the cache.
      return caches.match('/offline.html');
    })
  );
});

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open(CACHE_NAME).then(function(cache) {
      return fetch(event.request).then(function(response) {
        cache.put(event.request, response.clone());
        return response;
      });
    }).catch(function() {
      // Fallback to the offline page if not available in the cache.
      return caches.match('/offline.html');
    })
  );
});

Registering the Service Worker

Now that I’ve built my sw.js file, the next step in the process is to register the Service Worker. Within my page’s header (not the AMP header, I’ll get to that shortly) I added the following snippet which looks for the JavaScript file and then registers it on the device.

<script type="text/javascript">
  if ("serviceWorker" in navigator) {
    navigator.serviceWorker.register("/sw.js").then(function(registration) {
      console.log("Service Worker registration successful with scope: ", registration.scope);
    }).catch(function(err) {
      console.log("Service Worker registration failed: ", err);
    });
  }
</script>

After testing it locally I was able to go to the Application tab of Chrome developer tools and see the Service Worker was registered and running.

Service Worker Registration

I was able to open the Cache Storage tab and see all of the assets I’ve included inside urlsToCache array so I was confident that the Service Worker was working properly!

Manifest JSON

With my Service Worker registering and collecting data, the next thing I did was create a manifest.json file. The manifest works hand-in-hand with the Service Worker, and depending on the type of device and browser it allows you to change the behavior of your site. There are multiple manifest generators out there, I decided to use this one.

Here’s what my manifest.json file looks like.

{
  "name": "James Ives | Full-Stack Developer",
  "short_name": "James Ives",
  "theme_color": "#1c202f",
  "background_color": "#1c202f",
  "display": "browser",
  "Scope": "/",
  "start_url": "/",
  "icons": [
    {
      "src": "https://jamesiv.es/assets/images/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "https://jamesiv.es/assets/images/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "https://jamesiv.es/assets/images/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "https://jamesiv.es/assets/images/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "https://jamesiv.es/assets/images/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "https://jamesiv.es/assets/images/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "https://jamesiv.es/assets/images/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "https://jamesiv.es/assets/images/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "splash_pages": null
}

One of the more notable settings in the manifest file is the display mode. I have mine set to browser, but if set to standalone or fullscreen users will be prompted to add a link to your website to their devices home screen, allowing it to behave like a native app instead of a website, hence the name “Progressive Web App”.

The manifest file needs to be included in your pages header like so.

<link rel="manifest" href="manifest.json">

Amping Up the Service Worker

The last thing I wanted to do was add AMP support for my Service Worker. This allows you to start installing your Service Worker whenever someone starts browsing your page from the Google Cache. This means that if someone visits an AMP article of mine, their subsequent clicks on to other parts of my site both while online and offline will be near instantaneous.

In order to achieve this I created a file called sw.html. This file will act as a form of endpoint for AMP so it knows where to find my Service Worker. Inside this file I added some standard html with a header script which I got from a Google AMP workshop at AMPConf. Essentially this script looks for sw.js and registers it.

<!DOCTYPE html>
<html>
  <head>
    <title>Installing Service Worker</title>
    <script type="text/javascript">
        var swsource = "/sw.js";
        if("serviceWorker" in navigator) {
          navigator.serviceWorker.register(swsource)
            .then(function(reg){
              console.log('SW scope: ', reg.scope);
            })
            .catch(function(err) {
              console.log('SW registration failed: ', err);
            });
        };
    </script>
  </head>
  <body></body>
</html>

Within my AMP header file I added the component JavaScript for the amp-install-serviceworker component and added it just after the opening body tag. Inside the tag I added a direct path to sw.html and sw.js.

<!-- Install Service Worker -->
<amp-install-serviceworker src="/sw.js"
  data-iframe-src="https://jamesiv.es/sw.html"
  layout="nodisplay">
</amp-install-serviceworker>

After testing the AMP page from the Google Cache I was able to determine that the Service Worker was installing properly.

Wrapping Up

After all is said and done I managed to complete all of the goals I set out to do. I now have a working Gulp setup, Google AMP, and a Service Worker that installs via AMP. I even managed to significantly boost my sites performance and a11y rating so it scores close to top marks on performance rating sites and extensions like Page Speed Insights and Lighthouse. I can’t help but feel rather accomplished right now, cheers!

If you have any questions or comments please feel free to reach out to me on Twitter, LinkedIn, or you can send me an email using my contact form.