概要
minishlink/web-push を利用したWebブラウザのプッシュ通知の動作確認を行う。(PCのみ)
DockerコンテナではなくPHPのビルトイン開発用サーバで実行する。
PHP:8.3.7
OS:MacOS Sonoma 14.5
Safari:17.5
Chrome:127.0.6533.120(Official Build) (arm64)
Firefox:129.0.1 (64 ビット)
参考資料
Service Worker の概要 (Chrome for Developers)
mdn/serviceworker-cookbook(プッシュ通知の例)
web-push-libs/web-push-php: Web Push library for PHP
JSとPHPでWebPushを送信するWebアプリケーションを作ってみる
Web Pushを試してみた(フロントエンド編) | KEYPOINT
大事なこと
MDNによると、
「すべてのプッシュ通知は有益で時刻に敏感なものであるべきであり、ユーザーには最初のプッシュ通知を送信する前に必ずその許可を求め、今後プッシュ通知を取得しないようにする簡単な方法を提供する必要があります。」
とのこと。
覚え書き
ServiceWorkerが利用できるのは、localhost以外ではhttps通信のみ。
プッシュサーバと通信せずJSだけで通知を表示する
概要
実際に使うことはないだろうけれども、理解を深めるために実行した。
前もって指定した通知が表示できることを確認できる。
index.html の内容
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>BasicTest1</title>
<script src="app.js"></script>
</head>
<body>
BasicTest1<br>
<button
type="button"
onclick="handleClick()"
>
通知を表示
</button>
</body>
</html>
app.js の内容
async function handleClick (event) {
const result = await Notification.requestPermission();
switch (result) {
case 'default':
// 通知の許可を得るダイアログで×ボタンが押下され、許可も拒否もされなかった場合。
// または通知の許可を得るダイアログが表示される前。
break;
case 'denied':
// 通知の許可を得るダイアログで拒否を選択された場合。
// または過去に通知の許可を得るダイアログで拒否が選択された場合。
break;
case 'granted':
// 通知の許可を得るダイアログで許可が選択された場合。
// または過去に通知の許可を得るダイアログで許可が選択された場合。
await navigator.serviceWorker.register('serviceWorker.js');
const swr = await navigator.serviceWorker.ready;
await swr.showNotification('title1', {
body: '内容',
});
break;
}
}
serviceWorker.js の内容
※ プッシュサーバと通信しないで良いなら、空白で良い。
// コード無し。
動作確認
Chrome, Firefox, Safari で動作確認をする。
余談ですがコードを試している間、下記の現象があった。
- Chromeだと動くコードでもFirefoxで動かないことがあった。
- Chromeだと動くコードでもSafariで動かないことがあった。
- Firefoxだと動くコードでもSafariで動かないことがあった。
プッシュサーバと通信して通知を表示する
minishlink/web-push をインストール
% composer require minishlink/web-push
公開鍵と秘密鍵のペアを生成
シェルから実行する場合と、PHPのコードから実行する場合の、2つの方法がある。
Linuxのシェルからopensslコマンドを利用して生成するには下記コマンド。
$ mkdir keys && cd keys
$ 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
※ public_key.txt に公開鍵が保存される。
※ private_key.txt に公開鍵が保存される。
PHPのコードから実行する場合には下記コードを実行する。
<?php
require_once __DIR__ . '/vendor/autoload.php';
use Minishlink\WebPush\VAPID;
$keys = VAPID::createVapidKeys();
printf("publicKey = %s%s", $keys['publicKey'], PHP_EOL);
printf("privateKey = %s%s", $keys['privateKey'], PHP_EOL);
コードの準備
ディレクトリ構成
BasicTest2
├ vendor
│ └ ...
├ app.js
├ composer.json
├ composer.lock
├ index.html
├ manual_send_push_notification.php
├ receive_subscription.php
├ router.php
├ send_push_notification.php
├ serviceWorker.js
└ subscription.txt
各ファイルの概要
- router.php
PHPのビルトイン開発用サーバを利用する場合の、簡易ルーティングを行う。 - index.php
表示するページ。ここから各種動作確認を行う。 - app.js
サービスワーカーの登録、プッシュサーバへサブスクリプション登録、アプリケーションサーバへ登録したサブスクリプションの情報等を送信するイベントハンドラがある。 - serviceWorker.js
プッシュサーバから受信したデータをもとに通知等を行うイベントハンドラがある。 - receive_subscription.php
プッシュサーバへ登録したサブスクリプション等の情報を保存する。 - send_push_notification.php
プッシュ通知を実施するためのAPI。 - manual_send_push_notification.php
手動でプッシュ通知を実施するためのスクリプト。
router.php の内容
<?php
if ($_SERVER['REQUEST_URI'] === '/') {
require_once 'index.html';
} else {
$fileName = mb_substr($_SERVER['REQUEST_URI'], 1);
if (preg_match('|.js$|u', $fileName) === 1) {
header('Content-Type: text/javascript');
}
require_once $fileName;
}
index.html の内容
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>BasicTest2</title>
<script type="text/javascript" src="app.js"></script>
</head>
<body>
BasicTest2<br>
<br>
<button
type="button"
onclick="getPermission()"
>
通知の許可を取得
</button>
<br>
<br>
<button
type="button"
onclick="showNotification()"
>
通知を表示
</button>
<br>
<br>
<button
type="button"
onclick="updateSw()"
>
サービスワーカーを更新
</button>
</body>
</html>
app.js の内容
// 公開鍵。
const applicationServerKey = '生成した公開鍵の文字列';
// 公開鍵を編集する関数。minishlink/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;
}
async function getPermission() {
// 通知の許可を取得。
const result = await Notification.requestPermission();
if (result !== 'granted') {
// 許可以外だった場合は何もしない。
return;
}
// サービスワーカーを登録。
await navigator.serviceWorker.register('serviceWorker.js');
// アクティブになったサービスワーカーを取得。
const swr = await navigator.serviceWorker.ready;
// プッシュサーバへ登録。
const subscription = await swr.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
});
// 暗号化方式を確認。
let contentEncoding = 'aesgcm';
if (PushManager.supportedContentEncodings?.includes('aes128gcm')) {
contentEncoding = 'aes128gcm';
}
// アプリケーションサーバへsubscriptionの情報を保存。
await fetch('receive_subscription.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(Object.assign(subscription.toJSON(), {contentEncoding}))
});
// 完了した旨表示。
alert('プッシュサーバの登録まで完了');
}
async function showNotification() {
// 暗号化方式を設定。
let contentEncoding = 'aesgcm';
if (PushManager.supportedContentEncodings?.includes('aes128gcm')) {
contentEncoding = 'aes128gcm';
}
// 既存のプッシュサブスクリプションを取得。
const swr = await navigator.serviceWorker.ready;
const jsonSubscription = (await swr.pushManager.getSubscription()).toJSON();
// アプリケーションサーバへプッシュ通知をするようPOST。
await fetch('send_push_notification.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(Object.assign(jsonSubscription, { contentEncoding }))
});
// 完了した旨表示。
alert('プッシュ通知依頼のPOSTまで完了')
}
async function updateSw() {
const swr = await navigator.serviceWorker.ready;
swr.update();
}
serviceWorker.js の内容
// 通知をクリックした時に表示するURL。
let url = null;
self.addEventListener('push', async function (event) {
const jsonData = event.data.json();
url = jsonData.url;
event.waitUntil(
self.registration.showNotification(jsonData.title, {
body: jsonData.message,
}),
);
});
// 通知をクリックされた時のイベント。
self.addEventListener('notificationclick', function (event) {
event.notification.close();
clients.openWindow(url);
});
receive_subscription.php の内容
<?php
require_once __DIR__.'/vendor/autoload.php';
$subscription = json_decode(file_get_contents('php://input'), true);
file_put_contents('subscription.txt', '$endpoint = "' . $subscription['endpoint'] . '";' . PHP_EOL, FILE_APPEND);
file_put_contents('subscription.txt', '$auth = "' . $subscription['keys']['auth'] . '";' . PHP_EOL, FILE_APPEND);
file_put_contents('subscription.txt', '$p256dh = "' . $subscription['keys']['p256dh'] . '";' . PHP_EOL, FILE_APPEND);
file_put_contents('subscription.txt', '$contentEncoding = "' . $subscription['contentEncoding'] . '";' . PHP_EOL . PHP_EOL, FILE_APPEND);
return;
send_push_notification.php の内容
<?php
require_once __DIR__.'/vendor/autoload.php';
use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;
$subscription = Subscription::create(json_decode(file_get_contents('php://input'), true));
$auth = array(
'VAPID' => array(
'subject' => 'mailto:メールアドレス', // サイトのURLでも良い。
'publicKey' => '生成した公開鍵の文字列',
'privateKey' => '生成した秘密鍵の文字列',
),
);
$webPush = new WebPush($auth);
$payload = json_encode([
'title' => 'タイトル',
'message' => 'メッセージ',
'url' => 'https://google.co.jp'
]);
$report = $webPush->sendOneNotification(
$subscription,
$payload,
);
echo $report->isSuccess();
echo $report->getReason();
manual_send_push_notification.php の内容
<?php
// このスクリプトは「php manual_send_push_notification.php」という形で
// 直接実行する必要がある。
require_once __DIR__.'/vendor/autoload.php';
use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;
// ここから
$endpoint = 'エンドポイントを表す文字列';
$auth = '文字列';
$p256dh = '文字列';
$contentEncoding = '文字列';
// ここまでの情報は、
// receive_subscription.php が subscription.txt に保存している内容を
// コピペすれば良い。
$subscription = new Subscription($endpoint, $p256dh, $auth, $contentEncoding);
$auth = array(
'VAPID' => array(
'subject' => 'mailto:メールアドレス',
'publicKey' => '生成した公開鍵の文字列',
'privateKey' => '生成した秘密鍵の文字列',
),
);
$webPush = new WebPush($auth);
$payload = json_encode([
'title' => 'タイトル手動2',
'message' => 'メッセージ手動2',
'url' => 'https://google.co.jp'
]);
$report = $webPush->sendOneNotification(
$subscription,
$payload
);
printf("isSuccess = %s%s", (string)$report->isSuccess(), PHP_EOL);
// $report->getReason() でエラー詳細を確認できる。
動作確認
PHPのビルトイン開発用サーバを起動するために、下記コマンドを実行する。
% cd BasicTest2
% php -S localhost:8888 router.php
その後、ChromeとFirefoxとSafariで http://localhost:8888 へアクセスし動作確認をすれば良い。
manual_send_push_notification.php についてはphpコマンドを利用して直接実行する必要がある。
各ブラウザで挙動が違う箇所があったので、慣れるまでは各Webブラウザで動作確認すると良い。
違う箇所については、例えば以下があった。
- Firefoxでは下記から使用可能な暗号化方式を取得できなかった。
PushManager.supportedContentEncodings - VAPIDのsubjectにメールアドレスを指定する場合、Safari以外はメールアドレスのみで403エラーは発生しなかったが、Safariの場合、「mailto:メールアドレス」としないと403のエラーがプッシュサーバから返ってくる。(最初の数回は「mailto:」は無くてもエラーは返ってこなかった)