Firebase Cloud Messaging(FCM)を利用したWebPush通知の実装について

最近FCMを利用したWebPush通知を実装する機会があったため、
その備忘録として簡単にまとめておきます。

# WebPushについて

FacebookやGmail、SlackやChatworkなどを利用されているとブラウザの画面右上に飛んでくる通知。
（※ 画像はサンプルで開発したWebPush通知のスクショです）

![FCMを利用したWebPush.png](https://qiita-image-store.s3.amazonaws.com/0/17346/261d2f2d-6907-ea64-2f9e-c46ecd21845d.png)

これをFCMを利用して実装してみました。

# FCMとは

FCMとは、Firebase Cloud Messagingの略で、
W3CをIETFで標準化が進むWebPushをFCMを利用して通知を送信できるようになりました。

これまでは、Firefoxでは事前のキーやIDが必要なくWebPushを送信出来ていましたが、
GoogleChromeではAndroidのアプリケーションと同様に
GoogleCloudMessaging（GCM）の登録やキーとIDの取得が必要な状態でした。

FCMでは、VAPIDによる認証を利用することで、
GCMのような登録を不要としたWebPushを実現することが可能になっています。

# WebPushの流れ

Push送信までの流れを簡単に説明すると

1. アプリケーションサーバで鍵を生成する
2. ブラウザは、アプリケーションサーバから公開鍵を受け取る
3. ブラウザは、取得した公開鍵を利用して、プッシュサーバにプッシュ通知の購読を登録する
4. アプリケーションサーバから、プッシュサーバに対して通知を送信する
   - この際にブラウザ側で取得したEndpointや認証情報は、アプリケーションサーバに渡しておく
5. プッシュサーバから対象のブラウザに対して通知を送信する
6. ブラウザは、ServiceWorkerでpushイベントを購読し、受信すれば何かしらの動作を行う


詳細については下記記事が分かりやすくまとまっていました。

- [GCMの登録が不要になったChromeのWeb Pushを試してみる
](https://firebase.google.com/docs/cloud-messaging/server)

# プログラム

Node.js ver7.6.0を利用して実装してみました。

## 認証鍵の生成と交換（アプリケーションサーバ）
下記のようにアプリケーションサーバ側で鍵を生成し、
ブラウザからAPIが呼ばれたときに公開鍵を返すようにしています。

```Node.js
const webpush = require('web-push');
const vapidKeys = webpush.generateVAPIDKeys();

app.get('/getpush', (req, res) => {
  return res.json({
    publicKey: vapidKeys.publicKey
  });
});
```

## プッシュ通知購読処理（ブラウザ）

```webpush.js
/**
 * npm web-push パッケージ サイトを参考
 *    https://www.npmjs.com/package/web-push
 */
function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);

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

/**
 * serviceWorkerからSubscriptionを取得する
 */
async function getsubscription() {
  var reg = await navigator.serviceWorker.ready;
  var sub = await reg.pushManager.getSubscription();
  return sub;
}

/**
 * Subscriptionを取得するためにサーバ側で生成された
 * WebPush送信のための公開鍵をAPI経由で取得する
 */
async function getPublicKey() {
  var res = await fetch('getpush', {
                    method: 'GET',
                    headers: { 'Content-Type': 'application/json' }
                  }).then((res) => res.json());
  console.log('APIのレスポンス');
  console.log(res.publicKey);
  return res.publicKey;
}

/**
 * サーバから取得した公開鍵を元に
 * ServiceWorkerからSubscriptionを取得する
 */
async function subscribe(option) {
  var reg = await navigator.serviceWorker.ready;
  var sub = await reg.pushManager.subscribe(option);
  return sub;
}

/**
 * サーバから公開鍵を取得し、
 * ServiceWorkerからSubscriptionを取得する
 */
async function initSubscribe() {
  var vapidPublicKey = await getPublicKey();
  let option = {
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
  }
  return await subscribe(option);
}

/**
 * ページの読み込みが完了すれば、
 * WebPushを受け取るための準備を行う
 */
window.addEventListener('load', async () => {
  navigator.serviceWorker.register('./serviceworker.js');
  var sub = await getsubscription();
  if (!sub) {
    // ブラウザに通知許可を要求する
    var permission = await Notification.requestPermission();
    new Notification('WebPushの設定をしました');
    if (permission === 'denied') {
      return alert('ブラウザの通知設定をONにしてください');
    } else {
      sub = await initSubscribe();
    }
  }
  console.log(sub);
});
```

## Push送信処理（アプリケーションサーバ）

```Node.js
app.post('/send/webpush', (req, res) => {
  // ブラウザ側でプッシュサーバから取得した、Endpointとp256dh、authの認証情報をセットする
  var pushSubscription = {
      endpoint: req.body.endpoint,
      keys: {
          p256dh: req.body.p256dh,
          auth: req.body.auth
      }
  };
  // 送信するメッセージは、Json形式で送る必要がある
  var message = JSON.stringify({
    title: req.body.title,
    body: req.body.body,
    icon: req.body.icon,
    link: req.body.link,
  });
  var options = {
    TTL: 10000,
    vapidDetails: {
      subject: req.body.link,
      // 先ほど生成したVAPIDの鍵情報をセットする
      publicKey: vapidKeys.publicKey,
      privateKey: vapidKeys.privateKey
    }
  }
  // npmのweb-pushライブラリを利用して、通知を送信する
  webpush.sendNotification(pushSubscription, message, options).then((response)=>{
    return res.json({
      statusCode: response.statusCode || -1,
      message: response.message || '',
    });
  // 通知送信時にフォーマットエラーや必須パラメータの欠如、送信先が不明な場合などにエラーを検知する
  }).catch((error) => {
    console.log(error);
    return res.json({
      statusCode: error.statusCode || -1,
      message: error.message || '',
    });
  });
});
```

## 通知受信設定（ブラウザ）

ServiceWorkerを利用して、通知イベントを検知したときの処理を記載しておきます。

```serviceworker.js

// ブラウザに対して通知イベントを検知した際に実行される処理を定義
self.addEventListener('push', (e) => {
  var data = e.data.json();
  var title = data.title;
  var options = {
    body : data.body,
    icon: data.icon,
    data: {
      link_to: data.link
    }
  };
  e.waitUntil(self.registration.showNotification(title, options));
});
// 通知バーがクリックされた際に実行される処理を定義
self.addEventListener('notificationclick', (e) => {
  e.notification.close();
  e.waitUntil(clients.openWindow(e.notification.data.link_to));
});
```



## 参考
- [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging/)
- [GCMの登録が不要になったChromeのWeb Pushを試してみる](http://qiita.com/tomoyukilabs/items/9346eb44b5a48b294762)
- [Web Pushでブラウザにプッシュ通知を送ってみる](http://qiita.com/tomoyukilabs/items/217915676603fda73b0a)
- [Firebase (FCM)を利用して Google Chrome にプッシュ通知を送信する](https://www.ipentec.com/document/document.aspx?page=software-google-chrome-send-push-messaging-and-notification-using-firebase)