VAPIDでWebPushを実装してみた

# WebPush

VAPIDと呼ばれる仕組みを使ってWebPushを実装する。VAPIDの仕組みそのものは下記記事を参照。
https://qiita.com/tomoyukilabs/items/9346eb44b5a48b294762

## 処理の流れ

大雑把な流れは

* クライアントでアプリケーションサーバから公開鍵の取得
* サービスワーカーをブラウザへ登録
* アプリケーションサーバの公開鍵をPushサーバに登録して、クライアント公開鍵・クライアント秘密乱数・エンドポイントを取得
* クライアント公開鍵・クライアント秘密乱数・エンドポイントをサーバに登録
* アプリケーションサーバで任意のタイミングでエンドポイントに向けてリクエストを送信
* PushサーバからブラウザにPush送信
* サービスワーカーでpushイベントをハンドルして通知

## 設計

VAPIDでPushを行うに当たって、下記の値が必要になる。

* アプリケーションサーバ秘密鍵
* アプリケーションサーバ公開鍵
* クライアント公開鍵
* クライアント秘密乱数
* エンドポイント

上記のうち、サーバの鍵はコマンドで生成しておく。

~~~
$ openssl ecparam -genkey -name prime256v1 -out private_key.pem
$ openssl ec -in private_key.pem -pubout -outform DER|tail -c 65|base64|tr -d '=' |tr '/+' '_-' >> public_key.txt
$ openssl ec -in private_key.pem -outform DER|tail -c +8|head -c 32|base64|tr -d '=' |tr '/+' '_-' >> private_key.txt
~~~

### テーブル作成

クライアント公開鍵、クライアント秘密乱数、エンドポイントの3つはクライアントを区別するために必要なため、DBに保存する必要がある。
アプリケーションのユーザIDと紐づけておく。

| カラム名 | 型 | 説明 |
| :--- | :--- | :--- |
| id | int(11) | pkey
| user_id | int(11) | ユーザのID |
| endpoint | varchar(2048) | エンドポイント |
| key | varchar(255) | クライアント公開鍵 |
| token | varchar(255) | クライアント秘密乱数 |

### web-push-php
アプリケーションサーバからPushサーバへのリクエストは[web-push-libs/web-push-php](https://github.com/web-push-libs/web-push-php)を使う。

~~~php
$auth = array(
	'VAPID' => array(
		'subject' => 'https://hoge.com',
		'publicKey' => $public_key,
		'privateKey' => $private_key,
	),
);

$webpush = new \Minishlink\WebPush\WebPush($auth);
$payload = [
	'title' => '通知があります。',
	'message' => $message,
	'url' => 'https://hoge.com/user/'.$user_id
];

$res = $webpush->sendNotification(
	$endpoint,
	json_encode( $payload ), // jsonで送ると、pushイベントでjsonでデータを取得できる。
	$key,
	$token,
	true
);
~~~

## サービスワーカー

Pushサーバから送られたPushはサービスワーカーで処理する。

### 登録

サーバから公開鍵を取得して、サービスワーカーを登録する。また、サービスワーカーの登録に成功したら、Pushサーバからsubscriptionの取得を行う。取得できなかった場合はサーバ公開鍵と一緒に登録を行う。

~~~js
var serverKey = urlB64ToUint8Array(data.result.server_key); // Util function from https://github.com/GoogleChrome/push-notifications/blob/master/app/scripts/main.js#L31-L44

navigator.serviceWorker.register('serviceworker.js', { scope: './' }).then(function (registration) {
	// getSubscription()で登録済みのsubscriptionを取得してみる
	registration.pushManager.getSubscription().then(function (subscription) {
		if (subscription) {
			registerSubscription(subscription);
			return;
		}

		// 登録済みのsubscriptionがない場合は新たに登録する
		registration.pushManager.subscribe({
			userVisibleOnly: true,
			applicationServerKey: serverKey
		}).then(function (subscription) {
			// Pushサーバに登録できたらsubscriptionをアプリケーションサーバにも登録する
			registerSubscription(subscription);
			return;
		})
	});
});
~~~

### イベントハンドリング
よく使うと思われるイベントは下記の2つ。

* pushイベント：送られてきたPushイベントをハンドルして、`registration.showNotification`で通知する
* notificationclickイベント：通知をクリックしたときの処理を記述する

アプリケーションサーバで`$payload`に入れてjsonで送ったデータはpushイベントの`event.data.json()`で取得できる。
また、showNotificationの第二引数に渡したオブジェクトは、notificationclickイベントの`event.notification`で取得する。

~~~js
self.addEventListener('push', function(event) {
	const data = event.data.json() // payloadで送ったデータの取得

	return event.waitUntil(
		self.registration.showNotification(
			data.title,
			{
				// ここのデータをevent.notificationで取得する (event.notification.bodyとか)
				icon: '/img/icon.png',
				body: data.message,
				data: {
					url: data.url
				}
			}
		)
	);
}, false);

self.addEventListener('notificationclick', function(event) {
	event.notification.close();

	const data = event.notification.data;
	event.waitUntil(clients.matchAll({
		type: 'window'
	}).then(function(clientList) {
		for (var i = 0; i < clientList.length; i++) {
			var client = clientList[i];
			if (client.url === data.url && 'focus' in client) {
				return client.focus();
			}
		}
		if (clients.openWindow) {
			return clients.openWindow(data.url); // $payloadで送ったurlを開く
		}
	}));
});
~~~