Ruby on Rails 7 でプッシュAPIを利用する
概要
Ruby on Rails 7 (Hotwire Stimulus)で,プッシュAPIを利用する.サービスワーカの作成と登録,web-push
gemによるVAPID鍵の生成と利用などを扱う.
環境
対象 | バージョン |
---|---|
Ruby | 3.2.2 |
rails |
7.0.7.2 |
web-push |
3.0.0 |
先行記事
shimx. (2018). Rails4におけるブラウザプッシュ通知(Web Push Notifications)実装. Qiita. https://qiita.com/shimx/items/d23bb45b34c89f879db3
Rails 4でのプッシュAPIの利用で,serviceworker-rails
gemを用いている.
Push to Subscribe. (2023, September 05). https://fly.io/ruby-dispatch/push-to-subscribe
SaaSのFly.ioによる記事で,Stimulusを用いた実装を行なっている.本稿はこの記事に多く負っている.
localhost
gem の導入
(任意) localhost
gem[1] に自己SSL証明書を生成させて,PumaをSSLバインドすることができる[2].ただし,ローカル開発を容易にするために,localhost はブラウザーでも安全なオリジンとみなされているため,localhost
gemの導入は任意である[3].
(任意) ウェブアプリマニフェストの導入
ウェブアプリマニフェスト (manifest.json) はプッシュAPIの利用にとって必要ではないが,プログレッシブウェブアプリケーション (PWA) の実装には必要である.ここでは,PWAの実装において多く用いられる serviceworker-rails
gem[4]を利用せずにウェブアプリマニフェストを導入する.
app/assets/images/icons/*.png
にアイコンを設置し,app/assets/javascripts/manifest.json.erb
を作成する.Rubyスニペットを埋め込む必要が無い場合は,ERB拡張子を除き,JSONファイルとして作成してもよい.
<%
icon_sizes = [36, 48, 60, 72, 96, 144, 192, 512]
icon_size_vectors = icon_sizes.map { |s| "#{s}x#{s}" }
manifest =
{
name: "app_name",
short_name: "app_short_name",
start_url: "/",
id: "/",
icons: icon_size_vectors.map do |dim|
{
src: image_path("icons/icon-#{dim}.png"),
sizes: dim,
type: "image/png"
}
end,
display: "fullscreen",
orientation: "portrait"
}
%>
<%= JSON.pretty_generate(manifest) %>
manifest.json
がコンパイル対象であることをSprocketsに指示するため,app/assets/config/manifest.js
に次を追加する.
+ //= link manifest.json
そして,app/views/layouts/application.html.erb
などにある <head>
要素にマニフェストを参照するリンク要素を加える.
+ <link rel="manifest" href=<%= asset_path('manifest.json') %>>
これで,ウェブアプリマニフェストの導入は完了である.ただし,manifest.json
はアセットパイプラインに組み込まれることになるため,そのファイル名にフィンガープリントが付与されることに注意する.
サービスワーカーファイルの作成と登録
サービスワーカーファイル (sw.js) には,サービスワーカーに関するイベントに対するイベントリスナーとそのコールバック関数を記述する.イベントには install
やactivate
などがあるが,ここでは push
イベントに関してのみ記述する.self.registration.showNotification
関数で,サービスワーカー上で通知を作成できる[5].
self.addEventListener("push", event => {
const { title, ...options } = event.data.json();
self.registration.showNotification(title, options);
})
sw-registration.js.erb
では,このサービスワーカーファイル (sw.js) をウェブブラウザのサービスワーカーに非同期に登録させる.
const registerServiceWorker = async () => {
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.register("<%= asset_path 'sw.js' %>");
} catch (error) {
console.error(`Registration failed with ${error}`);
}
}
};
registerServiceWorker();
Sprocketsのmanifest.js
にこれらを追加する.
+ //= link sw.js
+ //= link sw-registration.js
application.html.erb
の<head>
要素にJavascriptインクルードタグを追加する.
+ <%= javascript_include_tag "sw-registration" %>
これにより,アプリケーション全体で sw.js
をサービスワーカーファイルとするサービスワーカーが活性化 (activate) する.
SubscriptionモデルとNotificationモデルの生成
ユーザが購読するプッシュサブスクリプションとプッシュ通知の内容を管理するモデルを生成する.『Push to Subscribe』では,Subscription モデルと Notification モデルと与えられている.
bin/rails generate migration createSubscription user:references endpoint auth_key p256dh_key user:references
bin/rails generate migration createNotification user:references title:string body:text
Subscriptionモデルは,ユーザを指す外部キーと,購読のエンドポイント,クライアント鍵の生成に必要な認証秘密文字列 (authentication secret) とP-256曲線の楕円曲線ディフィー・ヘルマン鍵共有[6]を属性に,Notificationモデルは,通知の題と内容を属性に持つ.
app/models/user.rb
にhas_many
を追加する.
+ has_many :notifications, dependent: :destroy
+ has_many :subsciptions, dependent: :destroy
Subscriptions コントローラの作成とパスの付与
後述するように,Stimulus コントローラからの Subscription モデルインスタンスの生成などは,HTTPS要求を通して行う必要がある.したがって,Subscriptions コントローラを作成し,そのアクションに対してパスを付与する.SubscriptionsController
は,create
アクションとdestroy
アクションをもつ.サービスワーカーに購読するときには Subscription
インスタンスを作成し,購読を解除するときには PushSubscription
オブジェクトのendpoint
プロパティをキーにして削除を行う.
class SubscriptionsController < ApplicationController
# POST /subscriptions or /subscriptions.json
def create
subscription = Subscription.new(subscription_params)
if subscription.save
render json: { status: :ok }
else
render json: { status: :unprocessable_entity }
end
end
# DELETE /subscriptions or /subscriptions.json
def destroy
subscription = Subscription.find_by(
endpoint: subscription_params[:endpoint],
user_id: subscription_params[:user_id]&.to_i
)
if subscription.destroy
render json: { status: :ok }
else
render json: { status: :unprocessable_entity }
end
end
private
def subscription_params
params
.require(:subscription)
.permit(:endpoint, :auth_key, :p256dh_key, :user_id)
end
end
Subscriptions コントローラの各アクションに対して,ルーティングを行う.
resources :subscriptions, only: %i[create] do
collection do
delete :destroy
post :change
end
end
web-push
gem の追加
web-push
gem[7] は,プッシュAPIを用いるために必要である Voluntary Application Server Identification (VAPID)[8] 公開鍵および秘密鍵を生成したり,ペイロードを送信したりするAPIを提供するgemである.
VAPID公開鍵および秘密鍵は,以下で生成できる.
vapid_key = WebPush.generate_key
vapid_key.public_key
vapid_key.private_key
これを,以下のようにRailsクレデンシャルに保存する.
EDITOR=vim rails credentails:edit
webpush:
public_key: xxxxxxxxx
private_key: xxxxxxxxx
push
メソッドを追加
NotificationモデルにNotification インスタンスが作成されたときに呼び出されるインスタンスメソッドとして,push
メソッドを定義する.Notification インスタンスが外部キーに持つユーザが購読しているすべてのウェブワーカに対してペイロードを送信し (WebPush.payload_send
),sw.js
で定義したpush
イベントのコールバック関数を呼び出す.
class Notification < ApplicationRecord
belongs_to :user
+ has_many :subscriptions, through: :user
+
+ validates :title, presence: true
+ validates :body, presence: true
+
+ after_create :push
+
+ def push
+ creds = Rails.application.credentials
+ subscriptions.each do |subscription|
+ response =
+ WebPush.payload_send(
+ message: 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 Info: #{response.inspect}"
+ rescue WebPush::ExpiredSubscription,
+ WebPush::InvalidSubscription
+ logger.warn "WebPush Warn: #{response.inspect}"
+ rescue WebPush::ResponseError => e
+ logger.error "WebPush Error: #{e.message}"
+ end
+ end
+ end
ボタン要素の追加
これを押したときに,プッシュサブスクリプションを行うボタン要素を定義する.ボタンタグのdatasets属性には,次節で作成する subscribe
stimulusコントローラ,初期条件でのアクション,stimulusコントローラ内で用いる各値などを与える.
<%= button_tag(
"Subcribe",
type: submit,
data: {
controller: "subscribe",
action: "subscribe#subscribe",
subscribe_sw_path_value: asset_path("sw.js"),
subscribe_user_id_value: current_user.id,
subscribe_path_value: subscriptions_path,
subscribe_key_value: Rails.application.credentials.dig(:webpush, :public_key).tr("_-", "/+")
}
) %>
そして,適当にルーティングを行う.
subscribe
stimulus コントローラの追加
subscribe
stimulus コントローラを追加する.
bin/rails generate stimulus subscribe
生成された subscribe_controller.ts
を次のように書き換える.
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="subscribe"
export default class extends Controller {
static values = {
swPath: String,
userId: String,
path: String,
key: String,
};
declare readonly swPathValue: string;
declare readonly userIdValue: string;
declare readonly pathValue: string;
declare readonly keyValue: string;
get element(): HTMLButtonElement {
return super.element as HTMLButtonElement;
}
}
element()
ゲッターは,自身の参照するボタン要素の型を HTMLButtonElement
であると強制するためのものである.なぜならば,初期条件では型が Element
であるからである.
この無名クラスに,以下に述べる connect
,subscribe
,unsubscribe
(,updateButtonProps
) インスタンスメソッドを追加する.
connect
パブリックメソッド
connect
パブリックメソッドは,コントローラがDOMに接続されたときに呼び出される.ブラウザがサービスワーカやプッシュマネジャに対応していないときや,Nofification
パーミッションが拒否されたときの処理や,既に購読しているか否かの条件分岐による処理などを行う.updateButtonProps
インスタンスメソッドは,のちに定義する.
connect() {
if (!navigator.serviceWorker || !window.PushManager) {
this.updateButtonProps({
text: "Push API not supported",
disabled: true,
});
return;
}
switch (Notification.permission) {
case "denied":
this.updateButtonProps({
text: "Notification permission denied",
disabled: true,
});
return;
}
navigator.serviceWorker
.getRegistration(this.swPathValue)
.then((registration) => {
if (!registration) {
this.updateButtonProps({
text: "Service Worker not registered",
disabled: true,
});
throw "Service Worker not registered";
}
return registration;
})
.then((registration) => registration.pushManager.getSubscription())
.then((subscription) => {
if (!subscription) {
this.updateButtonProps({ text: "Subscribe", action: "subscribe" });
return;
}
this.updateButtonProps({ text: "Unsubscribe", action: "unsubscribe" });
});
}
subscribe
パブリックメソッド
では,pushManager.subscribe
メソッドでプッシュマネジャへの購読を行い,fetch
関数で先に定義したバックエンドのSubscriptionsController#create
を呼び出す.
subscribe(event) {
event.stopPropagation();
event.preventDefault();
// extract key and path from this element's attributes
const key = new Uint8Array(
atob(this.keyValue)
.split("")
.map((char) => char.charCodeAt(0)),
);
const path = this.pathValue;
// request permission, perform subscribe, and post to server
Notification.requestPermission().then((permission) => {
if (permission !== "granted") return;
navigator.serviceWorker
.getRegistration(this.swPathValue)
.then((registration) => {
if (!registration) {
throw "Service Worker not registered";
}
return registration;
})
.then((registration) =>
registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: key,
}),
)
.then((subscription) => subscription.toJSON())
.then((subscription) => {
if (!subscription.endpoint || !subscription.keys || !path) {
throw "Invalid subscription";
}
return fetch(path, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRF-Token": document.querySelector("meta[name=csrf-token]")?.getAttribute("content") ?? "",
},
body: new URLSearchParams([
["subscription[endpoint]", subscription.endpoint],
["subscription[auth_key]", subscription.keys.auth],
["subscription[p256dh_key]", subscription.keys.p256dh],
["subscription[user_id]", this.userIdValue],
]),
}).then(() => {
this.updateButtonProps({
text: "Unsubscribe",
action: "unsubscribe",
});
});
})
.catch((error) => {
console.error(`Web Push subscription failed: ${error}`);
});
});
}
unsubscribe
パブリックメソッド
unsubscribe
パブリックメソッドでは,subscription.unsubscribe()
メソッドで購読を解除し,SubscriptionsController#destroy
を呼び出す.
unsubscribe(event) {
event.stopPropagation();
event.preventDefault();
navigator.serviceWorker
.getRegistration(this.swPathValue)
.then((registration) => {
if (!registration) {
throw "Service Worker not registered";
}
return registration;
})
.then((registration) => registration.pushManager.getSubscription())
.then((subscription) => {
if (!subscription) return;
return subscription.unsubscribe().then(() => {
return fetch(this.pathValue, {
method: "DELETE",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRF-Token": document.querySelector("meta[name=csrf-token]")?.getAttribute("content") || "",
},
body: new URLSearchParams([
["subscription[endpoint]", subscription.endpoint],
["subscription[user_id]", this.userIdValue],
]),
}).then(() => {
this.updateButtonProps({ text: "Subscribe", action: "subscribe" });
});
});
})
.catch((error) => {
console.error(`Web Push unsubscription failed: ${error}`);
});
}
updateButtonProps
プライベートメソッド
updateButtonProps
プライベートメソッドは,ボタンタグの disabled
属性や,テキスト内容,stimulus アクションを変更する.
private updateButtonProps({
text,
action,
disabled,
element = this.element,
}: {
text?: string;
action?: string;
disabled?: boolean;
element?: HTMLButtonElement;
}) {
if (disabled) {
element.disabled = true;
}
if (text) element.textContent = text;
if (action) element.dataset.action = `subscribe#${action}`;
}
}
create
アクション内でNotificationインスタンスを作成
SubscriptionsコントローラのSubscriptionsコントローラのcreate
アクション内でNotificationインスタンスを作成する.
def create
subscription = Subscription.new(subscription_params)
if subscription.save
+ Notification.create(
+ user: current_user,
+ title: "Great!",
+ body: "You've got subscribe to the push manager."
+ )
render json: { status: :ok }
else
render json: { status: :unprocessable_entity }
end
end
先に定義したボタンを押してみると,プッシュ通知が届くはずだ.
-
localhost. (2023, September 05). https://github.com/socketry/localhost ↩︎
-
SSL証明書を自作せずに、Railsの開発環境でhttps接続する方法. (2021, December 31). https://kseki.github.io/posts/rails-ssl-connection-with-localhost-gem ↩︎
-
サービスワーカーの使用 - Web API | MDN. (2023, September 02). Retrieved from https://developer.mozilla.org/ja/docs/Web/API/Service_Worker_API/Using_Service_Workers ↩︎
-
serviceworker-rails. (2023, September 05). Retrieved from https://github.com/rossta/serviceworker-rails ↩︎
-
ServiceWorkerRegistration.showNotification() - Web API | MDN. (2023, September 02). Retrieved from https://developer.mozilla.org/ja/docs/Web/API/ServiceWorkerRegistration/showNotification ↩︎
-
何それ ↩︎
-
web-push. (2023, September 05). Retrieved from https://github.com/pushpad/web-push ↩︎
-
Thomson, M., & Beverloo, P. (2017, November). Voluntary Application Server Identification (VAPID) for Web Push. doi: 10.17487/RFC8292 ↩︎
-
CSRF token InvalidAuthenticityToken - General - Hotwire Discussion. (2018, February 15). Retrieved from https://discuss.hotwired.dev/t/csrf-token-invalidauthenticitytoken/91 ↩︎
Discussion