💭

Next(app router)とRailsでweb pushを実現する

2023/11/06に公開

web pushに対応した時の内容を備忘録代わりに残しておく

Rails側

gemのインストール

web-pushというgemを使います、ハイフンなしのwebpushというgemはメンテされてないので注意
https://github.com/pushpad/web-push

VAPID公開鍵と秘密鍵の生成

WebPush.generate_key で公開鍵と秘密鍵を生成してメモしておきます

# One-time, on the server
vapid_key = WebPush.generate_key

# Save these in your application server settings
vapid_key.public_key
vapid_key.private_key

通知処理の実装

以下のようなmodelを作っておき、subscribeされたときにエンドポイント等をuserに紐づけて保存しておきます。
あとは通知を送りたいタイミングでweb_pushを呼ぶことで通知を送れます。
messageの中は { title:, body:, target_url: } な構造のhashを渡します。

app/models/web_push_subscription.rb
# frozen_string_literal: true

# == Schema Information
#
# Table name: web_push_subscriptions
#
#  id                              :bigint           not null, primary key
#  user_id(user id)                :bigint           not null
#  endpoint(web push endpoint)     :string(255)      not null
#  p256dh(web push p256dh)     :string(255)      not null
#  auth(web push auth)         :string(255)      not null
#  created_at                      :datetime         not null
#  updated_at                      :datetime         not null
#
# Indexes
#
#  index_user_id_on_web_push_subscriptions  (user_id)
#
class WebPushSubscription < ApplicationRecord
  def web_push(message)
    WebPush.payload_send(**payload(message))
  rescue WebPush::ExpiredSubscription,
         WebPush::InvalidSubscription => e
    Rails.logger.warn "WebPush Warn: #{e.message} #{self}"
    # expiredやinvalidな時はdestroyして購読解除する
    destroy!
  rescue WebPush::ResponseError => e
    Rails.logger.error "WebPush Error: #{e.message} #{self}"
  end

  private

  def payload(message)
    {
      message: message.to_json,
      endpoint:,
      p256dh: p256dh_key,
      auth: auth_key,
      vapid: {
        private_key: # 生成したVAPID秘密鍵をENVとかから読み取る,
        public_key: # 生成したVAPID公開鍵をENVとかから読み取る
      }
    }
  end
end

Next側

manifest.jsonの作成

manifest.json
{
  "theme_color": "#12486B",
  "background_color": "#ffffff",
  "display": "standalone",
  "scope": "/",
  "start_url": "/",
  "name": "hoge app",
  "short_name": "hoge"
}

各キーの定義は以下を参照
https://developer.mozilla.org/ja/docs/Web/Manifest

上記manifestではicon記載してないですが、icon付ける場合必要なiconサイズに関しては以下を参照
https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/How_to/Define_app_icons#create_the_necessary_icon_sizes

一括でicon生成してくれるサイトもあるみたいです
https://www.pwabuilder.com/imageGenerator

service workerの登録とsubscribe

public/service-worker.js を設置
通知を表示するのと通知をクリックした際に対象のURLを開いているタブがあればfocusし、なければ新しく開いてます

public/service-worker.js
self.addEventListener('push', (event) => {
  // Get the push message
  var message = event.data.json();
  // Display a notification
  event.waitUntil(
    self.registration.showNotification(message.title, {
      body: message.body,
      icon: './favicon.ico',
      data: { target_url: message.target_url }
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    clients
      .matchAll({
        type: 'window'
      })
      .then((clientList) => {
        for (const client of clientList) {
          if (
            client.url === event.notification.data.target_url &&
            'focus' in client
          ) {
            return client.focus();
          }
        }
        if (clients.openWindow) {
          return clients.openWindow(event.notification.data.target_url);
        }
      })
  );
});

以下をhookとかにして app/layout.tsx から読み込むようにしておきます
私はAppLayoutというcomponentを作って、そいつをlayout.tsxから読み込んでいます
NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEYに生成したVAPID公開鍵を入れておき、Uint8Arrayに変換します

Next.js 13.4以降はreactStrictModeがデフォルトでtrueになっており、useEffectが2回呼ばれるので注意です。
https://nextjs.org/docs/app/api-reference/next-config-js/reactStrictMode

回避方法はこの辺りが参考になります
https://github.com/reactwg/react-18/discussions/18#discussion-3385714

src/hooks/useWebPushSubscribe.ts
const base64ToUint8Array = (base64: string | undefined): Uint8Array => {
  if (base64 === undefined) return new Uint8Array(0);

  const padding = '='.repeat((4 - (base64.length % 4)) % 4);
  const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');

  const rawData = window.atob(b64);
  const outputArray = new Uint8Array(rawData.length);

  for (let i = 0; i < rawData.length; i += 1) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
};

export const useWebPushSubscribe = (): void => {
  useEffect((): void => {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/service-worker.js')
      
      navigator.serviceWorker.ready.then((reg) => {
        reg.pushManager.getSubscription().then((subscription) => {
          if (subscription !== null) return;

          registration.pushManager
              ?.subscribe(subscribeOptions)
              .then((pushSubscription) => {
                // returnしてるだけですがここにserverにpushSubscriptionを登録するリクエスト処理を実装します
	              return {
                  endpoint: sub.endpoint,
                  p256dh: sub.keys.p256dh,
                  auth: sub.keys.auth
	        };
              });
        });
      });
    }
  });
};

その他

Nextでweb pushで検索すると next-pwa を使う記事が多くHITする
https://github.com/shadowwalker/next-pwa

これはメンテされておらず、有志がforkしてapp routerに対応したものがある
https://github.com/shadowwalker/next-pwa/issues/424
https://github.com/DuCanhGH/next-pwa

これを使うことで serviceWorker の登録を記載する必要がなかったり、Service Workerのコードを /src/worker/index.ts で管理してpublic以下に生成することができたりする。

Google製のWorkBoxなるライブラリを使っていてService Workerの各種機能を簡単な記述で使えるようになるみたいですが、今回はオフライン対応などは不要だったのでtoo muchだと判断し導入を見送りました

Discussion