【解決】iOSでWebプッシュ通知が4件以降来ない

## はじめに

iOS 16.4からiOSのPWAでもWebプッシュ通知がサポートされました。

https://pantograph.co.jp/blog/production/pwa.html

それまでiPhoneでプッシュ通知を受け取るにはネイティブアプリにする必要がありましたが、Webプッシュに対応したことでWebアプリをPWA対応するだけでプッシュ通知が実装できるようになりました。
Androidは元々Webプッシュに対応しているので、iPhoneとAndroid同一のコードで実装できます。

ところがそう簡単にはいかず...

## 起こった事象

構成は通知サービスにFCM（[FirebaseCloudMessaging][fcm]）、通知の送信側をAWS Lambda、受信側はNuxt3で作成したPWAです。
ざっくり構成図はこんな感じ。LambdaからFirebaseAdminSDKを利用してFCMのAPIをコールしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1649149/96e74929-5d2b-b198-79d3-f49d7a8721de.png)

通知の送信側(Lambda)に送信処理のコードを、受信側(Nutx・PWA)に受信処理のコードをそれぞれ記述します（コードは後述）。

今回問題があったのは受信側の方で、Firebaseの公式ドキュメント通りに実装していました。

https://firebase.google.com/docs/cloud-messaging/js/receive?hl=ja

Androidでは公式通りのコードで問題なく受信できていたのですが、iOSの場合にフォアグラウンドの状態で通知を3件受け取ると、それ以降全く通知が来なくなるという事象が発生しました。
原因としては公式ドキュメント記載の以下の通りに記述すると、端末ごとに発行されるFCMトークンが3件目の通知受信後に勝手に更新され、サーバサイドで保持しているFCMトークンが無効になり通知が送れなくなっていました。受信ができないというよりそもそもFCMトークンが変わっているので送れていないといった感じですね。

```js
const messaging = getMessaging();
onMessage(messaging, (payload) => {
  console.log('Message received. ', payload);
  // ...
});
```

https://github.com/firebase/firebase-js-sdk/issues/8010

https://github.com/firebase/firebase-js-sdk/issues/6620



FirebaseSDKの問題っぽいですが解決されずに月日が流れ、今でもざわざわしています。
解決策は上記issueのコメントを参考にしました。
ちなみに「ios webプッシュ 通知来ない」「ios webプッシュ 3件」（英訳も）などと検索しても全くヒットせず、通知3件受信後にトークンが変わっている事象を長いデバッグの末にようやく気づき、やっと上記issueに巡り会えました。

解決に大変苦戦して涙を流したので、同じように苦しんでいる方の参考になればと思い解決方法を残します。

## 解決コード

解決したコードは以下の通りです。
iOS、Androidともに下記のコードで動作しました。

お急ぎの方用に該当の部分だけ先に載せます。

```firebase-messaging-sw.js
importScripts(
  "https://www.gstatic.com/firebasejs/11.0.1/firebase-app-compat.js",
)
importScripts(
  "https://www.gstatic.com/firebasejs/11.0.1/firebase-messaging-compat.js",
)
importScripts("env.js")

const firebaseApp = firebase.initializeApp(FIREBASE_CONFIG)

const messaging = firebase.messaging(firebaseApp)

self.addEventListener("push", function (event) {
  event.stopImmediatePropagation()
  console.log("[Service Worker] Push Received.")
  const payload = event.data.json()

  event.waitUntil(
    self.clients
      .matchAll({ type: "window", includeUncontrolled: true })
      .then((clients) => {
        const isForeground = clients.some(
          (client) => client.visibilityState === "visible",
        )

        // クライアントがフォアグラウンドの時のみ通知を表示
        if (isForeground) {
          const notificationTitle = payload.notification.title
          const notificationOptions = {
            body: payload.notification.body,
            icon: "/icons/192x192.png",
          }

          // 通知を表示
          return self.registration.showNotification(
            notificationTitle,
            notificationOptions,
          )
        }
      }),
  )
})
```

コードの詳細を解説していきます。

### 送信側（Lambda）

まずは送信側のコードです。pythonで実装しています。
送信側コードの説明は他いろんな記事で紹介されている実装と変わらないので省略します。

```python
fcm_token = "XXXXXXX" #端末ごとに払い出されるトークンを指定
try:
    webpush = messaging.WebpushConfig(
        notification=messaging.WebpushNotification(
            silent=False,
            vibrate=True,
        ),
    )

    message = messaging.Message(
        notification=messaging.Notification(
            title="通知テスト",
            body="プッシュ通知だよ",
        ),
        webpush=webpush,
        token=fcm_token,
    )

    response = messaging.send(message)
    print("Successfully sent message:", response)
except:
    print("Unregistered token")
```

### 受信側(PWA)

受信側で以下の4つの対応が必要になります（PWA対応も必要ですが省略）。

- FCMにアプリを登録
- アプリ情報をPWAに登録
- 端末ごとのFCMトークンを取得しサーバサイドに保存
- 通知受信時の処理を記述

#### FCMにアプリを登録

こちらの方の記事を参考にされてください。

https://qiita.com/nanin/items/f3382a871eb04bf6828e#firebase%E7%99%BB%E9%8C%B2

#### アプリ情報をPWAに登録

Firebaseから通知を受信するために、PWAに上記で作成したFirebaseアプリの情報を登録します。
下記コードはPWA起動時に読み込む必要があり、今回はNuxt3で実装したので`plugins`フォルダに`firebase.js`として保存しています。

