Progressive Web Apps, or PWAs, have a home in Rails. There are a few pieces to add to any app to make it a PWA. With a little configuration, an app will be installable in a browser, accessible offline, and in line with any other front end framework.
PWAs?
Progressive Web Apps are a type of web app that can provide richer experiences online. Google has written up a guide on what they consider necessary for a delightful web experience here. Rails provides a lot of the necessary tools for page speed, such as Turbolinks and Russian Doll Caching. In order to be a PWA, an app needs to supply two specific files at specific paths: an app manifest, available at /manifest.json
and a service worker, available at /service-worker.js
. Here are two ways to provide these files currently in Rails: either in the public folder, or preferably, through Rails itself.
Providing the components
The first problem to consider is that the manifest file and the service worker file cannot be inside the asset pipeline path or the webpacker path. There is some discussion on Github about this, but the key part of the specification is that the service worker scope is dictated by the path of the file. If the service worker is served from /packs
, then it can only be used for web pages that are in /packs
. That’s not very useful. It’s therefore necessary to set up manifest.json
and service-worker.js
outside the asset pipeline or webpack.
You need to add some general configuration to your site. First, point to the manifest file in your application.html.erb
head tag:
<!-- Lighthouse Details -->
<link rel="manifest" href="/manifest.json">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#C50001"/>
In your javascript, you need to register the service worker. This can go in application.js
, either in webpack or in the asset pipeline location:
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/service-worker.js', { scope: './' })
.then(function(reg) {
console.log('[Companion]', 'Service worker registered!');
console.log(reg);
});
}
Then add the manifest.json and service-worker.js files as you need.
First Method: Public Folder
Write out the manifest.json file and the service-worker.js file by hand, and place them in the public folder in the Rails app. Files in the public folder path are served as is. One downside is that the asset paths are not available in the public folder. This means that those assets cannot be cached by the browser, and the app would lose some offline mode functionality.
Second Method: Service Worker Controller
By adding a controller that manages the /service-worker.js
and /manifest.json
requests, the asset paths are now available for all assets that need caching.
Add the following lines to you routes.rb file:
# Service Worker Routes
get '/service-worker.js' => "service_worker#service_worker"
get '/manifest.json' => "service_worker#manifest"
Make a controller called service_worker.rb
and add 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.
In the views folder, make a folder called service_worker
and add a manifest.json.erb
file that looks something like this:
{
"short_name": "My App",
"name": "My Very 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 webpack features are available. Those icons referenced above also need to be provided.
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' %>',
]);
})
);
}
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);
})
);
})
);
}
// Borrowed from https://github.com/TalAter/UpUp
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' && 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);
This service worker skeleton came from a Rails service worker gem that wasn’t working with webpack.
Whatever files need caching are added in the onInstall
function. This template only has the webpack pack files, but any images, or asset pipeline files that should be cached can be added too.
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.
Easy PWAs
PWAs with Rails are very easy to setup. They don’t necessarily get the webpacker/asset pipeline benefits of compilation and minimization, but the ability to serve the service worker and app manifest with just a little bit of configuration means there is no need to rewrite an existing app in order to make a web page sleek and modern.
More PWA goodness: HNPWA!
You can see a full fledged example of a Rails based PWA here.
Feel free to leave a comment or question below.
Want To Learn More?
Try out some more of my Stimulus.js Tutorials.
24 comments on “Easy PWAs the Rails way”
Great article! I’m following it to implement offline capabilities in a legacy system. I have just one question. How do you proceed regarding cache invalidation? You know, javascripts, stylesheets, and event images can change. When this happen, how could I remove the changed assets from the cache?
Every time the browser loads the service-worker.js file, it will cache the files you want cached. If you want to invalidate the cache, change the CACHE_NAME value in the example, and then on activation, any caches with a different name will be cleared.
My small understanding of React and similar, is that it allows to have the whole application offline, having the ability to cache some part of the data as well (through the local storage). How do you deal with that in a Rails application? I understand that most of the part it’s possible to do, but my missing piece is the offline component of a PWA where you are able to load all the application offline, having the pages rendered server side I find that hard to understand how to get them into the PWA/manifest.
Do you have any good example of a slightly complex rails app (not a simple todo app) that has PWA/offline support?
You can cache whatever pages you’d like, so that’s a per application decision. But if your React application is contingent on an API being available, that’s no different than a rails app that loads html pages. But I suppose if it’s a tip calculator, having all the logic loaded client side would be an advantage. I’d argue that you’re under the same constraints regarding what files/resources you need should cache no matter what framework you’re working with.
https://serviceworke.rs/strategy-cache-update-and-refresh.html is a good resource for deciding how you can cache information.
I have an example of a pwa at https://hnpwa.onrails.blog, but since it’s more of a news app, it isn’t really optimized for offline mode.
Thanks! This article made it easy to understand how a PWA works
Hey John thanks for the great article. Have you got any examples of the service-worker.js file you used for this tutorial?
Thank you!
Yes, I extracted this article from an app I was working on, that’s now available at https://github.com/johnbeatty/hnpwa-app
Wonderful thank you 🙂
Thanks for writing these up. I’d love to see a tutorial on setting up the Add to Home Screen A2HS part of PWA functionality. It seems to me that this would be a big plus for a lot of web apps, but it’s proving hard to get working in Rails (at least for me as a newbie) 🙂 Thanks again for taking the time to write these up
The service workers work in my localhost but seem to fail when deployed to heroku. The console errors display
`A bad HTTP response code (500) was received when fetching the script.
You may have to tweak the http server settings.
.js files are usually treated like static files, when in this case, it’s dynamically being created, so you need to configure something so that the request hits your rails app.
One thing I had to do in Nginx was turn off all caching for the service worker and manifest.json files
location = /service-worker.js {
expires -1;
}
location = /manifest.json {
expires -1;
}
Just an update: I managed to fix the issue. I had to go to the webpacker.yml file and set extract_css to false. Somehow that work. Not sure why though
I think you should update your example of service_worker.js.erb to be like the one you have at github (https://github.com/johnbeatty/hnpwa-app/blob/master/app/views/service_worker/service_worker.js.erb)
More specifically, I spend a few hours to notice that it should be ” and not ”
I have no idea how Webpacker works, so I’m glad I found the commit in your git repo and it is working now 🙂
What I meant was: It should be stylesheet_path ‘application.css’ and not asset_pack_path ‘application.css’
Great tutorial, super simple and easy to understand.
Only one thing, I’m getting “Web app manifest does not meet the installability requirementsFailures: Manifest does not have `short_name`, Manifest does not have `name`.” even though the manifest has them (I copied from the tutorial and changed the content.
If I go to /manifest.json its all there.
What could have gone wrong?
Thanks!
I can’t edit it, but it was a stupid problem. the favicon gem has its own manifest that was loading instead. Fixed it by combining the manifests.
But another problem came up that I just couldn’t solve. Whenever I login (using devise, rails 6), it would redirect me to the service worker file itself, instead of the root path.
Any idea what might be causing it?
I haven’t seen that. What happens when you comment out either directive from your routes file?
After I commend out the service worker route, when I login, i get redirected to the home page as expected, but the service worker fails to start.
I get this in the console:
A bad HTTP response code (404) was received when fetching the script.
(index):1 Uncaught (in promise) TypeError: Failed to register a ServiceWorker for scope (‘http://localhost:3000/’) with script (‘http://localhost:3000/service-worker.js’): A bad HTTP response code (404) was received when fetching the script.
I can’t seem to understand what makes login redirect to the service worker file itself…
After I commend out the service worker route, when I login, i get redirected to the home page as expected, but the service worker fails to start.
I get this in the console:
A bad HTTP response code (404) was received when fetching the script.
(index):1 Uncaught (in promise) TypeError: Failed to register a ServiceWorker for scope (‘http://localhost:3000/’) with script (‘http://localhost:3000/service-worker.js’): A bad HTTP response code (404) was received when fetching the script.
I can’t seem to understand what makes login redirect to the service worker file itself…
Are you able to run this on SSL? I think localhost works for PWAs, but Service Workers are by design only able to run over an https connection.
I would also look at the logs in your app. Once you’ve logged in, what are the redirects you’re seeing in log/development.log? i.e.
Started POST “/login” for 127.0.0.1 at 2020-04-11 11:58:07 -0400
Redirected to …/login
Completed 302 Found in 77ms (ActiveRecord: 0.4ms)
Started GET “/login” for 127.0.0.1
It happens both in development and in production, where its https.
Started POST “/users/sign_in” for ::1 at 2020-04-13 10:55:51 +0300
Processing by Users::SessionsController#create as HTML
Parameters: {“authenticity_token”=>
“ZQDmKo1vmsW8gouQeNkmFI7hzrSLbxv8kB75vVV3KaRmTS29T/5a/+ii8BXGKVjea7+VXmBSoPF3+gf1BEcgrQ==”,
“user”=>{“login”=>”456”, “password”=>”[FILTERED]”, “remember_me”=>”0”}, “commit”=>”כניסה”}
User Load (0.9ms) SELECT “users”.* FROM “users” WHERE (lower(username) = ‘456’ OR lower(email) = ‘456’) ORDER BY “users”.”id” ASC LIMIT $1 [[“LIMIT”, 1]]
↳ app/models/user.rb:35:in `find_first_by_auth_conditions’
Redirected to http://localhost:3000/service-worker.js
Completed 302 Found in 154ms (ActiveRecord: 0.9ms | Allocations: 4700)
Have you looked into some kind of devise configuration change, like this: https://github.com/heartcombo/devise/issues/5076 ?
I have a suspicion something isn’t configured on Devise’s side, and it’s somehow pulling a random (to us at least) route after someone logs in to the page.
I had the same problem, and it’s coming from the fact that he is treating this service_worker.js.erb file as an html file. Just add a respond_to in your controller action and it will solve it (for me it did at lease).
The ServiceWorkerController will look like this :
class ServiceWorkerController < ApplicationController
protect_from_forgery except: :service_worker
def service_worker
respond_to do |format|
format.js
end
def manifest
end
end
I think this is not correct. The second end on manifest should be closing the respond_to inside service_worker:
def service_worker
respond_to do |format|
format.js
end
end
def manifest
end