ExpressとServiceWorkerを利用してPush通知を送る

## はじめに
PCのブラウザにslackのようなPush通知を送りたい場合、SSRの構成であれば、アプリケーションサーバーとクライアントとプッシュサーバーがあれば実現できる。<br>

プッシュサーバーはクライアントのブラウザごとに用意できるかどうかが異なり、Chrome Firefox Edgeなどが対応している。

Push通知を送るまでの流れとしては…

- クライアント側でPush通知受け取りの許可がされた場合必要な情報をアプリケーションサーバーに送る。
- アプリケーションサーバーでそれらの情報を保管しておく。
- Push通知を送る際に、保管情報をもとに、プッシュサーバーへリクエストを送る。
- プッシュサーバーからブラウザがPush通知を受け取る。

という形になる。

現在仕事でチャットシステムを開発しており、特定のユーザーに向けてメンションが送られた際、そのユーザーのデバイスにPush通知を送るようなシステムを開発したのでその手順を紹介する。

ようはslackのメンションを作りたい。

### 構成

サーバー

- Express
- SocketIO
  - Node.jsの環境でWebsocketの接続ができるようになるライブラリ
- web-push
  - サーバーからクライアントアプリに向けPush通知を流してくれるNPMパッケージ

DB

- MongoDB (CosmosDB)

クライアント

- Vue.js
- SocketIO-client
  - SocketIOへ接続するためのクライアント側のライブラリ
  - SocketIOとSocketIO-clientで統一したバージョン管理を行わないと動かない
- Axios
  - HTTPリクエストはAxiosで行う

## service workerの登録
システムをログイン制にし、チャットで他のユーザーへメンションを投げられるようにしてみる。<br>
その際、メンションをserviceWorkerの機能を利用しログインユーザーの端末にPush通知を送れるようにしてみたい。

VueでPWAを導入するための手順はNuxtで開発しているのか、vue-cliで開発しているのかで異なる。今回はvue-cliを利用していたため、そちらの方法を紹介する。

```cli
vue add @vue/pwa
```

を実行すれば既存のプロジェクトにpwaの機能を追加することができる。<br>
追加されるファイルに、registerServiceWorker.jsがあるが、これはブラウザにserviceWorkerを登録するjavascriptファイルになる。

```registerServiceWorker.js
import { register } from 'register-service-worker'

if (process.env.NODE_ENV === 'production') {
  register(`${process.env.BASE_URL}service-worker.js`, {
    ready () {
      console.log(
        'App is being served from cache by a service worker.\n' +
        'For more details, visit https://goo.gl/AFskqB'
      )
    },
    registered () {
      console.log('Service worker has been registered.')
    },
    cached () {
      console.log('Content has been cached for offline use.')
    },
    updatefound () {
      console.log('New content is downloading.')
    },
    updated () {
      console.log('New content is available; please refresh.')
    },
    offline () {
      console.log('No internet connection found. App is running in offline mode.')
    },
    error (error) {
      console.error('Error during service worker registration:', error)
    }
  })
}
```
それぞれのファンクションは登録時のステータスに応じて呼び出される。エラー時にどうしたいといったことはここでハンドルができる。<br>

また、vueはdevelopmentモードでのserviceWorkerの実行を推奨していないため、実際の登録はプロダクションで行う必要もある。<br>

[![Image](https://gyazo.com/69e382508d9d8cfccc844983ff8424ff/thumb/1000)](https://gyazo.com/69e382508d9d8cfccc844983ff8424ff)

serviceWorkerがちゃんと登録されているかどうかは開発者ツールのApplicationタブをみて、Sourceにファイルが登録されていればいい。

## Push通知許可の実装

よくwebで見かけるPush通知を許可するかどうかのボタンを実装する。これが許可されるとブラウザごとに設定されているPushサーバーからエンドポイントとなるURLとキーが発行される。サーバー側はPush通知を送りたい際にそのエンドポイントに向けてキーと一緒にPush通知として表示する内容を送り付けることができる。

[![Image](https://gyazo.com/f8383ebd51b176adf2b9ccb1575d8ac1/thumb/1000)](https://gyazo.com/f8383ebd51b176adf2b9ccb1575d8ac1)

[https://developer.mozilla.org/ja/docs/Web/API/PushManager/getSubscription](https://developer.mozilla.org/ja/docs/Web/API/PushManager/getSubscription)

`PushManager.getSubscription().then(subscription => {})`で`subscription`に`null`が入っていた場合、そのユーザーはPush通知を許可してなく、Push通知を許可していた場合は、subscriptionに上で述べた情報が格納される。これらの情報はこのPush通知を許可したユーザーに対して、Push通知を送るためにアプリケーションサーバーで保管しておく必要がある。<br>

```registerServiceWorker.js
 swRegistration.pushManager.getSubscription()
   .then(function(subscription) {
   // 通知が購読されたかどうか
     isSubscribed = !(subscription === null);
 // もし購読されていれば、アプリケーションサーバーへ購読者情報の登録
 if (isSubscribed) updateSubscriptionOnServer(subscription);
   });
```

## service workerの実装
serviceWorkerではイベントごとにコードを書くことになる。

```sw.js
 // push通知を受け取ったときの挙動
 self.addEventListener("push", function(event) {
   const data = JSON.parse(event.data.text());
   const title = data.title;
   // push通知のbody アイコンの情報を詰める。
   const options = {
     body: data.message, // 表示メッセージ
     icon: "../thumbnail/pwa/android-chrome-192x192.png", // アイコン
     badge: "../thumbnail/pwa/android-chrome-192x192.png", // バッチアイコン
   };
   event.waitUntil(self.registration.showNotification(title, options));
 });
```

また、serviceWorkerにはPush通知をクリックした際の挙動を記すことができ、アプリサーバー→Pushイベント→クリックイベントとその内容を指定することもできる。

## アプリケーションサーバーでPush通知の認証情報を受け取る
serviceWorkerで発行されるPush通知に必要な情報は以下のような情報である。

```json
 {
 "endpoint":"endpointURL",
 "p256dh":"...",
 "auth":"..."
 }
```
- endpoint - Push通知を送るためのPushサーバーのURL
- p256dh - ブラウザが発行した公開鍵
- auth - ユーザーエージェントとサーバー側で共有される共有鍵生成を難化するための乱数

開発しているシステムはログイン制のシステムなのでこれらの情報とユーザー情報を紐づけて保管している。ユーザーは複数のブラウザでシステムを扱う可能性があるためユーザーと認証情報は1:多の関係になる。<br>

## WebPushを用いてPush通知を送る
アプリケーションサーバーからPush通知を送るにあたってNode.js環境で利用できる[web-push](https://www.npmjs.com/package/web-push)を利用した。

- サーバー側でもあらかじめ公開鍵と秘密鍵のセットを生成しておき、それをweb-pushの`setVapidDetails`でセットしておく。
- 次にクライアントから受け取った`auth`、`p256dh`の鍵、endpointのURLをセットすればPush通知を実行できる。

```server.js
const webpush = require('web-push');
 // サーバー側の鍵情報を詰める
webpush.setVapidDetails(
  'mailto:example@yourdomain.org',
  vapidKeys.publicKey,
  vapidKeys.privateKey 
);
  
 // Push通知を送るクライアント側の情報を詰める
const pushSubscription = {
  endpoint: '.....',
  keys: {
    auth: '.....',
    p256dh: '.....'
  }
};
// Push通知を送る
webpush.sendNotification(pushSubscription, 'Your Push Payload Text');
```
Push通知は通常のHTTPリクエスト同様endpointのURLからPush通知の実行結果をHTTPステータスコードで受け取れるため、エラーハンドリングなどは各エンドポイントの仕様を確認すればできる。

腹が立つのがこのあたりの仕様がまったくエンドポイントのドメインごとに異なり、いちいち確認しなければいけないところ。

#### ユーザーがPush通知許可を取りやめたときのHTTPステータスコード
- chrome - 403
- firefox - 410

## 最終的に
あとはチャットの通信にWebPushを送る関数を呼び出せばチャットとPush通知機能がつながることになる。メンション先のユーザーがPush通知を許可していればブラウザから通知が表示されるようになる。<br>

実際に世界で最も優れたブラウザVivaldiが受け取ったメンションのスクショ<br>
[![Image](https://gyazo.com/da804bf7096b343a0f438d678ef05de7/thumb/1000)](https://gyazo.com/da804bf7096b343a0f438d678ef05de7)<br>
(開発中のシステム名とアイコンが映ったので修正を入れてる)<br>

### 参考
[ウェブアプリへのプッシュ通知の追加 | Web | Google Developers](https://developers.google.com/web/fundamentals/codelabs/push-notifications/?hl=ja)
Push通知に関するクライアント側の設定や書き方を大いに参考にした。

[@vue/cli-plugin-pwa](
https://github.com/vuejs/vue-cli/tree/dev/packages/@vue/cli-plugin-pwa#readme)
vue-cliでPWA環境を整えるために必要なモジュール

[web-push](https://github.com/web-push-libs/web-push#readme)
Node.js環境でPush通知を送るためのNPMパッケージ