DEV Community

Alessandro Rodi
Alessandro Rodi

Posted on • Edited on

The Progressive Rails App

A step by step tutorial to create a Progressive Web App with Ruby on Rails and Webpacker

If you Google for this topic you will find different tutorials on how to realise a Progressive Web App with Rails. I was really not happy with any of the solutions I found so far: I wanted a solution purely based on Webpack. 

In this article, I will suggest to you how to realise a Progressive Web App (PWA) using the latest Webpacker and the latest Rails release. 

This technique uses just a simple npm package and supports any Modern Rails Application and therefore we will define a new concept: "The Progressive Rails App" (PRA). 
The solution I provide does not perform any workaround by serving the service workers through a controller or by skipping the Webpack pipeline.

If you already know what a PWA is, you can skip directly to Getting Started, otherwise, the next chapter is meant for you.

What is a PWA (for Rails developers)

There are some points that must be fulfilled, in order to consider our application a PWA. Once all these points are respected, the users will be able to install the app on their mobile devices as a standard app. In addition, it will be soon possible to publish your PWA on the Play Store or install it on Desktop.

Let's see these points one by one, from the Rails developer point of view:

It loads instantly, also offline

A service worker must be registered in our application so that it can be loaded also in the absence of an internet connection. This tutorial focuses a lot on how to configure a Service Worker for our Rails app, and we'll do it using Webpacker.

Site is served over HTTPS

For a Rails developer, it simply means to have config.force_ssl = true inside the production.rb configuration. Your website will be served over https, and this requirement will be fulfilled.

Responsive design

Many frontend libraries are available to simplify the design of a responsive application. In a previous article, I explained why you should get rid of Sprockets and use Webpack also for CSS and other static resources. If you did that, you can simply use an npm package to install Bootstrap or Bulma to help you designing your frontend.

manifest.json

This file, that you'll have to place inside the public folder, will contain all the information necessary to install the app on the device of your users.

Getting Started

Start a brand new Rails app (or use you existing one):



rails new progressive-rails-app --skip-sprockets


Enter fullscreen mode Exit fullscreen mode

and enable HTTPS for production in your config/environments/production.rb file.
This is important because one of the requirements for a PWA is that it runs on https.
If you are starting from scratch, here are some commands to create some content. We add a simple CRUD for Posts, to start testing our application:



bundle exec rails g scaffold Post title:string content:text
bundle exec rails db:migrate


Enter fullscreen mode Exit fullscreen mode

And point the root page to hit:



root 'posts#index'


Enter fullscreen mode Exit fullscreen mode

You can also add some seeds:



5.times do |i|
  Post.create(title: "Post #{i}", content: 'A lot of stuff')
end


Enter fullscreen mode Exit fullscreen mode

Now that we have some data and a working application, we can start adding what it takes for it to be considered a PWA.

manifest.json

If you debug your app from the Google Chrome Console you'll see that your app doesn't contain a Manifest

Brand new Rails app without any manifest.json

Let's create a Manifest file. This is the starting point of every PWA.

Add the following to your application.html.erb template:



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


Enter fullscreen mode Exit fullscreen mode

And create a manifest.json in the public folder of the project. The manifest should contain all the minimal entries to avoid warnings in the browser. Here is an example:



