Rails PWAs using Turbo HHNPWA #7

Does your app even need to be a Progressive Web App? The answer lies in what is missing from your web app.

Would it help to have some information available in the situation where no internet connection is available?

Do you need to be available immediately, just like a native app?

Progressive Web Apps, even if visited through the normal browser chrome, can provide some nice enhancements to your web app’s experience. Thankfully, the minimum configuration to get this experience is very small. If you’ve been following along, you’ll see we started with a regular, performant and interactive Rails app, and only now are we going to turn on the PWA switch.

Setting it up

What makes any web site a progressive web app? Two small pieces tell the browser to treat your web page as a progressive web app. You’ll need a:

  • JSON Manifest
  • Service worker JavaScript file

The browser now grants your app the ability to run a parallel JavaScript process with the ability to inspect and modify all HTTP requests. The biggest advantage of having a service worker is offline access. Offline access stores important information on device, or give your user a small experience of the full online experience. I think the biggest gap in PWAs is that most services require a constant online connection. However, perhaps a Calendar or ToDo service could perform well in this situation. They could keep a cached copy of your events or todos, and then update any changes when the network is available.

Adding the Required Files

There are a number of techniques for making your Rails App a PWA. The biggest challenge with the current asset pipeline / webpacker setup is sending the service worker to the client. The PWA specification sets the root of the service worker at the path that it’s served from. If it’s example.org/service-worker.js, then the service worker gets to proxy all traffic for example.org. However, if it’s example.org/packs/js/service-worker.js, then it can only proxy requests under /packs/js/.

Putting the service worker and manifest file as JSON templates lets you use the most of the Rails stack. You can specify images through the asset pipeline, and you can use routing to prefetch pages that should be available online.

This can be done with a controller called service_worker_controller.rb and two methods, like so:

class ServiceWorkerController < ApplicationController
  protect_from_forgery except: :service_worker

  def service_worker
  end

  def manifest
  end
end

The protect from forgery for the service_worker method allows the javascript to be served without any forgery request problems in rails.


Update the routes file:

get '/service-worker.js' => "service_worker#service_worker"
get '/manifest.json' => "service_worker#manifest"

In the views folder, make a folder called service_worker and add a manifest.json.erb file that looks something like this:

{
"short_name": "HHNWPA",
"name": "HOTWire Hacker News Progressive Web App",
"icons": [
  {
    "src": "<%= asset_path('icon_192.png') %>",
    "type": "image/png",
    "sizes": "192x192"
  },
  {
    "src": "<%= asset_path('icon_512.png') %>",
    "type": "image/png",
    "sizes": "512x512"
  }
],
"start_url": "<%= root_path %>",
"background_color": "#fff",
"display": "standalone",
"scope": "<%= root_path %>",
"theme_color": "#000"
}

Asset paths are available, with means that all the Rails asset pipeline or WebPacker features are available. Those icons referenced above also need to be provided, so if you don’t have them, remove them from the manifest.

Now, add a service-worker.js.erb file that looks something like this:

var CACHE_VERSION = 'v1';
var CACHE_NAME = CACHE_VERSION + ':sw-cache-';

function onInstall(event) {
  console.log('[Serviceworker]', "Installing!", event);
  event.waitUntil(
    caches.open(CACHE_NAME).then(function prefill(cache) {
      return cache.addAll([
        '<%= asset_pack_path 'application.js' %>',
        '<%= asset_pack_path 'application.css' %>',
        '<%= asset_path 'application.css' %>'
      ]);
    })
  );
}

function onActivate(event) {
  console.log('[Serviceworker]', "Activating!", event);
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.filter(function(cacheName) {
          // Return true if you want to remove this cache,
          // but remember that caches are shared across
          // the whole origin
          return cacheName.indexOf(CACHE_VERSION) !== 0;
        }).map(function(cacheName) {
          return caches.delete(cacheName);
        })
      );
    })
  );
}

function onFetch(event) {
  event.respondWith(
    // try to return untouched request from network first
    fetch(event.request).catch(function() {
      // if it fails, try to return request from the cache
      return caches.match(event.request).then(function(response) {
        if (response) {
          return response;
        }
        // if not found in cache, return default offline content for navigate requests
        if (event.request.mode === 'navigate' ||
          (event.request.method === 'GET' &amp;&amp; event.request.headers.get('accept').includes('text/html'))) {
          console.log('[Serviceworker]', "Fetching offline content", event);
          return caches.match('/offline.html');
        }
      })
    })
  );
}

self.addEventListener('install', onInstall);
self.addEventListener('activate', onActivate);
self.addEventListener('fetch', onFetch);

Whatever files need caching are added in the onInstall function. This template only has the webpack pack files and the asset pipeline CSS, but any images, or asset pipeline files that should be cached can be added here.

Loading the service worker with Stimulus

I’ve found a Stimulus controller can load the Service Worker, and it neatly keeps JavaScript out of your other ERB templates. It also provides a cleaner JavaScript communication point between your HTML and the Service Worker.

<div data-controller="service-worker">
  <!-- HTML Code -->
</div> 

It’s weird to say it, since this is the seventh tutorial on this website, but we’ll need to install Stimulus.

rails webpacker:install:stimulus

In the connect() function, the controller sees if the Service worker is running.

If the service worker isn’t running, the controller registers the service worker, and sets up a chain of callbacks, waiting for the installation and activation to complete.

Here is service_worker_controller.js

import { Controller } from "stimulus";
export default class extends Controller {
  connect() {
    if (navigator.serviceWorker) {
      if (navigator.serviceWorker.controller) {
        // If the service worker is already running, skip to state change
        this.stateChange();
      } else {
        // Register the service worker, and wait for it to become active
        navigator.serviceWorker
          .register("/service-worker.js", { scope: "./" })
          .then(function (reg) {
            console.log("[Companion]", "Service worker registered!");
            console.log(reg);
          });
        navigator.serviceWorker.addEventListener(
          "controllerchange",
          this.controllerChange.bind(this)
        );
      }
    }
  }

  controllerChange(event) {
    navigator.serviceWorker.controller.addEventListener(
      "statechange",
      this.stateChange.bind(this)
    );
  }

  stateChange() {
    // perform any visual manipulations here
  }
}

Testing?

Google’s Lighthouse is the best way to test how an app matches the specification. Lighthouse will give recommendations, and list what’s missing. It can even test accessibility. Progressive Web Apps need HTTPS when not being served from localhost, so testing in Google Chrome during development is the easiest option.

Next Steps

Subscribe for updates as this project takes shape. You can see all the code at Github: https://github.com/OnRailsBlog/hhnpwa.

Other HOTWire Tutorials

Leave a Reply