Web Push機能のサンプルアプリをFCMで実装してみた

# はじめに
Mobile PushにFCMを使うことになったので、モバイルの知識がなくても出来るWeb Pushから始めることに。
ただ、執筆時点で推奨されているHTTP v1 APIでの使用にちょっとだけ苦労しました。
備忘録の意味も込めて手順を残しておきます。

# FCM（Firebase Cloud Messaging）とは
今回はサンプルアプリの構築手順がメインですので、FCM自体の詳細な説明については参考リンクの記事に譲ります。
概要だけ書くと、FCMは[Firebaseが提供するメッセージングサービス](https://firebase.google.com/docs/cloud-messaging?hl=ja)で、Android、iOS、WebブラウザなどにPush通知を飛ばすことができるようになります。
また、FCM APIへHTTPリクエストする際に事前登録したクライアントのトークンを指定することで、宛先を限定したPush通知が可能です。

# Web Pushを試してみよう
今回のサンプルアプリ作成に使用した環境はこちら。
* Googleアカウント（Firebaseサービス利用のため）
* MacOS Monterey（v12.2.1）
* Webブラウザ（Chrome 99.0.4844.51）
* Node（v16.13.0）

### Firebaseのプロジェクトを準備する
まずはFirebaseでFCMプロジェクトを作成。
Googleアカウントを使用して[Firebase](https://firebase.google.com)にログインしたら、トップ画面でプロジェクトを作成。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2521590/009488c8-2d3d-34a3-f65c-137f8b4d3e15.png)
プロジェクト名を入力すると、自動で一意のプロジェクトIDが割り当てられます。
この後Google Analyticsの有効/無効を任意に選択し、プロジェクトの作成を実行。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2521590/0d724755-cbec-4ce9-f325-d15ead594ae3.png)
作成したプロジェクトの概要ページで`</>`を選択し、Web Pushを使用するためのアプリを追加します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2521590/4bf18062-b164-dab7-f0d5-a661667ebc0f.png)
アプリのニックネームを適当に設定。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2521590/942ab71c-f714-331a-5381-8ccdd8a8415a.png)
SDKの追加方法で`<script>タグを使用する`を選択するとconfig情報（`firebaseConfig`）が表示されるので控えておきましょう。
![図1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2521590/698e9831-61dd-e862-cb79-42abff01ccdb.png)
Push通知に必要な秘密鍵は、`プロジェクトの概要`の右にある⚙アイコンから`プロジェクトの設定` → `サービス アカウント`タブと進み、`Node.js`を選択した状態で`新しい秘密鍵の生成`でDLしておきます。
ランダム文字列でファイル名が生成されますが、分かりやすいようここでは`service-account.json`としておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2521590/16cbc488-6fdf-9677-117c-4d3f7e8b7539.png)
また`Cloud Messaging`タブのウェブ設定にある`Generate key pair`でGCPサービス外へのPush通知に使用するVAPIDを生成し、こちらも控えておきましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2521590/25a755e6-ba02-491f-6e68-c1fdbfc70c9f.png)

### FCMからクライアントへトークンを発行する
FCMからクライアントのトークンを取得するためのサンプルアプリを実装します。
[公式のサンプル](https://github.com/firebase/quickstart-js)をCloneし、下のようにソースを適宜書き換えます。（差分のみ記載）
なお、`MY_…`は先ほど控えた設定値に書き換えます。
`firebase-messaging-sw.js`は[Service Worker](https://firebase.google.com/docs/cloud-messaging/js/receive?hl=ja)と言い、クライアントがバックグラウンドの際に実行される処理を定義します。
今回はサンプルの`onBackgroundMessage`をそのまま使用し、バックグラウンドの時にはPush通知がOS固有の通知センターに表示されるようにします。


```messaging/index.html
- <script src="/__/firebase/9.6.7/firebase-app-compat.js"></script>
- <script src="/__/firebase/9.6.7/firebase-messaging-compat.js"></script>
- <script src="/__/firebase/init.js"></script>
+ <script src="https://www.gstatic.com/firebasejs/9.6.8/firebase-app-compat.js"></script>
+ <script src="https://www.gstatic.com/firebasejs/9.6.8/firebase-firestore-compat.js"></script>
+ <script src="https://www.gstatic.com/firebasejs/9.6.8/firebase-auth-compat.js"></script>
+ <script src="https://www.gstatic.com/firebasejs/9.6.8/firebase-messaging-compat.js"></script>
:
:
<script>
+ firebase.initializeApp({
+   apiKey: "MY_API_KEY",
+   authDomain: "MY_AUTH_DOMAIN",
+   projectId: "MY_PROJECT_ID",
+   storageBucket: "MY_STORAGE_BUCKET",
+   messagingSenderId: "MY_MESSAGING_SENDER_ID",
+   appId: "MY_APP_ID",
+   measurementId: "MY_MEASUREMENT_ID",
+ });
:
:
function resetUI() {
  clearMessages();
  showToken("loading...");
  // Get registration token. Initially this makes a network call, once retrieved
  // subsequent calls to getToken will return from cache.
    messaging
-      .getToken({ vapidKey: "<YOUR_PUBLIC_VAPID_KEY_HERE>" })
+      .getToken({ vapidKey: "MY_PUBLIC_VAPID_KEY_HERE" })
```

```messaging/firebase-messaging-sw.js
- importScripts("/__/firebase/9.2.0/firebase-app-compat.js");
- importScripts("/__/firebase/9.2.0/firebase-messaging-compat.js");
- importScripts("/__/firebase/init.js");
+ importScripts("https://www.gstatic.com/firebasejs/9.6.8/firebase-app-compat.js");
+ importScripts("https://www.gstatic.com/firebasejs/9.6.8/firebase-messaging-compat.js");
:
:
+ firebase.initializeApp({
+   apiKey: "MY_API_KEY",
+   authDomain: "MY_AUTH_DOMAIN",
+   projectId: "MY_PROJECT_ID",
+   storageBucket: "MY_STORAGE_BUCKET",
+   messagingSenderId: "MY_MESSAGING_SENDER_ID",
+   appId: "MY_APP_ID",
+   measurementId: "MY_MEASUREMENT_ID",
+ });
```

ローカルサーバを立ち上げてindex.htmlにアクセスすると、トークン取得用の簡易アプリが表示されます。
しばらくするとFCMに登録したトークンが表示されるはずです。
このトークンも後で使用するので控えておきましょう。
![図1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2521590/790f083f-80a5-196a-1250-9071f0423fa3.png)

### Push通知を送信する
Push通知を送信するための仮想サーバプログラムを実装します。
先ほどDLした`service-account.json`はこのサーバプログラムのルート直下に置いておきます。
`MY_REGISTER_TOKEN`は先ほど控えたクライアントの登録トークン、`YOUR_APP_ID`はFCMのプロジェクトIDに書き換えます。

```postPushMessage.js
var google = require("googleapis");
var https = require("https");

function getAccessToken() {
  return new Promise(function (resolve, reject) {
    const key = require("./service-account.json");
    const jwtClient = new google.Auth.JWT(
      key.client_email,
      null,
      key.private_key,
      ["https://www.googleapis.com/auth/firebase.messaging"],
      null
    );
    jwtClient.authorize(function (err, tokens) {
      if (err) {
        reject(err);
        return;
      }
      postPushMessage(tokens.access_token);
    });
  });
}

function postPushMessage(accessToken) {
  const data = JSON.stringify({
    message: {
      token:
        "MY_REGISTER_TOKEN",
      notification: {
        title: "フォアグラウンド通知のタイトルだよ。",
        body: "フォアグラウンド通知の本文だよ。",
      },
    },
  });
  const options = {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: "Bearer " + accessToken,
    },
    body: data,
  };
  const url =
    "https://fcm.googleapis.com/v1/projects/YOUR_APP_ID/messages:send";
  const request = https.request(url, options, (response) => {
    console.log(`statusCode: ${response.statusCode}`);
  });
  request.write(data);
  request.end();
}

getAccessToken();
```

これで準備完了です。
実行してリクエストが成功することを確認します。
```
$ node ./postPushMessage.js
statusCode: 200
```

### 挙動確認
スクリプトを実行した際にブラウザがフォアグラウンドかバックグラウンドかで挙動が変わります。
フォアグラウンドの時には通知を受け取った旨がサンプルアプリに表示されます。
![図1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2521590/192c9775-b8c9-00f8-0752-41c3d008ba32.png)
バックグラウンドの時には通知センターに通知が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2521590/70fbaf48-5113-09e7-9a1f-d0ad0626b165.png)
お疲れ様でした。

# 旧HTTP APIと何が変わったの？
冒頭でも言いましたが、通知メッセージについては[旧来のHTTP APIと現行のHTTP v1 APIで色々と仕様が異なっており](https://firebase.google.com/docs/cloud-messaging/auth-server?hl=ja)、公式ドキュメントで[差分](https://firebase.google.com/docs/cloud-messaging/migrate-v1?hl=ja)を見ながら手探りで実装する必要がありました。
特に以下の違いを見つけるのに少々時間がかかってしまいました。
* Push通知リクエストの向き先エンドポイントが異なる
```:旧仕様
POST https://fcm.googleapis.com/fcm/send
```
```:新仕様
POST https://fcm.googleapis.com/v1/projects/YOUR_APP_ID/messages:send
```
* Push通知リクエスト用の認証には生成後不変なサーバーキーではなく一時的なトークンを使用する
* Push通知リクエスト用のBodyも書き方が異なる
```:旧仕様
{
  "to": "bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1...",
  "notification": {
    "title": "Portugal vs. Denmark",
    "body": "great match!"
  },
}
```
```:新仕様
{
  "message":{
    "token":"bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1...",
    "notification":{
      "title":"Portugal vs. Denmark",
      "body":"great match!"
    },
  }
}
```

参考になれば幸いです。

# 参考リンク
* [Firebase Cloud Messaging - Firebase Documentation](https://firebase.google.com/docs/cloud-messaging?hl=ja)
* [AndroidのPush通知(FCM)をサーバー知識無しで試してみよう - Qiita](https://qiita.com/flatfisher/items/bdec83caf3c7f9c8917c)
* [Firebase Cloud Messagingを使ってWeb Push通知を試してみる - Tdomy](https://tdomy.com/2020/06/web-push-using-firebase-cloud-messaging/)
* [FCMの仕様理解 - Zenn](https://zenn.dev/yukichi_tech/scraps/a64c09fd91fdf9)