Jonathan Bennett

Set Up Push Notifications in Rails: Part 2

With the foundation for SSL and push notifications in place, it’s time to enable client-side notifications. Here’s the standard workflow:

  1. Show an “Enable Notifications” button.
  2. Request user permission.
  3. Create a subscription and submit tokens if permission is granted.
  4. Save tokens on the backend.
  5. Send notifications to the client.

Let’s break it down:

Step 1: Show Button

To prevent spam, push notification requests must be triggered by a user action. Additionally, we’ll use the previously created VAPID keys. This logic will live in a Stimulus controller:

// app/javascript/push_notifictions_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
	prompt(e) {
		alert("permissions")
	}
	
	get vapidKey() {
		return document.querySelector('meta[name="vapid_key"]')?.content
	} 
}

Add the key to the <head> of your application layout and create a button to trigger the controller:

<head>
	<%= tag.meta name: :vapid_key, content: ENV["VAPID_KEY"] %>
</head>

<body data-controller="push-notifications">
	<button data-action="push-notifications#prompt">Enable Notifications</button>
</body>

Step 2: Request Permission

When the button is clicked, we’ll request permission from the user and handle their response:

// app/javascript/push_notifictions_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
	prompt(e) {
		e.preventDefault()
		// bail out early if not available or already granted
		if (window.Notification?.permission === "granted" ||
			!navigator.serviceWorker) return;
		
		Notification.requestPermission().then(permission => {
			if (permission !== "granted") {
				console.error("Push notifications declined", permission)
				// other error handling/prompts
				return
			}
			
			this.setupPushSubscription()
		})
	}
	
	setupPushSubscription() {}
}

Step 3: Create and Submit Tokens

Once permission is granted, create a subscription and submit it to the backend. Using @rails/request.js simplifies this process:

// app/javascript/push_notifictions_controller.js
import { Controller } from "@hotwired/stimulus"
import { post } from '@rails/request.js'

export default class extends Controller {
	async setupPushSubscription() {
		// register our previously created service worker
		navigator.serviceWorker.register("/service-worker")
		
		const serviceWorkerRegistration = await navigator.serviceWorker.ready
		await serviceWorkerRegistration.pushManager.subscribe({
		  userVisibleOnly: true,
		  applicationServerKey: this.vapidKey
		})
		
		this.saveSubscription()
	}
	
	async saveSubscription() {
		const registration = await navigator.serviceWorker.ready
		const subscription = await registration.pushManager.getSubscription()
		if (!subscription) return
		
		const response = await post("/web_push_subscriptions", {
		  body: JSON.stringify(subscription)
		})
	}
}

Step 4: Save Tokens

To store the subscription tokens, we’ll create a model:

rails generate model WebPushSubscription user:references endpoint auth p256dh user_agent
class WebPushSubscription < ApplicationController
	def create
		subscription = Current.user.web_push_subscriptions.find_or_initialize_by(
			endpoint: params[:endpoint],
			auth: params[:keys][:auth],
			p256dh: params[:keys][:p256dh]
		)
		subscription.user_agent = request.user_agent
		subscription.save!
		
		head :ok
	end
end

We are using find_or_initialize_by to allow multiple submissions with the same subscription values.

Step 5: Send a Notification

We can add a simple method to our subscription to send a hash to our device. I’ll usually wrap this with an additional method to help standardize around specific keyword arguments:

class WebPushSubscription < ApplicationRecord
	belongs_to :user
	
	def send_notification(title: nil, url: nil) # add option standardized keys
		data = {
			title: title,
			url: url
		}.compact_blank
		
		deliver_payload(data)
	end
	
	def deliver_payload(data)
		WebPush.payload_send(
			message: data.to_json,
			endpoint: endpoint,
			p256dh: p256dh,
			auth: auth,
			vapid: {
				public_key: ENV.fetch("VAPID_PUBLIC"),
				private_key: ENV.fetch("VAPID_PRIVATE")
			}
		)
	end
end

Once we visit and enable notifications, we should be able to test this from the rails console:

WebPushSubscription.last.send_notification(title: "Hello World")