Firebase Cloud MessagingによるWebプッシュ通知のメモ

## はじめに

最近、Firebase Cloud Messaging(以下FCM)によるWebプッシュ通知の機能開発を試みた。のだが、色々あってあまりうまい具合に動かず、結局[OneSignal](https://onesignal.com/)を使う形で舵を切りなおした。が、Firebase Cloud Messagingを使った機能開発で培った試行錯誤のノウハウをそのまま失ってしまうのは惜しいと考え、ここに備忘録という形で供養する。誰かのためになればいいなと思います。  

## 必要なPermission

Google CloudのAPI Keyの画面で、API Keyに付与するPermissionを選択することになると思うが、FCMでデバイストークンを発行する・そのデバイストークン向けにWebプッシュ通知を送信するために必要なPermissionは以下4点。これ以外は必要ない。ただ、「少なくともこのPermissionがあれば最低限動いた」という意味であって、この中にも不要なものがあるかもしれない。
  - Firebase Management API
  - FCM Registration API
  - Firebase Cloud Messaging API
  - Firebase Installations API

## デバイストークンの発行、管理、その他

- PWA化は以下の記事が参考になりました。ありがとうございます。
  https://qiita.com/Coa3/items/c81c8c7984bd5fc4ec69
  - なお、Next.jsのWebアプリをiOS向けにPWA化するだけなら、`manifest.json`やアイコン類をつくって`/public`配下に格納しておくだけでよく、`next-pwa`のインストールは必要ない。これは公式ドキュメントでもそんなようなことが書かれている(公式ドキュメントでは`app/manifest.ts`を作るサンプルを提示しているが要するに`manifest.json`つくって配置するのと同じなのでまあ好みの問題だと思う)
    https://nextjs.org/docs/app/building-your-application/configuring/progressive-web-apps
- Webプッシュ通知に関しては以下の記事が参考になりました。ありがとうございます。
  https://qiita.com/nanin/items/f3382a871eb04bf6828e
  - ただし必要なのは`firebase-messaging-sw.js`のみで、`service-worker.js`はなくてもデバイストークン発行可能だった。
- デバイストークン発行するためにフロント側（Next.jsではClient-Components）でFirebaseのオブジェクトを生成することになるが、これにあたり指定が必要なフィールドは`apiKey`、`projectId`、`messagingSenderId`、`appId`の4つだけで、他のプロパティ(`authDomain`と`storageBucket`)は指定不要だった。それと`getToken`の引数で渡す`vapidKey`が必要。つまり、部分的には省くが以下の実装で`token`の変数にデバイストークンが発行されて格納される。
  ```javascript
  const firebaseConfig = {
    apiKey: process.env['NEXT_PUBLIC_FIREBASE_API_KEY'],
    //authDomain: process.env['NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN'],
    projectId: process.env['NEXT_PUBLIC_FIREBASE_PROJECT_ID'],
    //storageBucket: process.env['NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET'],
    messagingSenderId: process.env['NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID'],
    appId: process.env['NEXT_PUBLIC_FIREBASE_APP_ID'],
  };
  if ('serviceWorker' in navigator) {
                          await navigator.serviceWorker.register('/firebase-messaging-sw.js');
  }
  ...
                      const app = initializeApp(firebaseConfig);
                      const messaging = getMessaging(app);
  ...
                      const token = await getToken(messaging, {
                                      vapidKey: process.env["NEXT_PUBLIC_FIREBASE_VAPID_KEY"] ,
                                      serviceWorkerRegistration: await navigator.serviceWorker.ready,
                                    });
  ```
- 「FCMに登録されているデバイストークンの一覧」を取り出すためのAPIはないらしい。
   https://stackoverflow.com/questions/43060846/
   デバイストークンの保管は実装者の責任でやれということだそうだ。それは別にいいんだがFCMって際限なくデバイストークン発行可能ってわけでもないのではないのだろうか。調べられてない。
   - 使われてないトークンは270日で期限切れにするという記述はある。
     https://firebase.google.com/docs/cloud-messaging/manage-tokens?hl=ja
- インストールしたPWAをアプリとして削除するとメッセージは届かなくなるがFCM SDKでの送信結果はsuccessになる。試してみた感じ、後述する送信データ形式不正の件も含め、「実際には届かないがSDKの実行結果は成功扱いになる」ケースはちょこちょこ起きる。多分FCM自身も最終的に「実機に届ける」部分までは把握しきれてないんじゃないか（SDKの範疇で実行に成功していればsuccessと返す仕様なんじゃないか）と予想する。個人の意見です。

## FCMによるWebプッシュ通知

- デバイス単体に送信するなら`https://fcm.googleapis.com/v1/projects/[プロジェクトID]/messages:send`に`curl`なり`fetch`なりすればよい。ここに書いてあります。→https://firebase.google.com/docs/reference/fcm/rest?hl=ja
    - ただ、上記の通り、これは「単体」にのみしか送信できない。昔の記事とか読むと`tokens`フィールドに`string`の配列でデバイストークン並べれば送信できるような記述もチラホラ見かけるが、少なくとも試した限りではそうしたことはできなかった。後述するSDK使うと内部的にはどうやらAPI呼んでるっぽいのでAPIのリソースにも「複数端末への送信」に相当するものがありそうな気がするが、とりあえず公式には公開されていない。複数端末への送信も、`for`でグルグル回しながらその分このAPIコールすれば出来なくはない、が…
 - 単発で複数端末に送信するなら`firebase-admin/messaging`インストールして`messaging.sendEachForMulticast`を使う。ただしこれも最大500人のデバイストークンが対象となる。501以上渡すどうなるのかはわからない。こういう場合は500で配列区切ってループをネストするしかない、のかな？なお、昔の記事だと`sendAll`とか`sendMulticast`とか使う例が出てくるがこれは2024年12月現在どうやら廃止されている→https://firebase.google.com/docs/reference/admin/node/firebase-admin.messaging.messaging.md
- メッセージのペイロードにあたる[`BaseMessage`クラス](https://firebase.google.com/docs/reference/admin/node/firebase-admin.messaging.basemessage.md)は全部のフィールドが必須ではないからか、意味不明なオブジェクト渡してもSDKでの実行自体はエラーなく動いてしまう（「送信できた」としてレスポンスが返ってくる）。例えば以下のオブジェクトは実際には端末にはWebプッシュ通知としては届かないが、レスポンスは`"success"`で返ってくる:
  ```javascript
  const admin = require('firebase-admin');
  const {initializeApp, } = require('firebase-admin/app');
  const registrationTokens = [
      'eLKZxsvF3jkwGqVn691234:AP...',
  ];
  (async()=>{
    try {
          const app = initializeApp({
            credential: admin.credential.cert({
                projectId: process.env['FIREBASE_PROJECT_ID'],
                clientEmail: process.env['FIREBASE_CLIENT_EMAIL'],
                privateKey: process.env['FIREBASE_PRIVATE_KEY'].replace(/\\n/g, '\n'),
            }),
        });
        const messaging = getMessaging(app);

        // notificationとdataオブジェクトはbodyで囲う必要がない（そんな仕様はない）のでこれは送信データとしては形式不正
        const messageForMultipleDevices = {
            body: {
                notification: {
                    title: 'test title by sendMulticast',
                    body: 'test body by sendMulticast:' + new Date().toUTCString(),
                },
                data: {
                    test: 'data.test by sendMulticast',
                },
            },
            tokens: registrationTokens,
        };
        const r = await messaging.sendEachForMulticast(messageForMultipleDevices);
        console.log(`result:`);
        console.log(JSON.stringify(r));
        
    } catch(error) {
        throw error;
    }
  })();
  ```
  これを実行すると
  ```bash
  result:
  {"responses":[{"success":true,"messageId":"projects/[project id]/messages/[uuid]"}],"successCount":1,"failureCount":0}
  ```
  となって、正常に送信できたようなレスポンスを返してくるが、データ形式が不正なので（端末に届けるべき送信メッセージのペイロードが存在しないので）当然実機には何も届かない。ちなみにデータ形式としては以下が正解。(これで実機にWebプッシュ通知のメッセージが届いた実績があります。)
  ```javascript
          const messageForMultipleDevices = {
            notification: {
                title: 'test title by sendMulticast',
                body: 'test body by sendMulticast:' + new Date().toUTCString(),
            },
            data: {
                test: 'data.test by sendMulticast',
            },
            tokens: registrationTokens,
        };
  ```
     - ドキュメントには特に明記されていないが、実際に何度か試してみたところでは、上記のように送信データに不正なオブジェクトを渡して送信すると（実際には届かないがSDKのレスポンスはsuccessになるような実行をすると）、どうもそのときに送信先に指定したデバイストークンがFCMの中でしれっと「無効」な扱いにされてる（以後、そのデバイストークンには正当なメッセージオブジェクトを渡したとしても通知が届かなくなる）気がする。実際、そのあとデバイストークン再発行したら別の値になった。ここは実体験によるものであり、仕様を言及するものではないですが、気になったので書いておきます。