We’re Fly.io. We run apps for our users on hardware we host around the world. Fly.io happens to be a great place to run Rails applications. Check out how to get started!
All your favorite social apps have the ability to send you notifications even when you aren’t even using them. Sending notifications used to require different mechanisms for different clients, but with Safari joining the party, now is a good time to consider implementing the Push API in your applications. This can be a great way to engage with your audience and clients.
Unfortunately the webpush protocol, explanations, API, and even gem share a number of problems. Overall, they:
- provide too many choices
- are incomplete
- may even suggest things that no longer work
This blog post will take you through creating a complete Rails 7 application with Web Push, and will do so in a way that will show you how to add Web Push to your existing Rails application.
This demo application’s model will include Users, Subscriptions, and Notifications, where a User may have both many Subscriptions and many Notifications. We are going to make use of the web-push gem and create a service worker.
Before proceeding to the logic, let’s get some scaffolding/administrivia out of the way. Start by running the following commands:
rails new webpush --css=tailwind
cd webpush
bin/rails generate scaffold User name:string
bin/rails generate scaffold Subscription \
endpoint auth_key p256dh_key user:references
bin/rails generate scaffold Notification \
user:references title:string body:text
bin/rails db:migrate
bundle add web-push
bin/rails generate controller ServiceWorker
Next run the following in the rails console to add some VAPID keys to your credentials:
creds = Rails.application.credentials
key = Webpush.generate_key
add = YAML.dump('webpush' => key.to_h.stringify_keys).sub('---','')
creds.write creds.read + "\n# Webpush VAPID keys#{add}"
Then add some routes to config/routes.rb
:
get "/service-worker.js", to: "service_worker#service_worker"
post "/notifications/change", to: "notification#change",
as: "change_notifications"
Now let’s get started.
Add a Service Worker
Web push notifications require you to install some JavaScript that runs
separate from your web application as a service worker. This code has two
responsibilities: listen for push requests and show them as notifications, and post
subscription change information to the server. Place the following into
app/views/service_worker/service_worker.js.erb
to accomplish both tasks:
self.addEventListener("push", event => {
const { title, ...options } = event.data.json();
self.registration.showNotification(title, options);
})
self.addEventListener("pushsubscriptionchange", event => {
const newSubscription = event.newSubscription?.toJSON()
event.waitUntil(
fetch(<%= change_notifications_path.inspect.html_safe %>, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
old_endpoint: event.oldSubscription?.endpoint,
new_endpoint: event.newSubscription?.endpoint,
new_p256dh: newSubscription?.keys?.p256dh,
new_auth: newSubscription?.keys?.auth
})
})
)
})
Update app/controllers/service_worker_controller.rb
to disable
authentication for this script:
class ServiceWorkerController < ApplicationController
skip_before_action :verify_authenticity_token
def service_worker
end
end
There are no secrets in this file, so this is safe to do.
Sending notifications on save
For this demo, notifications are stored in the database, and are sent when saved. We accomplish this by using the after_save
Active Record Callback in app/models/notification.rb
:
class Notification < ApplicationRecord
belongs_to :user
after_save do |notification|
notification.user.push(notification)
end
end
Next we update the User
model in app/models/user.rb
to iterate over the subscriptions and call WebPush.payload_send
on each. While in this file, we also add has_many
calls to complete the relations.
class User < ApplicationRecord
has_many :notifications
has_many :subscriptions
def push(notification)
creds = Rails.application.credentials
subscriptions.each do |subscription|
begin
response = WebPush.payload_send(
message: notification.to_json,
endpoint: subscription.endpoint,
p256dh: subscription.p256dh_key,
auth: subscription.auth_key,
vapid: {
private_key: creds.webpush.private_key,
public_key: creds.webpush.public_key
}
)
logger.info "WebPush: #{response.inspect}"
rescue WebPush::ExpiredSubscription,
WebPush::InvalidSubscription
logger.warn "WebPush: #{response.inspect}"
rescue WebPush::ResponseError => response
logger.error "WebPush: #{response.inspect}"
end
end
end
end
While this is a fair number of lines of code, it is pretty straightforward, merely passing the notification, the subscription, and VAPID keys we generated earlier to the payload_send
call, and the results from this call are logged.
Expired and invalid subscriptions should eventually be cleaned up, but perhaps not immediately as they may be in the process of being changed. As they say, this is left as an exercise for the student.
User Interface
We are going to make one functional and one cosmetic change to the user interface for this demo.
Since users have subscriptions, we add a Create Subscription button to the users page in app/views/users/_user.html.erb
:
<span data-controller="subscribe" class="hidden"
data-path=<%= subscriptions_path %> data-key="<%=
Rails.application.credentials.webpush.public_key.tr("_-", "/+")
%>">
<%= render partial: 'subscriptions/form', locals: {
subscription: Subscription.new(user: user)
} %>
</span>
This span
element makes use of a Stimulus controller that we will get to in a minute, is initially hidden, contains the subscriptions_path
and public_key
as data attributes, and renders the subscriptions form with the user pre-filled in.
Where you place this HTML fragment in the form is up to you. If it is inside the if
statement, this will only show up on the index page.
The cosmetic change is actually more involved. We start by getting a list of users in NotificationsController in app/controllers/notifications_controller.rb
:
before_action only: %i[ new edit ] do
@users = User.all.map {|user| [user.name, user.id]}
end
Next we make use of this list in app/views/notifications/_form.html.erb
:
<%= form.select :user_id, @users, {}, class: "..." %>
And we also make one tiny change to app/views/notifications/_notification.html.erb
:
<%= notification.user.name %>
Taken together, this lets us create, view, and edit notifications using user names instead of record ids.
If you feel so inclined, you can make the same change to the subscriptions pages, but as that information is lower level and not something that you will be directly editing, it is fine to leave it as it is for now.
Wiring up the Browser
At this point, we have a hidden form on user pages. There is a lot left to be done:
- We have to hide the individual form fields, and then unhide the rest of the form to reveal the Create Subscription button.
- We disable this button if notifications have already been granted or denied on this device.
- When the button is clicked we need to request permission for notifications, and if granted do the following in sequence:
- Register the service worker
- Create subscription
- Post the subscription endpoint and keys to the server
The code for all of this is below. Place it into
app/javascript/controllers/subscribe_controller.js
:
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="subscribe"
export default class extends Controller {
connect() {
// hide notification form fields
for (const field of this.element.querySelectorAll('.my-5')) {
field.style.display = 'none'
}
// unhide remainder of the form, revealing the submit button
this.element.style.display = 'inline-block'
// find submit button
const submit = this.element.querySelector('input[type=submit]')
if (!navigator.serviceWorker || !window.PushManager) {
// notifications not supported by this browser
this.disable(submit)
} else if (Notification.permission !== "default") {
// permission has already been granted or denied
this.disable(submit)
} else {
// prompt for permission when clicked
submit.addEventListener("click", event => {
event.stopPropagation()
event.preventDefault()
this.disable(submit)
// extract key and path from this element's attributes
const key = Uint8Array.from(atob(this.element.dataset.key),
m => m.codePointAt(0))
const path = this.element.dataset.path
// request permission, perform subscribe, and post to server
Notification.requestPermission().then(permission => {
if (Notification.permission === "granted") {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: key
})
})
.then(subscription => {
subscription = subscription.toJSON()
let formData = new
FormData(this.element.querySelector('form'))
formData.set('subscription[endpoint]',
subscription.endpoint)
formData.set('subscription[auth_key]',
subscription.keys.auth)
formData.set('subscription[p256dh_key]',
subscription.keys.p256dh)
return fetch(path, {
method: 'POST',
headers: {'Content-Type':
'application/x-www-form-urlencoded'},
body: new URLSearchParams(formData).toString()
})
})
.catch(error => {
console.error(`Web Push subscription failed: ${error}`)
})
}
})
})
}
}
// disable the submit button
disable(submit) {
submit.removeAttribute('href')
submit.style.cursor = 'not-allowed'
submit.style.opacity = '30%'
}
}
While the largest block of code in this entire demo, it isn’t particularly complex: it merely performs a few checks, runs steps in sequence, and extracts and passes in data as required.
Updating Subscriptions
One final piece completes the puzzle. Our application needs to update subscription information as it changes. These requests will be made by the service worker.
Add the following to app/controllers/subscriptions_controller.rb
:
# POST /subscriptions/change
def change
subscription = Subscription.find_by_endpoint!(
params[:old_endpoint]
)
if params[:new_endpoint]
subscription.endpoint = params[:new_endpoint]
end
if params[:new_p256dh]
subscription.p256dh_key = params[:new_p256dh]
end
if params[:new_auth]
subscription.auth_key = params[:new_auth]
end
if @subscription.save
format.json {
render :show,
status: :ok,
location: subscription
}
else
format.json {
render json: subscription.errors,
status: :unprocessable_entity
}
end
end
For this to work, you will also need to add to the start of the controller:
skip_before_action :verify_authenticity_token, only: [:change]
Endpoints generally are secrets, but it is beyond my expertise to determine how this can be compromised and exploited.
Try it out!
At this point you should have a working demo. You can launch your server by running bin/dev
, create a user by going to http://localhost:3000/users
. Create a subscription by clicking on the Create Subscription button associated with that user, and finally create a notification by going to http://localhost:3000/notifications
.
http://localhost:3000/subscriptions
can also be used to see the messy details.
Explore Further
This blog post can also be viewed as a series of steps that you can use to add push notification to existing Rails applications. Undoubtedly your UI and model will be different, and those changes may affect the stimulus controller, but the basic steps to add the functionality should be the same and hopefully much of this code can be reused.
This demo also only sends title
and body
for notifications, there are more options that you can play with.
If you want to explore more, two of the best resources I used in preparing this post were: