AWS Amplify の ServiceWorker で Web Push 対応を実装してみる - クライアント編

Web Push、送りたいことありますよね。

この記事は Web Push 通知を使うアプリを AWS Amplify で作ってみる話です。
特にクライアント側を AWS Amplify でどう実装するかを見てみます。
当初サーバー側もあわせて書こうとしたのですが、分量が多くなりそうだったので今回は一旦クライアント編として、サーバー編はまた Amplify の API カテゴリを絡めたものを後日書きます（たぶん）。

# Web Push やりたいときにやらなきゃいけないこと

Web Push を運用しようとすると、けっこう色々やることがあります。

1. Push を受け取るクライアント側
 1. Service Worker の実装
 * 購読状態管理、Service Worker 登録等を行うフロントアプリケーションの実装
 * 購読処理時に払い出されるエンドポイントをサーバーサイドに送信
1. Push を送るサーバー側
 1. 秘密鍵・公開鍵の生成、管理
 * クライアントから送られてきたエンドポイント情報をサービスのユーザーと紐付けて保存、管理
 * [Web Push 送信用ライブラリ](https://github.com/web-push-libs/) を使って適切なタイミングで送信

等々です。Amplify の ServiceWorker クラスはこの中で、特に **1-2. 購読状態管理、Service Worker 登録等を行うフロントアプリケーションの実装** を助けてくれます。
また、その Amplify プロジェクトで Analytics カテゴリを有効にしている場合、ServiceWorker のライフサイクルイベントを自動で収集、可視化してくれる機能も Amplify が提供してくれます。

# 前提条件

## 仕様

今回は、

* ブロック済でなければページを開いたときにプッシュ通知購読の許可を求める
* 購読の停止、停止後の再購読を行う UI、機能は提供しない ※
* 現在の購読状況（未購読、購読済み、ブロック済み）を表示する
* Push 通知がサポートされていない環境（Safari 等）ではそう表示する
* 購読済みの場合は、プッシュ通知に必要なサブスクリプション情報を表示する

というモノを実装することにします。

※ 実際のサービス運用時には、購読停止の際に Unsubscribe() の実行、サーバーサイドのデータベース更新等が必要になるでしょう

## 参考にした資料

この記事は以下２つのドキュメント・チュートリアルを参考にしています。

1. [Service Workers - Amplify JavaScript](https://aws-amplify.github.io/docs/js/service-workers)
1. [ウェブアプリへのプッシュ通知の追加 - developers.google.com](https://developers.google.com/web/fundamentals/codelabs/push-notifications/)

この記事内では AWS Amplify が関わる Web Push の登録・受信部分のみに触れますが、実際のアプリケーションではユーザーの体験を考える上でどんなときにどう購読 ON/OFF の UI を表示すべきかなど、もう少し考えることがあります。
上の 2. のチュートリアルはそういう面にも触れられているのでぜひご覧ください。

## 作業環境

今回は Vue project をベースにします。

```shell-session

$ node -v
v12.13.1
$ npm -v
6.9.0
$ npm install -g @vue/cli
...
$ vue --version
@vue/cli 4.1.1
$ npm install -g @aws-amplify/cli
...
$ amplify --version
4.5.0
```

使った環境、ブラウザは↓です。なお、Safari は現在 Push 通知に対応していません。
* macOS High Sierra 10.13.6
* Google Chrome ver.78
* Firefox ver.68.3

# 実装していき

## Vue プロジェクト、Amplify プロジェクトの作成

```shell-session
$ vue create amplifywebpush
? Please pick a preset: (Use arrow keys)
❯ default (babel, eslint)

...

$ cd amplifywebpush
$ npm install aws-amplify aws-amplify-vue
$ amplify init
? Enter a name for the project amplifywebpush
? Enter a name for the environment dev
? Choose your default editor: Vim (via Terminal, Mac OS only)
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using vue
? Source Directory Path:  src
? Distribution Directory Path: dist
? Build Command:  npm run-script build
? Start Command: npm run-script serve
? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use yourprofilename
...

$ tree -L 1
.
├── README.md
├── amplify
├── babel.config.js
├── node_modules
├── package-lock.json
├── package.json
├── public
├── src
└── yarn.lock

4 directories, 5 files
```

はい

## 必要な Amplify のモジュール、カテゴリの追加

まず、ServiceWorker クラスは `@aws-amplify/core` パッケージに、Analytics は `aws-amplify/analytics` に入っているのでインストールします。

```shell-session
$ npm install @aws-amplify/core
$ npm install @aws-amplify/analytics
```

次に、Service Worker のライフタイムイベントを集計するため、アプリケーションに Analytics カテゴリを追加します。途中の選択肢は全てデフォルトで Enter を押しています。

```shell-session
$ amplify add analytics
$ amplify push
✔ Successfully pulled backend environment dev from the cloud.

Current Environment: dev

| Category  | Resource name   | Operation | Provider plugin   |
| --------- | --------------- | --------- | ----------------- |
| Auth      | cognito57ed5053 | Create    | awscloudformation |
| Analytics | amplifywebpush  | Create    | awscloudformation |
? Are you sure you want to continue? (Y/n) Yes

...

$ amplify status
```

`src/main.js` で Amplify を初期化します。

```javascript
import Vue from 'vue';
import App from './App.vue';
import awsconfig from './aws-exports';
import Amplify, * as AmplifyModules from 'aws-amplify';
import { AmplifyPlugin } from 'aws-amplify-vue';

Amplify.configure(awsconfig);
Vue.use(AmplifyPlugin, AmplifyModules);
Vue.config.productionTip = false;

new Vue({
  render: h => h(App)
}).$mount('#app');
```

この時点では何も UI をいじっていないので、デフォルトの Vue プロジェクトの画面が表示されます。

```shell-session
$ amplify run

...

  App running at:
  - Local:   http://localhost:8080/
  - Network: http://192.168.0.1:8080/

  Note that the development build is not optimized.
  To create a production build, run yarn build.
```

![vue.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1490/bda871c5-6701-43f1-4fa9-ab36077df848.png)

はい

## ServiceWorker の実装

まず、`public/service-worker.js` を次のように実装してみます。

```javascript
/**
 * Push 通知の受信時に発火するイベント
 */
addEventListener('push', (event) => {
  console.log('[Service Worker] Push Received.');
  console.log(`[Service Worker] Push had this data: "${event.data.text()}"`);

  if (!(self.Notification && self.Notification.permission === 'granted'))
    return;

  let data = event.data ? event.data.json() : {};

  let title = data.title || "Web Push Notification";
  let message = data.message || "New Push Notification Received";
  let icon = "path/to/image";
  let badge = "path/to/image";
  let options = {
    body: message,
    icon: icon,
    badge: badge
  };
  event.waitUntil(self.registration.showNotification(title, options));
});

/**
 * 通知がクリックされたときに発火するイベント
 * 通知ごとに適切なクリック時の処理を記述。
 * 今回は Amplify JavaScript のドキュメントを開くという処理にする。
 */
addEventListener('notificationclick', (event) => {
  console.log('[Service Worker] Notification click: ', event);
  event.notification.close();
  event.waitUntil(
    clients.openWindow('https://aws-amplify.github.io/amplify-js')
  );
});
```

これはかなり最小限の実装ですが、色々な API やキャッシュ機能を使った例としては [Example Service Worker - Amplify JavaScript](https://aws-amplify.github.io/docs/js/service-workers#example-service-worker) や [ウェブアプリへのプッシュ通知の追加 - developers.google.com](https://developers.google.com/web/fundamentals/codelabs/push-notifications/) を参照するといいと思います。

## ServiceWorker の登録

で、`src/App.vue` を次のように実装します。

```vuejs
<template>
  <div>
    <h2>Amplifyで作るWebPushアプリ</h2>
    <p>{{ state }}</p>
    <p>{{ endpointInfo }}</p>
  </div>
</template>

<script>
import { ServiceWorker } from 'aws-amplify';

const serviceWorker = new ServiceWorker();
const yourPublicKey = 'Paste your public key here';

export default {
  name: 'app',
  data(){
    return {
      registeredServiceWorker: null,
      state: '',
      endpointInfo: '',
    }
  },
  methods :{
    isPushSupported() {
      return ('serviceWorker' in navigator && 'PushManager' in window)
    },
    async updateUI() {
      if (!this.isPushSupported()) {
        this.state = 'Push 通知がサポートされていない環境';
        this.endpointInfo = '';
        return;
      }

      // 購読状況によって UI を変える
      if (Notification.permission == 'denied') {
        // すでに拒否されている
        this.state = 'ブロック済';
        this.endpointInfo = '';
      } else {
        var subscription = await this.registeredServiceWorker.pushManager.getSubscription();
        if (subscription) {
          // 購読済み
          this.state = '購読済';
          this.endpointInfo = JSON.stringify(subscription);
        } else {
          // 未購読
          this.state = '未購読';
          this.endpointInfo = '';
        }
      }
    },
  },
  async mounted(){
    this.registeredServiceWorker = await serviceWorker.register('/service-worker.js', '/');

    if ('permissions' in navigator) {
      let notificationPermission = await navigator.permissions.query({name:'notifications'});
      notificationPermission.onchange = () => {
        this.updateUI();
      };
    }

    if (Notification.permission !== 'denied') {
      await serviceWorker.enablePush(yourPublicKey);
    }
    this.updateUI();
  },
}
</script>
```

`serviceWorker.enablePush(yourPublicKey)` は Push 通知の購読をユーザーに確認し、許可されたら購読処理をすすめるメソッドです。サーバー側で生成する公開鍵を引数に渡しています。この値はあとで置き換えます。

これらの行は、割と Amplify が処理を隠蔽してくれているところで、例えば公開鍵は URL エンコードされた Base64 文字列を `UInt8Array` に変換してから使う必要がありますが、それは `enablePush()` メソッドが内包しています。
参考: [該当部分の実装 - github.com/aws-amplify/amplify-js](https://github.com/aws-amplify/amplify-js/blob/5c206308452023ca44b21162272e13f86ea6d59d/packages/core/src/ServiceWorker/ServiceWorker.ts#L111-L165)

この状態で、`amplify run` してから Chrome や Firefox でページを開くと、おなじみ？の通知許可が求められます。 `serviceWorker.enablePush(yourPublicKey)` による動作です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1490/ba47a84d-ce27-63fb-afe2-69c447620e0f.png)


## 公開鍵の取得と模擬的なサーバーサイドの用意

冒頭で書いたとおり、今回はフロント側について書きたいので、バックエンドは手軽に鍵生成や Push 送信のテストができるコンパニオンサイト、[Push Companion](https://web-push-codelab.glitch.me/)を使います。

[Push Companion](https://web-push-codelab.glitch.me/)を開くと、Public KeyとPrivate Keyが表示されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1490/8e9faf98-e552-1f44-78ed-57a07650e19a.png)

Public Key をコピーして、`src/App.vue` に反映します。

```javascript
[-]
const yourPublicKey = 'Paste your public key here';

[+]
const yourPublicKey = 'BDirXoCByCLittjLnybgMtlmAl1Oc52zE--QIgU378Z7ljkoiWDjy2F*****************************';
```

## アプリケーションを起動して購読してみる

```shell
$ amplify run

...

  App running at:
  - Local:   http://localhost:8080/
  - Network: http://192.168.10.13:8080/
```

ブラウザで `http://localhost:8080/` を開き、通知許可のダイアログを表示します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1490/ba47a84d-ce27-63fb-afe2-69c447620e0f.png)

ここで許可（Allow）すると、画面上に JSON 文字列が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1490/bea1ba6c-7f4b-63f7-261f-ad8657c9bb0e.png)

この JSON 文字列は Subscription 情報などと呼ばれ、サーバーサイドから Push を送信するときの宛先情報にあたるものです。 

実際のサービスでは、この後 subscriptionInfo をサーバーサイドに送り、ユーザー情報と紐付けてデータベースに保存しておくなどして、必要なときに必要なユーザーに Push を送信することができるよう管理する必要があるでしょう。

## Push を送信、受信してみる

では、いよいよ Push 通知を受信してみます。また[コンパニオンサイト](https://web-push-codelab.glitch.me/)に頼ります。

"Subscription to Send To" に、先程の SubscriptionInfo 文字列をコピーして、"Text to Send" に好きなメッセージを書いて "SEND PUSH MESSAGE" をクリックします。単なる文字列を送ってもいいのですが、通常 JSON で送ることが一般的で、今回の Service Worker 側でもそれを期待しているので、次の内容を送ってみます。

`{"title": "Hello Amplify!!!", "message": "Amplifyかわいい"}`

---
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1490/3242a136-5375-7f14-7435-0316b7d3c0f0.png)
---

うまく実装されていれば、次のような通知が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1490/ad560f78-c23e-6ab9-ac69-7a4f3567efbf.png)

Vue アプリケーションのタブを閉じていたり、別 Window を表示したりしていても通知が表示されるでしょうか？通知をクリックしたとき、ちゃんと `service-worker.js` で記述した処理が実行されているでしょうか？確認してみてください。

期待通りに動いていれば、これで Amplify を使ったクライアントサイドの Web Push 通知対応ができました。

## Analytics の集計情報を見てみる

最初に、Service worker のイベントを集計するために Analytics カテゴリを追加してありました。ちゃんと動いているか見てみましょう。

```shell-session
$ amplify console analytics
```

Amazon Pinpoint のマネジメントコンソールが開いたら、 Analytics > Events を見てみます。
Filters を有効にすると、次のようなイベントが収集されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1490/ccc00070-7422-feeb-859e-4746ae4f3229.png)


Amplify が自動的に Service worker のライフサイクルや状態変化、メッセージングなどのイベントを集計してくれています。

## 補足

### 開発中に購読状態を変更したいとき

開発中は、一度購読を許可した後にもう一度もとに戻したい、拒否を取り消したいときがたくさんあると思います。
Chrome のアドレスバーのアイコンをクリックすると、そのサイトに対する購読状態を変更することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1490/8e8eab8f-0ab4-4f23-c21b-2e328e701fd9.png)


### サーバーサイドの Push 送信実装について

今回はコンパニオンサイトに頼りましたが、実際にサーバーサイドからの Push 送信を自分で実装するときは Web Push 用のライブラリを使って実装することが一般的かと思います。

https://github.com/web-push-libs/

言語別のライブラリがあるので、必要のある方は見てみてください。

その辺の実装は、後日書くサーバー編で触れたいと思います。

### その他考慮すること

前提条件にも書きましたが、今回の記事はクライアント側の Amplify に関わる部分にフォーカスしています。
サービスを運用する際は、これら以外に購読済みのユーザーが購読を停止するための UI や処理、ユーザーの Subscription 情報や状況を保存するバックエンドのデータベース等が必要になると思われます。
その辺も後日書くサーバー編に（ｒｙ

また、Appiterate による調べでは、不適切で不快な通知はユーザー離反の最も大きな要因になり得ます。ユーザー体験を十分に設計する必要があります。

[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1490/3101ddbc-a1c5-dd68-c031-a625e2e4cbea.png)](https://www.slideshare.net/AmazonWebServicesJapan/aws-startup-2019/45)
[[AWS Start-up ゼミ] よくある課題を一気に解説！ 御社の技術レベルがアップする 2019 春期講習](https://www.slideshare.net/AmazonWebServicesJapan/aws-startup-2019/45)



# まとめ

* Amplify の `ServiceWorker` クラスは Web Push 購読処理を隠蔽して、ちょっと手軽にしてくれる
* Amplify で `Analytics` カテゴリを有効にしていると、自動的に Service worker の挙動をトラッキングしてくれる

みんなも Amplify で Web Push 処理しましょう。