Push to Subscribe

A bird pushing a red button on a phone
Image by Annie Ruygt

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.

You can play with this right now.

It’ll take less than 10 minutes to get your Rails application running globally.

Try Fly for free

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: