ℹ️

Ruby on Rails 7 でプッシュAPIを利用する

2023/09/05に公開

概要

Ruby on Rails 7 (Hotwire Stimulus)で,プッシュAPIを利用する.サービスワーカの作成と登録,web-pushgemによる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ファイルとして作成してもよい.

app/assets/javascripts/manifest.json.erb
<%
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に次を追加する.

app/assets/config/manifest.js
+ //= link manifest.json

そして,app/views/layouts/application.html.erbなどにある <head> 要素にマニフェストを参照するリンク要素を加える.

app/views/layouts/application.html.erb
+ <link rel="manifest" href=<%= asset_path('manifest.json') %>>

これで,ウェブアプリマニフェストの導入は完了である.ただし,manifest.jsonはアセットパイプラインに組み込まれることになるため,そのファイル名にフィンガープリントが付与されることに注意する.

サービスワーカーファイルの作成と登録

サービスワーカーファイル (sw.js) には,サービスワーカーに関するイベントに対するイベントリスナーとそのコールバック関数を記述する.イベントには installactivateなどがあるが,ここでは push イベントに関してのみ記述する.self.registration.showNotification関数で,サービスワーカー上で通知を作成できる[5]

app/assets/javascripts/sw.js
self.addEventListener("push", event => {
  const { title, ...options } = event.data.json();
  self.registration.showNotification(title, options);
})

sw-registration.js.erb では,このサービスワーカーファイル (sw.js) をウェブブラウザのサービスワーカーに非同期に登録させる.

app/assets/javascripts/sw-registration.js.erb
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にこれらを追加する.

app/assets/config/manifest.js
+ //= link sw.js
+ //= link sw-registration.js

application.html.erb<head>要素にJavascriptインクルードタグを追加する.

application.html.erb
+ <%= 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.rbhas_manyを追加する.

app/models/user.rb
+ has_many :notifications, dependent: :destroy
+ has_many :subsciptions, dependent: :destroy

Subscriptions コントローラの作成とパスの付与

後述するように,Stimulus コントローラからの Subscription モデルインスタンスの生成などは,HTTPS要求を通して行う必要がある.したがって,Subscriptions コントローラを作成し,そのアクションに対してパスを付与する.SubscriptionsControllerは,createアクションとdestroyアクションをもつ.サービスワーカーに購読するときには Subscriptionインスタンスを作成し,購読を解除するときには PushSubscriptionオブジェクトのendpointプロパティをキーにして削除を行う.

app/controllers/subscriptions_controller.rb
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 コントローラの各アクションに対して,ルーティングを行う.

config/route.rb
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

Notificationモデルにpushメソッドを追加

Notification インスタンスが作成されたときに呼び出されるインスタンスメソッドとして,pushメソッドを定義する.Notification インスタンスが外部キーに持つユーザが購読しているすべてのウェブワーカに対してペイロードを送信し (WebPush.payload_send),sw.jsで定義したpushイベントのコールバック関数を呼び出す.

app/models/notification.rb
 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コントローラ内で用いる各値などを与える.

app/views/notifications.html.erb
<%= 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 を次のように書き換える.

app/javascript/controllers/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 であるからである.

この無名クラスに,以下に述べる connectsubscribeunsubscribe (,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}`;
  }
}

Subscriptionsコントローラのcreateアクション内でNotificationインスタンスを作成

Subscriptionsコントローラのcreateアクション内でNotificationインスタンスを作成する.

app/controllers/subscriptions_controller.rb
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

先に定義したボタンを押してみると,プッシュ通知が届くはずだ.

脚注
  1. localhost. (2023, September 05). https://github.com/socketry/localhost ↩︎

  2. SSL証明書を自作せずに、Railsの開発環境でhttps接続する方法. (2021, December 31). https://kseki.github.io/posts/rails-ssl-connection-with-localhost-gem ↩︎

  3. サービスワーカーの使用 - Web API | MDN. (2023, September 02). Retrieved from https://developer.mozilla.org/ja/docs/Web/API/Service_Worker_API/Using_Service_Workers ↩︎

  4. serviceworker-rails. (2023, September 05). Retrieved from https://github.com/rossta/serviceworker-rails ↩︎

  5. ServiceWorkerRegistration.showNotification() - Web API | MDN. (2023, September 02). Retrieved from https://developer.mozilla.org/ja/docs/Web/API/ServiceWorkerRegistration/showNotification ↩︎

  6. 何それ ↩︎

  7. web-push. (2023, September 05). Retrieved from https://github.com/pushpad/web-push ↩︎

  8. Thomson, M., & Beverloo, P. (2017, November). Voluntary Application Server Identification (VAPID) for Web Push. doi: 10.17487/RFC8292 ↩︎

  9. CSRF token InvalidAuthenticityToken - General - Hotwire Discussion. (2018, February 15). Retrieved from https://discuss.hotwired.dev/t/csrf-token-invalidauthenticitytoken/91 ↩︎

Discussion