```js
import { initializeApp } from 'firebase/app';

export default defineNuxtPlugin(() => {
  // Initialize Firebase
  // 値は環境変数から読み込み
  initializeApp({
      apiKey: process.env.VITE_APP_FIREBASE_API_KEY,
      authDomain: process.env.VITE_APP_FIREBASE_AUTH_DOMAIN,
      projectId: process.env.VITE_APP_FIREBASE_PROJECT_ID,
      storageBucket: process.env.VITE_APP_FIREBASE_STORAGE_BUCKET,
      messagingSenderId: process.env.VITE_APP_FIREBASE_MESSAGING_SENDER_ID,
      appId: process.env.VITE_APP_FIREBASE_APP_ID,
    },);
});
```

また、バックグラウンドで通知を受信するためにServiceWorkerへの登録も必要です。
公式ドキュメント通りに`firebase-messaging-sw.js`というファイル名で作成し、Nuxt3の場合は`public`フォルダに配置します。
他のフレームワークの場合も`favicon.ico`など静的ファイルを配置するディレクトリに置いてください。

```firebase-messaging-sw.js
importScripts(
  "https://www.gstatic.com/firebasejs/11.0.1/firebase-app-compat.js",
)
importScripts(
  "https://www.gstatic.com/firebasejs/11.0.1/firebase-messaging-compat.js",
)
importScripts("env.js")

const firebaseApp = firebase.initializeApp(FIREBASE_CONFIG)

const messaging = firebase.messaging(firebaseApp)
```

#### 端末ごとのFCMトークンを取得しサーバサイドに保存

FCMアプリをPWAに登録し、端末側でプッシュ通知を許可しておくと、端末ごとに一意のFCMトークンが割り振られるので、取得してサーバサイドに保存しておきます。
トークン取得処理が走ると通知許可されていない端末の場合自動で通知許可を求めるダイアログが表示されます。
ボタンを押した時にトークン取得処理を走らせる場合はこんな感じ

```vue
<template>
    <button @click="requestForToken">トークン取得</button>
</template>
<script setup>
import { getToken, getMessaging } from "firebase/messaging"

const messaging = getMessaging()

const requestForToken = async () => {
  return getToken(messaging, { vapidKey: config.vapidKey })
    .then((fcmToken) => {
      // fcmTokenの保存処理
    })
    .catch((err) => {
      console.log("An error occurred while retrieving token. ", err)
    })
}
</script>
```

送信側ではここで取得した`fcmToken`を指定して送信リクエストを行います。

#### 通知受信時の処理を記述

最後は通知の受信処理です。
`firebase-messaging-sw.js`に受信コードを追記します（一番上に載せたコードと同じです）。

```firebase-messaging-sw.js
importScripts(
  "https://www.gstatic.com/firebasejs/11.0.1/firebase-app-compat.js",
)
importScripts(
  "https://www.gstatic.com/firebasejs/11.0.1/firebase-messaging-compat.js",
)
importScripts("env.js")

const firebaseApp = firebase.initializeApp(FIREBASE_CONFIG)

const messaging = firebase.messaging(firebaseApp)

self.addEventListener("push", function (event) {
  event.stopImmediatePropagation()
  console.log("[Service Worker] Push Received.")
  const payload = event.data.json()

  event.waitUntil(
    self.clients
      .matchAll({ type: "window", includeUncontrolled: true })
      .then((clients) => {
        const isForeground = clients.some(
          (client) => client.visibilityState === "visible",
        )

        // クライアントがフォアグラウンドの時のみ通知を表示
        if (isForeground) {
          const notificationTitle = payload.notification.title
          const notificationOptions = {
            body: payload.notification.body,
            icon: "/icons/192x192.png",
          }

          // 通知を表示
          return self.registration.showNotification(
            notificationTitle,
            notificationOptions,
          )
        }
      }),
  )
})
```

FCMの公式ドキュメントがわかりづらいなと思ったのですが、フォアグラウンドでの受信時に通知を表示するにはコードの記述が必要で、バックグラウンドの受信は記述がなくてもServiceWorkerにFCMアプリを登録しておくだけで通知が表示されます。
今回はフォアとバックどちらも通知を表示する必要があったのでコードを書いていたのですが、iOSだとバックグラウンド時に同じ通知が2件表示されてしまう（自動で処理される分とコードを記述している分両方走ってるぽい）ので、フォアとバックを判断してフォアの時だけ表示処理が走るようにしています。

これでiOSでも3件以上通知が受け取れるようになりました。

## おわりに

iOSはPWAをあんまり推していないみたいなので、いろいろと対応していない機能も多いし挙動も安定しませんね。
今回の事象もiOS16.4のBeta版リリース時には発生していなかった記憶があるので（その時期に当初開発したので）、いつの間にかこんな挙動になっていて涙って感じです。
同じように涙を流している方の参考になれば幸いです。

ちなみにAndroidはフォアグラウンドのときは問題なく通知がきますが、端末スリープ状態だとスリープ中は通知が来なくて、スリープ解除時に一気に来るような挙動をします。
Androidの仕様？みたいですが、回避方法を知っている方がいればコメントで教えていただけると嬉しいです。


[fcm]: https://firebase.google.com/docs/cloud-messaging?hl=ja