# manifest.json example
{
  "short_name": "PWA",
  "name": "Progressive Web App",
  "icons": [
    {
      "src": "/icons/android-chrome-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/android-chrome-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "start_url": "/",
  "background_color": "#3367D6",
  "display": "standalone",
  "scope": "/",
  "theme_color": "#3367D6"
}


Enter fullscreen mode Exit fullscreen mode

You can generate all the icons you need from https://www.favicon-generator.org/ and place them in a public/icons folder.

Adapting the Webpacker configuration

At the time of writing this article, the provided, default, configuration of Webpacker doesn't allow us to write a Progressive Web App

The main reason is that Service Workers should reside in the public folder, while all the resources are compiled inside the public/packs folder. 

We need to customize the Webpacker configuration in order to:

  • keep compiling all resources in the public/packs folder;
  • compile Service Workers in the public folder without appending an hash at the end of the name;
  • make webpack-dev-server listen to the whole public folder to Hot Reload your content also when Service Workers change.

I adapted the @rails/webpacker configuration and published it as an npm package. This package will allow you to use all the power of Webpack and advanced features like Hot Module Reloading, while developing your Progressive Rails App. It tackles exactly the points listed above.

Install the webpacker-pwa npm package with yarn add webpacker-pwa and change config/webpack/environment.js to the following:



const { resolve } = require('path');
const { config, environment, Environment } = require('@rails/webpacker');
const WebpackerPwa = require('webpacker-pwa');
new WebpackerPwa(config, environment);
module.exports = environment;


Enter fullscreen mode Exit fullscreen mode

and add the following to webpacker.json:



service_workers_entry_path: service_workers


Enter fullscreen mode Exit fullscreen mode

service_workers is the folder where we will write our service worker(s). 
If you configured it correctly, when running bin/webpack you should see:



webpacker-pwa is configured but no service workers are available.


Enter fullscreen mode Exit fullscreen mode

The first Service Worker

Create a service-worker.js in the app/javascript/service_workers folder with the following debug content:



self.addEventListener('install', function(event) {
    console.log('Service Worker installing.');
});

self.addEventListener('activate', function(event) {
    console.log('Service Worker activated.');
});
self.addEventListener('fetch', function(event) {
    console.log('Service Worker fetching.');
});


Enter fullscreen mode Exit fullscreen mode

and register it in application.js with:



window.addEventListener('load', () => {
  navigator.serviceWorker.register('/service-worker.js').then(registration => {
    console.log('ServiceWorker registered: ', registration);

    var serviceWorker;
    if (registration.installing) {
      serviceWorker = registration.installing;
      console.log('Service worker installing.');
    } else if (registration.waiting) {
      serviceWorker = registration.waiting;
      console.log('Service worker installed & waiting.');
    } else if (registration.active) {
      serviceWorker = registration.active;
      console.log('Service worker active.');
    }
  }).catch(registrationError => {
    console.log('Service worker registration failed: ', registrationError);
  });
});


Enter fullscreen mode Exit fullscreen mode

this will allow us to see if the service worker is working correctly.

Note: do not use webpack-dev-server for now. Is not yet working, but we will get there!

Note 2: add public/service-worker.js* to .gitignore. You shouldn't push this file directly. It's compiled by webpack.

If you did everything correctly you will see the following in the Chrome DevTools console:
Service Worker installed

And on the Chrome console, you should see:



ServiceWorker registered:  
Service Worker installing.
Service worker installing.
Service Worker activating.


Enter fullscreen mode Exit fullscreen mode

Now, when we deploy our application, and SSL is available, you should see the install icon on the browser.
Install icon on Chrome Browser

If you want to test this locally, you can use a service like ngrok.com, just run ngrok http 3000 on the root folder of your project.

If you receive a "Blocked host" error, you can disable this check temporarily by setting config.hosts = nil inside config/environments/development.rb.

Your Progressive Rails App

And that's it! You have your first Service Worker running and your app is now officially a Progressive Rails App! 🎉

Installed app

This looks already pretty cool since it allows you to install the application on your device

From now on, you can follow any Progressive Web App tutorial to start implementing your services, and if you are already experienced with Service Workers, you can start coding as you are used to. But here I will give you some more details about how to get the maximum out of your Progressive Rails App and implement a couple of features that you might need most of the times.

Push Notifications

That's why you are here, right? You want to send push notifications to your users as well, right? You also want your app to show the super annoying message "Wants to send you push notifications" and start annoying your customers, right? Let's do it!

Ask for permission

Start by asking for permissions, without this, you cannot send any notification. You should do that after registering your service worker, so change the code we wrote in application.js as follows:



navigator.serviceWorker.register('/service-worker.js').then(registration => {
  console.log('ServiceWorker registered: ', registration);

  // all code from before

  window.Notification.requestPermission().then(permission => {    
    if(permission !== 'granted'){
      throw new Error('Permission not granted for Notification');
    }
  });
});


Enter fullscreen mode Exit fullscreen mode

You should handle all the cases properly, but this is out of scope for this tutorial, please refer to this one for example, to get some more details about how/when to ask for permissions and how to manage all possible responses from the user.

Ask for permission

Also our app can annoy users with Push Notifications.

Click "Allow" to give notifications permissions and if you refresh the page, it won't ask you anymore, since it already obtained permission before.

Subscribe to notification service

You need to generate a pair of public/private keys to send notifications. Again follow the article linked above for the details about why.



yarn global add web-push
web-push generate-vapid-keys


Enter fullscreen mode Exit fullscreen mode

Now change our service-worker.js to subscribe to the push manager and react to push notifications:



function urlB64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/\-/g, '+')
    .replace(/_/g, '/');

  const rawData = atob(base64);
  const outputArray = new Uint8Array(rawData.length);

  for (var i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

self.addEventListener('install', function(event) {
  console.log('Service Worker installing.');
});

self.addEventListener('activate', async function(event) {
  console.log('Service Worker activated.');
  try {
    const applicationServerKey = urlB64ToUint8Array('<YOUR_PUBLIC_KEY_HERE>')
    const options = { applicationServerKey, userVisibleOnly: true }
    const subscription = await self.registration.pushManager.subscribe(options)
    console.log(JSON.stringify(subscription))
  } catch (err) {
    console.log('Error', err)
  }
});
self.addEventListener('fetch', function(event) {
  console.log('Service Worker fetching.');
});
self.addEventListener('push', function(event) {
  console.log('[Service Worker] Push Received.');
  console.log(`[Service Worker] Push had this data: "${event.data.text()}"`);

  const title = 'A nice title';
  const options = {
    body: event.data.text(),
    icon: 'images/icon.png',
    badge: 'images/badge.png'
  };

  event.waitUntil(self.registration.showNotification(title, options));
});


Enter fullscreen mode Exit fullscreen mode

Every time you change your service worker you need to manually unregister it from the Chrome Application tab in Developers' tools, and refresh the page.

You should see in the Browser console:



{ "endpoint":"https://fcm.googleapis.com/fcm/send/...",
  "expirationTime":null,
  "keys":{
    "p256dh":"...",
    "auth":"..." }
}


Enter fullscreen mode Exit fullscreen mode

Save them. You'll need them later.

From the DevTools, from the same window where you unregister your service worker, you can now send a test push notification.

Send test push notification

Send notifications from ruby

In short, that's easy. Add the webpush gem and use the following:



require 'webpush'

Webpush.payload_send(
    message: 'Hello from ruby',
    endpoint: <ENDPOINT-HERE>,
    p256dh: <P256DH-HERE>,
    auth: <AUTH-HERE>,
    vapid: {
        subject: 'Hello from ruby',
        public_key: <PUBLIC-KEY>,
        private_key: <PRIVATE-KEY>
    }
)


Enter fullscreen mode Exit fullscreen mode

You can simply save this as notifications.rb and run it with ruby notifications.rb to make a test.

This means that when you subscribe on the frontend, you need to save the endpoint and all other information in the backend, and associate them (maybe) with your users.

Offline mode

When internet is not available we want to still display something to the user. Let's see how to do that. I will cover a simple offline page with an image, after that is up to you. This is the article where I took inspiration from.

Start by creating an offline page:



get 'offline', to: 'home#offline', as: :offline


Enter fullscreen mode Exit fullscreen mode


class HomeController < ApplicationController
  def offline
    render 'offline', layout: false
  end
end


Enter fullscreen mode Exit fullscreen mode

we render a very simple view, without using the layout used for the rest of the application.



html
  head    
    css:
      body {
        background-color: #00a4cd;
        color: #ffffff;
        padding: 4rem 2rem;
      }

      .text-center {
        text-align: center;
      }
  body
    .text-center
      = image_pack_tag 'logo_white.svg'
    h1.text-center
      ' You need a working internet connection to use Agreeder
    p.text-center
      ' Offline mode is not supported yet


Enter fullscreen mode Exit fullscreen mode

and edit your service worker as follows:



const OFFLINE_VERSION = 1;
const CACHE_NAME = 'offline';
const OFFLINE_URL = 'offline';

self.addEventListener('install', function (event) {
  event.waitUntil((async () => {
    const cache = await caches.open(CACHE_NAME);
    // Setting {cache: 'reload'} in the new request will ensure that the response
    // isn't fulfilled from the HTTP cache; i.e., it will be from the network.
    await cache.add(new Request(OFFLINE_URL, {cache: 'reload'}));
  })());
});

self.addEventListener('activate', async function (event) {
  event.waitUntil((async () => {
  // Enable navigation preload if it's supported.
  // See https://developers.google.com/web/updates/2017/02/navigation-preload
    if ('navigationPreload' in self.registration) {
      await self.registration.navigationPreload.enable();
    }
  })());

  // Tell the active service worker to take control of the page immediately.
  self.clients.claim();
});

self.addEventListener('fetch', function (event) {
  // We only want to call event.respondWith() if this is a navigation request
  // for an HTML page.
    event.respondWith((async () => {
      try {
        // First, try to use the navigation preload response if it's supported.
        const preloadResponse = await event.preloadResponse;
        if (preloadResponse) {
          return preloadResponse;
        }

        return await caches.match(event.request) || await fetch(event.request);
      } catch (error) {
        // catch is only triggered if an exception is thrown, which is likely
        // due to a network error.
        // If fetch() returns a valid HTTP response with a response code in
        // the 4xx or 5xx range, the catch() will NOT be called.
        console.log('Fetch failed; returning offline page instead.', error);

        const cache = await caches.open(CACHE_NAME);
        const cachedResponse = await cache.match(OFFLINE_URL);
        return cachedResponse;
      }
    })());
});


Enter fullscreen mode Exit fullscreen mode

Reload your Service Worker and refresh the page. Now, you can enable Offline mode in Chrome DevTools and you should have an Offline Page!

Offline page without image

The image is not cached and therefore not available yet in offline mode

To show the image we need, of course, to cache it as well. Adding it to the cache is easy, simply add it where you also add the offline page:



await cache.add(new Request('/packs/media/images/logo_white-a925d045a774f93a59598e709010d411.svg', {cache: 'reload'}));


Enter fullscreen mode Exit fullscreen mode

Reload the service worker, refresh the page in "Online mode" and check if the asset is cached correctly on Chrome DevTools:

Offline page with image

This technique works but is not the best out there, since you need to specify the exact name of the asset to cache and, if the asset changes or the offline page changes, you need to remember to also update the Service Worker. I will explain in a different article how to tackle that, but if you are in a hurry and want to find out before, you can look into Google Workbox

Hot Module Reload

webpack-dev-server and hot module reloading will still work, but unfortunately not for our service workers. Webpacker middleware, by default, redirects only requests to /packs to webpack-dev-server, and since our service workers live in the public folder directory, they cannot be served.

I developed a small gem that adds a middleware and allows you to serve also service workers. It's called webpacker-pwa. Add it to your Gemfile, or copy-paste the middleware in your project from the Github project.

This Rack middleware will intercept requests for Service Workers and serve them through webpack-dev-server.

Note that the "refreshed" service worker will be loaded but not activated. This is correct! You can automatically refresh it by adding the following:



self.addEventListener('install', function(event) {
  self.skipWaiting();
});


Enter fullscreen mode Exit fullscreen mode

You can now start coding your service worker and take full advantage of Hot Module Reloading!

Hot module reloading for Service Workers


We change the title of our notifications on the fly

The Progressive Rails App

You are ready to start coding your Progressive Rails App. 
I wanted to show you how easy is to start with Service Workers on Rails, and I hope that Webpacker will soon allow that out-of-the-box, maybe taking inspiration from webpacker-pwa.
Now that you know how to write a service worker, send push notification, and implement an offline page, you can start reading specific guides to implement what you need. I hope this Guide could help you start making your Rails app a Progressive Rails App, and you will be more confident from now on, that this is a very easy task.

For other tutorials regarding Webpacker, check my previous blog posts on dev.to and medium.com.

Thanks Renuo AG, as always, for supporting me in writing this article and for all the improvements in the Rails ecosystem.

Take a look into Agreeder, is a very cool app to take decisions by sorting your preferences.

Please also check Gifcoins, out internal reward system at Renuo. It's a free and easy tool to compliment each other at your company, and a different way to say "Thank you!"

Top comments (11)

Collapse
 
mariod78 profile image
Mario D'Arco

Thanks Alessandro for the interesting read. I'm trying to follow your guide, but I'm stuck at the bit where it says and add the following to webpacker.json. I started a new Rails app from scratch and I do not have such file (Rails 6).

It's probably something obvious, but last time I had to work with frontend code, jQuery was still in fashion, so please bear with me!

Collapse
 
tococorocko profile image
Toco Corocko

It's webpacker.yml.

Collapse
 
fwolfst profile image
Felix Wolfsteller

Nice read - although I am absolutely not sold on on this kind of stuff. I feel like DHH a decade ago ;) . One little improvement: the link to renuo should point to renuo.ch , I believe.

Collapse
 
coorasse profile image
Alessandro Rodi

Thank you Felix! What do you mean exactly with "I am absolutely not sold on on this kind of stuff"?

Collapse
 
fwolfst profile image
Felix Wolfsteller

It's a personal(!) thing: At the moment, I do not want to invest brain-mass and development-hours into modern Javascript-based functionality. I would have to write a lot to be understood - bottom line is probably: I hope to settle on tech that is still around without many changes in 5 years + my typical projects wouldnt benefit a lot from that.

Thread Thread
 
superails profile image
Yaroslav Shmarov

Felix, moving to webpacker is not as hard as you think ;)
And regarding PWA, imagine:
You add a manifest, a service_worker and that's it!
Whenever someone opens your website in mobile he gets the "mobile app experience". That's a nice bargain!

Collapse
 
superails profile image
Yaroslav Shmarov

Very good article. Thanks, Alissandro. I've done the implementation and it works well.
However there's one thing bothering me:
1) I "download" the PWA on mobile
2) I press "log in with google/github/etc"
3) I am redirected to the web browser to log in
4) After log in I stay in the browser version of the app (not redirect back to PWA)
How would you tackle redirecting back into the PWA after social login?

Collapse
 
codicacom profile image
Codica

Great read!

Collapse
 
broger profile image
broger

Hi alessandro, great job with the gem, is there a way to use it with chunks-webpack-plugin?

Collapse
 
mbm1607 profile image
Muhammad Khan

@broger Did you ever end up finding a way to do this?

Collapse
 
deikka profile image
Alex Núñez

Hi Alessandro. Is this tutorial compatible with webpacker 6? I'm struggling hard with the webpacker configuration and can´t make it work...

thanks in advance!