WebPush通知をMAMPで動かす方法 (2/2)

前回からの続きです。

Web Workerから発展した「Service Worker」という仕組みの中で使用できる「Webプッシュ通知」という機能をWindows版のMAMPで動くように設定して行きます。WSL2を使用しても動作します。

色々なサイトを訪問すると以下の様なダイアログが表示されることがあります。このダイアログで「許可する」を選択すると、次回からはブラウザーを起動していなくてもサイトからの通知を受け取ることが出来ます。受け取った通知はWindowsの場合はデスクトップ画面の右下等に表示されます。

登録(許可)するのにメールアドレス等の情報は必要なく、ボタンを押すだけです。登録してもらえれば広告効果は大きいと思われますが、残念ながら許諾率(許可してもらえる割合)は全体で5~10パーセント程度と言われている様です。このブログでプッシュ通知を行う予定はありませんが、仕組みを理解するために登録方法をメモとして残します。

Webプッシュ処理の流れ

登録の流れとしては以下の様になります。

  • サーバー側で「公開鍵」と「秘密鍵」を用意
  • ブラウザーで認証ダイアログを表示
  • サーバーの公開鍵を使用して、サブスクリプション(エンドポイント、ブラウザーの公開鍵、認証トークン)を作成
  • 作成したサブスクリプションをサーバーに送信

プッシュ通知の流れとしては以下の様になります。

  • サーバーの公開鍵、秘密鍵を使用してブラウザー(側)に認証してもらう
  • 送られてきたサブスクリプションとメッセージをセットにして、ブラウザー(側)に送信
Service Workerを実装します

こちらのサイト様の説明が簡潔で分かりやすかったので、お手本にさせて頂きました。

「C:¥MAMP¥htdocs」フォルダ内に「WebPush」というフォルダを作成して、VSCodeで開きます。以下のファイルを作成して、画面の内容を書き込んで保存します。(この記事のソースはこちらからダウンロードできます。) 各ファイルの意味は以下の様になっています。Web Workerと比べると実装はかなり複雑です。最終形のコードのためかなり長めですが、後ほど個別にご説明いたしますのでよろしかったらご覧ください。

  • config.php・・・公開鍵、秘密鍵等の設定ファイルです。
  • index.php・・・メイン画面です。
  • webpush.js・・・ブラウザー(メインスレッド)から直接参照されるファイルになっています。
  • service-worker.js・・・バックグラウンドで動作するファイルになっています。
  • SendPush.php・・・プッシュ通知を送信するファイルになっています。
  • SendPush.js・・・プッシュ通知画面を制御するスクリプトファイルになっています。
  • phpinfo.php・・・PHP情報を表示するファイルになっています。
  • curl.php・・・cURL機能のテスト画面になっています。

Webプッシュ通知を行うにはComposerのパッケージが必要になります。(Composerのインストール方法は、WSL2の場合はこちらの記事を、MAMPの場合はこちらの記事をご参照ください。)

設定ファイルです。

<?php
define('CURRENT_URI', $_SERVER['REQUEST_URI']);

define('PUBLIC_KEY', '*** 公開鍵をここに貼り付けます ***');
define('PRIVATE_KEY', '*** 秘密鍵をここに貼り付けます ***');

メインページのファイルです。

<?php
require_once 'config.php';
session_start();

$req_method = strtoupper($_SERVER['REQUEST_METHOD']);

if ($req_method === 'POST') {
    $raw = file_get_contents('php://input');
    $data = json_decode($raw);

    $_SESSION['endpoint'] = $data->endpoint;;
    $_SESSION['userPublickKey'] = $data->userPublicKey;
    $_SESSION['userAuthToken'] = $data->userAuthToken;
}
?>

<!DOCTYPE html>
<html lang="jp">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebPushテスト</title>
    <script>
        window.PUBLIC_KEY = '<?php echo PUBLIC_KEY; ?>';
        window.URL = '<?php echo $_SERVER['PHP_SELF']; ?>';
        window.SUB_FOLDER = '<?php echo CURRENT_URI; ?>';
    </script>
    <script src="webpush.js"></script>
</head>

<body>
    <p> <a href="javascript:allowWebPush()">WebPushを許可する</a> </p>
    <p> <a href="phpinfo.php" target="_blank">phpinfo</a> </p>
    <p> <a href="curl.php" target="_blank">cURLテスト</a> </p>
    <p> <a href="SendPush.php" target="_blank">送信フォームを表示</a> </p>
</body>

</html>

メインページから呼ばれるスクリプトファイルです。Service Workerを登録します。

/** サービスワーカーの登録 */
self.addEventListener('load', async () => {
    if ('serviceWorker' in navigator) {
        sub_folder = window.SUB_FOLDER;
        window.sw = await navigator.serviceWorker.register('service-worker.js',
            { scope: `${sub_folder}` });
    }
});

/** WebPushを許可する仕組み */
async function allowWebPush() {
    if ('Notification' in window) {
        let permission = Notification.permission;

        if (permission === 'denied') {
            alert('Push通知が拒否されているようです。ブラウザの設定からPush通知を有効化してください');
            return false;
        } else if (permission === 'granted') {
            alert('すでにWebPushを許可済みです');
            generateConnectionInfo();
            return false;
        }
    }

    generateConnectionInfo();
}

async function generateConnectionInfo() {
    // 取得したPublicKey
    const appServerKey = window.PUBLIC_KEY;
    const applicationServerKey = urlB64ToUint8Array(appServerKey);

    // push managerにサーバーキーを渡し、トークンを取得
    let subscription = undefined;
    try {
        subscription = await window.sw.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey
        });
    } catch (e) {
        alert('Push通知機能が拒否されたか、エラーが発生しましたので、Push通知は送信されません。');
        return false;
    }

    // 必要なトークンを変換して取得(これが重要!!!)
    const key = subscription.getKey('p256dh');
    const token = subscription.getKey('auth');
    const request = {
        endpoint: subscription.endpoint,
        userPublicKey: btoa(String.fromCharCode.apply(null, new Uint8Array(key))),
        userAuthToken: btoa(String.fromCharCode.apply(null, new Uint8Array(token)))
    };

    console.log(request);

    fetch(window.URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(request)
    });
}

/**
 * トークンを変換するときに使うロジック
 * @param {*} base64String 
 */
function urlB64ToUint8Array(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 postMessage(data) {
    const activeRegistration = await navigator.serviceWorker.ready
    activeRegistration.active.postMessage(data);
}

navigator.serviceWorker.addEventListener('message', (e) => {
    alert(e.data);
});

navigator.serviceWorker.addEventListener('error', (e) => {
    console.log(e)
}, false);

Service Workerファイルです。

// プッシュ通知を受け取ったときのイベント
self.addEventListener('push', (e) => {
    const title = 'Push通知テスト';
    const options = {
        body: e.data.text(), // サーバーからのメッセージ
        tag: title, // タイトル
        icon: 'icon-512x512.png', // アイコン
        badge: 'icon-512x512.png' // アイコン
    };

    e.waitUntil(self.registration.showNotification(title, options));
    Notify(e.data.text());
});

self.addEventListener('error', (e) => {
    console.log(e)
    Notify('エラーが発生しました');
}, false);

// プッシュ通知をクリックしたときのイベント
self.addEventListener('notificationclick', (e) => {
    e.notification.close();
    e.waitUntil(clients.openWindow('http://localhost:8080/'));
});

// Service Worker インストール時に実行される
// キャッシュするファイルとかをここで登録する
self.addEventListener('install', (e) => {
    console.log('service worker install ...');
    e.waitUntil(self.skipWaiting());
});

self.addEventListener('message', (e) => {
    Notify(e.data);
})

function Notify(data) {
    self.clients.claim();
    self.clients.matchAll().then(all => all.forEach(client => {
        client.postMessage(data);
    }));
}

メッセージ送信画面ファイルです。メッセージの送信処理も行っています。

<?php
require_once 'config.php';
require_once 'vendor/autoload.php';

use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;

session_start();

$req_method = strtoupper($_SERVER['REQUEST_METHOD']);

const VAPID_SUBJECT = 'http://localhost:8080';

if ($req_method === 'POST') {
    $message = file_get_contents('php://input');

    // push通知認証用のデータ
    $subscription = Subscription::create([
        'endpoint' => $_SESSION['endpoint'],
        'publicKey' => $_SESSION['userPublickKey'],
        'authToken' => $_SESSION['userAuthToken'],
    ]);

    // ブラウザに認証させる
    $auth = [
        'VAPID' => [
            'subject' => VAPID_SUBJECT,
            'publicKey' => PUBLIC_KEY,
            'privateKey' => PRIVATE_KEY,
        ]
    ];

    $webPush = new WebPush($auth);

    $report = $webPush->sendOneNotification(
        $subscription,
        $message
    );

    if ($report->isSuccess()) {
        echo '送信成功!';
    } else {
        echo '送信失敗...';
    }
    
    die();
}
?>
<!DOCTYPE html>
<html lang="jp">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SendPush</title>
    <script>
        window.URL = '<?php echo $_SERVER['PHP_SELF']; ?>';
    </script>
</head>

<body>
    <h1>メッセージ送信</h1>
    <form id="messageForm">
        <label for="message">メッセージ</label>
        <input type="text" id="message" name="message">
        <button>送信</button>
    </form>
    <h2>送信済みメッセージ</h2>
    <ul id="messages">
    </ul>
    <script src="SendPush.js"></script>
</body>

</html>

メッセージ送信画面用のスクリプトファイルです。

const messageForm = document.querySelector('#messageForm');
const messages = document.querySelector('#messages');
messageForm.addEventListener('submit', function (e) {
    e.preventDefault();
    const message = messageForm.elements.message.value;
    fetch(window.URL, {
        method: 'POST',
        headers: { 'Content-Type': 'text/plain' },
        body: message
    }).then((res) => {
        return res.text();
    }).then((data) => {
        addMessage(message + ` (${data})`);
    })
    messageForm.elements.message.value = "";
})

function addMessage(message) {
    const newMessage = document.createElement('li');
    newMessage.append(message);
    messages.append(newMessage);
}

PHP情報を表示するファイルです。

<?php echo phpinfo();

cURLコマンドのテスト用ファイルです。


<?php
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "https://www.yahoo.co.jp/");
curl_exec($ch);
curl_close($ch);
公開鍵と秘密鍵を取得します

こちらのサイト様から公開鍵と秘密鍵を取得します。

取得したカギをconfig.phpファイルのそれぞれの場所に貼り付けます。

<?php
define('CURRENT_URI', $_SERVER['REQUEST_URI']);

define('PUBLIC_KEY', '*** 公開鍵をここに貼り付けます ***');
define('PRIVATE_KEY', '*** 秘密鍵をここに貼り付けます ***');
プッシュ通知に必要なComposerパッケージをインストールします

VSCodeにCtrl + @と入力してターミナル画面を表示して、以下の様に入力します。ソースファイルをダウンロードした場合は「composer install」と入力するとパッケージが復元されます。

composer require minishlink/web-push
PHPのビルトインサーバーを起動します

ターミナル画面に以下の様に入力して、PHPのビルトインサーバーを起動します。ついでに前回の記事を参考にしてデバッグ環境も整えておきます。

php -S localhost:8080

WSL2を使用している場合はこの段階で実行可能です。Windows版のMAMPで検証したい場合は以下の設定が必要です。

MAMPの環境設定を行います

Composerでインストールした「web-push」パッケージがOpenSSLを必要とするので、MAMPのOpenSSLを使用する様に設定して行きます。Windowsキーを押して表示された検索欄に「env」と入力してEnterキーを押します。表示されたダイアログの「環境変数」ボタンを押して、環境変数ダイアログを表示します。

「新規(N)...」ボタンを押します。

表示されたダイアログで、変数名を「OPENSSL_CONF」、変数値を「C:\MAMP\bin\apache\conf\openssl.cnf」にしてOKボタンで閉じます。その他のダイアログもすべてOKボタンで閉じて、VSCodeも再起動しておきます。

以上でOpenSSLは使える様になりましたが、通信にcURLコマンドを使用しているので、こちらも設定して行きます。Windowsには標準でcURLコマンドが入っていますが、そのままではSSL通信ができない様です。こちらのサイト様から証明書ファイル(cacert.pem)をダウンロードして、「C:\MAMP\ca\」というフォルダを作成してその中に保存します。

次にPHPの設定ファイル(php.ini)ファイルにcURLの証明書の場所を設定します。PHPの設定ファイルの場所は、VSCodeでPHPサーバーを起動した状態でメインページの「phpinfo」リンクをクリックして表示されたページで「php.ini」と検索すると「Loaded Configuration File」という項目の位置に表示されています。

PHPの設定ファイル(php.ini)を開いて「curl」という項目を検索します。「curl.cainfo」という項目のコメント外して、先ほどダウンロードした証明書ファイルの場所("C:\MAMP\ca\cacert.pem")を追加して保存します。

PHPサーバーを再起動して、再度PHP情報を表示します。「curl」と検索して「curl.cainfo」項目が正しく読み込まれているか確認します。メインページの「cURLテスト」リンクをクリックして何かページが表示されば正常に動作しています。

念のためPHP情報のページで「openssl」と検索して、環境変数で設定したOpenSSLの設定ファイルが読み込まれているか確認します。

PHPのデバッグ(XDebug)も行いたい場合は、設定ファイルに以下の項目を追加しておくと便利かもしれません。PHPのバージョン名はご自身の環境に合わせて適宜修正してください。

[XDebug]
xdebug.mode = debug
xdebug.start_with_request = yes
zend_extension = "C:\MAMP\bin\php\php8.2.14\ext\php_xdebug.dll"

以上でMAMPの設定は終了です。

サンプルファイルの使用方法

メインページの「WebPushを許可する」リンクをクリックすると、プッシュ通知の許可を求めるダイアログが表示されますので、「許可する」ボタンを押します。(データベースを使用していませんので、サンプルアプリの起動時は毎回この操作を行う必要があります。)

メインページの「送信フォームを表示」リンクをクリックしてメッセージ送信フォームを表示します。メッセージ欄に何か入力して「送信」ボタンを押すとメッセージがプッシュ通知されます。

プッシュ通知が成功した場合は、送信済みメッセージ欄に送信成功と表示されます。

送信されたメッセージは、デスクトップ画面の右下に表示されます。(設定によってはまとまって表示されます。) 

また、メインページにも同じ内容のダイアログが表示されます。

以上が動作説明ですが、間違って通知設定を拒否してしまった場合や、そもそも通知設定自体を削除したい場合は以下の方法で登録を削除できます。(Chromeの場合)

右上にある「Google Chromeの設定ボタン」をクリックして、表示されたメニューから「設定」を選択します。

左のリストから「プライバシーとセキュリティ」を選択して、プライバシーとセキュリティグループから「サイトの設定」を選択します。

最近のアクティビティに表示されている削除したいサイトを選択します。

表示された画面で、「データを削除」ボタンと「権限をリセット」ボタンを押すと登録情報が削除されます。

通知メッセージが表示されない場合は

メッセージ通知が成功しているのに、デスクトップにメッセージが表示されない場合は以下の設定をご確認ください。

①の通知アイコンがスリープマークになっている場合は、個別の通知を表示しないで通知用のウィンドウにまとめられます。②のボタンをクリックすると解除されます。

そもそも通知自体が来ない場合は「通知設定」リンクをクリックします。リンクが表示されていない場合は、上画像の②を押して応答不可モードにすると表示されます。

表示された設定画面で、対象のアプリが通知を受け取る設定(オン)になっているか確認します。

Service Workerの使用方法

今までプッシュ通知の事しかご説明しませんでしたが、Service Workerの本来の仕事はブラウザーとネットワークの間に位置するプロキシサーバーとしての役目があります。そのためネットワークがダウンしていてもキャッシュを使用してコンテンツを供給する機能や、WEBページをスマートフォンのアプリケーション(PWAというそうです)の様に表示する機能などを持っています。プッシュ通知はその中の1つの機能となっています。Web Workerとは目的も用途も異なるため操作方法もかなり異なっています。

・Service Workerを作成するには

Web Workerはいくつでも作成できましたが、Service Workerは1つのディレクトリ(スコープ)に対して1つだけ登録できるようです。登録方法は以下の様になっています。指定されたスコープ以下が管理対象になります。

/** サービスワーカーの登録 (メインスレッドから呼ばれます) */
self.addEventListener('load', async () => {
    if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('service-worker.js', { scope: '/' });
    }
});

複数のディレクトリ(スコープ)に対して登録できますが、スコープが重なった場合は最も長いスコープ名に登録されたスクリプトファイルが登録される様です。

また、Web Workerは作成されるとすぐに実行されましたが、Service Workderはコードが変更されてもすぐには実行(読み込み)されない様です。前に実行されていたスクリプトの処理が終了するまで実行されない様です。

・Service Workerを終了するには

Service Workerの起動や停止はブラウザーが自動で行うので、コードで停止はできない様です。停止したい場合は直接ブラウザーの設定を操作する必要があります。

・Service Workerと通信するには

どちらからも「postMessage」メソッドでデータの送信が可能です。データを受信するにはイベントリスナー等で「message」イベントにコールバック関数を登録しておきます。送信されたデータはコールバック関数の引数(e)の「data」というプロパティで取得できます。

メインスレッド側から通信する場合は、相手が起動するまで待つ必要があります。serviceWorkerオブジェクトの「ready」プロパティで有効になっているService Workerを取得できます。readyプロパティは有効なService Workerがない場合は有効になるまで待機します。

/* メインスレッド */

/* 送信 */
const activeRegistration = await navigator.serviceWorker.ready;
activeRegistration.active.postMessage('Hello, world');

/* 受信 */
navigator.serviceWorker.addEventListener('message', (e) => {
  console.log('Service Workerからデータを受信しました: ', e.data);
}, false);

Service Workerからメインスレッド側にメッセージを送信するには、「clients」コレクションのインスタンスを使用します。matchAllメソッドでクライアントのタイプを指定できます。

/* サービスワーカースレッド */

/* 受信と送信 */
self.addEventListener('message', (e) => {
    self.clients.claim();
    self.clients.matchAll().then(all => all.forEach(client => {
        client.postMessage(e.data);
    }));
})
・Service Workerのエラーをキャッチするには

「error」イベントにコールバック関数を登録するとキャッチできるとありますが、私の環境ではメインスレッド側でエラーイベントは取得できませんでした。何かやり方が悪いのかもしれません。

/* メインスレッド */
navigator.serviceWorker.addEventListener('error', (e) => {
    console.log(e)
}, false);

Service Worker内では自分のエラーイベントが取得できるようです。

/* サービスワーカースレッド */
self.addEventListener('error', (e) => {
    console.log(e)
}, false);
・その他の制限事項など

セキュリティのため、https通信のページかlocalhost以外では動作しない様です。またWeb Workerと同様に、Service Worker内ではブラウザーの「window」オブジェクトにアクセスしたり、DOM操作を行うことが出来ません。ネットワーク通信は fetch が使用できます。(XMLHttpRequestは使用できない様です。) fetch関数の応答 (promise)も使用できる様です。windowにアクセスできないので、localStorageは使用できませんがIndexedDB(ブラウザーのストレージ)は使用できる様です。

各ファイルの説明
・config.php

公開鍵と秘密鍵を設定します。ドキュメントルートからだけでなく、サブフォルダでも実行できる様になっています。サブフォルダで実行された場合は「CURRENT_URI」定数にフォルダ名がセットされます。

<?php
define('CURRENT_URI', $_SERVER['REQUEST_URI']);

define('PUBLIC_KEY', '*** 公開鍵をここに貼り付けます ***');
define('PRIVATE_KEY', '*** 秘密鍵をここに貼り付けます ***');
・index.php

メインページです。データベースを使用していないので、サブスクリプション情報(エンドポイント、ブラウザーの公開鍵、認証トークン)をメインページを表示しているブラウザーから受信したら、PHPのセッションストレージ($_SESSION変数)に保存して他のページ(WebPush.php)からも参照できる様にしています。

$req_method = strtoupper($_SERVER['REQUEST_METHOD']);

if ($req_method === 'POST') {
    $raw = file_get_contents('php://input');
    $data = json_decode($raw);

    $_SESSION['endpoint'] = $data->endpoint;;
    $_SESSION['userPublickKey'] = $data->userPublicKey;
    $_SESSION['userAuthToken'] = $data->userAuthToken;
}
・webpush.js

メインスレッドから呼ばれるスクリプトファイルになっています。メインページが読み込まれたらService Workerファイルを登録します。

/** サービスワーカーの登録 */
self.addEventListener('load', async () => {
    if ('serviceWorker' in navigator) {
        sub_folder = window.SUB_FOLDER;
        window.sw = await navigator.serviceWorker.register('service-worker.js',
            { scope: `${sub_folder}` });
    }
});

「WebPushを許可する」リンクをクリックすると以下の関数が呼び出されます。現在の状態は「Notification.permission」プロパティで取得できます。default(初回訪問など)、denied(拒否)、granted(許可)の状態があります。拒否された場合はコードからは変更できないのでそのまま終了します。それ以外の場合はサーバーから送られてきた公開鍵からサブスクリプション(エンドポイント、ブラウザーの公開鍵、認証トークン)を作成します。

/** WebPushを許可する仕組み */
async function allowWebPush() {
    if ('Notification' in window) {
        let permission = Notification.permission;

        if (permission === 'denied') {
            alert('Push通知が拒否されているようです。ブラウザの設定からPush通知を有効化してください');
            return false;
        } else if (permission === 'granted') {
            alert('すでにWebPushを許可済みです');
            generateConnectionInfo();
            return false;
        }
    }

    generateConnectionInfo();
}

サブスクリプションを作成します。36行目のService Workerのプッシュマネージャーにある、subscribe関数を呼び出すと許諾を求めるダイアログが表示されます。この関数の引数には、サーバーから送られてきた公開鍵をbase64に変換して、さらに得られた文字列を8ビットのバイト配列に変換したものを渡す様です。46行目以降で、作成したサブスクリプションからエンドポイント、ブラウザーの公開鍵、認証トークンを作成します。これらのデータを56行目でサーバーに送信しています。

async function generateConnectionInfo() {
    // 取得したPublicKey
    const appServerKey = window.PUBLIC_KEY;
    const applicationServerKey = urlB64ToUint8Array(appServerKey);

    // push managerにサーバーキーを渡し、トークンを取得
    let subscription = undefined;
    try {
        subscription = await window.sw.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey
        });
    } catch (e) {
        alert('Push通知機能が拒否されたか、エラーが発生しましたので、Push通知は送信されません。');
        return false;
    }

    // 必要なトークンを変換して取得(これが重要!!!)
    const key = subscription.getKey('p256dh');
    const token = subscription.getKey('auth');
    const request = {
        endpoint: subscription.endpoint,
        userPublicKey: btoa(String.fromCharCode.apply(null, new Uint8Array(key))),
        userAuthToken: btoa(String.fromCharCode.apply(null, new Uint8Array(token)))
    };

    console.log(request);

    fetch(window.URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(request)
    });
}

/**
 * トークンを変換するときに使うロジック
 * @param {*} base64String 
 */
function urlB64ToUint8Array(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;
}
・service-worker.js

プッシュ通知をハンドリングするファイルです。通知を受け取ると「push」イベントが発生します。11行目の「showNotification」関数で通知をデスクトップに表示します。コールバック関数の引数(e)の「waitUntil」関数を使用すると、通知を表示し終わるまで(引数の処理が終わるまで)待ってくれる様です。

// プッシュ通知を受け取ったときのイベント
self.addEventListener('push', (e) => {
    const title = 'Push通知テスト';
    const options = {
        body: e.data.text(), // サーバーからのメッセージ
        tag: title, // タイトル
        icon: 'icon-512x512.png', // アイコン
        badge: 'icon-512x512.png' // アイコン
    };

    e.waitUntil(self.registration.showNotification(title, options));
    Notify(e.data.text());
});

Service Workerファイルが読み込まれると「install」イベントが発生します。初めて読み込まれた場合はそのまますぐに有効化されますが、修正されたファイルを読み込んだ場合等はすぐに有効化されません。既に読み込まれているファイルの処理が終了するまで待機します。しかし30行目にある「skipWaiting」関数を呼び出すと強制的に新しいファイルを有効化する事ができる様です。

// Service Worker インストール時に実行される
// キャッシュするファイルとかをここで登録する
self.addEventListener('install', (e) => {
    console.log('service worker install ...');
    e.waitUntil(self.skipWaiting());
});

ブラウザー(Chrome)の検証ツールからも更新可能です。

Service Workerで管理できるページは、「ファイルが登録されてから新たに開かれたページ」になっています。すでに開かれているページは管理対象にはならない様です。38行目にある「claim」関数を呼び出すと、すでに開かれていたページも現在のスクリプトファイルの管理対象に追加することが出来ます。

self.addEventListener('message', (e) => {
    Notify(e.data);
})

function Notify(data) {
    self.clients.claim();
    self.clients.matchAll().then(all => all.forEach(client => {
        client.postMessage(data);
    }));
}
・SendPush.php

プッシュ通知を送信するフォームファイルです。メインページでセッション変数に保存されたデータからサブスクリプション情報を作成します。(18行目) 25行目から33行目でサーバーに保存されている公開鍵と秘密鍵から、ブラウザー用の認証情報を作成しています。35行目でブラウザーから取得したサブスクリプション情報を使用してメッセージを暗号化して送信しています。

<$req_method = strtoupper($_SERVER['REQUEST_METHOD']);

const VAPID_SUBJECT = 'http://localhost:8080';

if ($req_method === 'POST') {
    $message = file_get_contents('php://input');

    // push通知認証用のデータ
    $subscription = Subscription::create([
        'endpoint' => $_SESSION['endpoint'],
        'publicKey' => $_SESSION['userPublickKey'],
        'authToken' => $_SESSION['userAuthToken'],
    ]);

    // ブラウザに認証させる
    $auth = [
        'VAPID' => [
            'subject' => VAPID_SUBJECT,
            'publicKey' => PUBLIC_KEY,
            'privateKey' => PRIVATE_KEY,
        ]
    ];

    $webPush = new WebPush($auth);

    $report = $webPush->sendOneNotification(
        $subscription,
        $message
    );

    if ($report->isSuccess()) {
        echo '送信成功!';
    } else {
        echo '送信失敗...';
    }
    
    die();
}
・SendPush.js

プッシュ通知を送信する画面用のスクリプトファイルです。送信ボタンを押すと発生するフォームの「submit」イベントをフックして、「preventDefault」関数を呼び出してサーバーにPOSTデータが送信されない様にしています。その代わりにAjaxという仕組みを利用してFetch関数で、入力データ(送信メッセージ)をサーバーに送信しています。送信したメッセージとサーバーから返信されたメッセージを結合して、画面を再読み込みせずに送信結果をスクリプトで追加しています。

const messageForm = document.querySelector('#messageForm');
const messages = document.querySelector('#messages');
messageForm.addEventListener('submit', function (e) {
    e.preventDefault();
    const message = messageForm.elements.message.value;
    fetch(window.URL, {
        method: 'POST',
        headers: { 'Content-Type': 'text/plain' },
        body: message
    }).then((res) => {
        return res.text();
    }).then((data) => {
        addMessage(message + ` (${data})`);
    })
    messageForm.elements.message.value = "";
})

function addMessage(message) {
    const newMessage = document.createElement('li');
    newMessage.append(message);
    messages.append(newMessage);
}
実際のサーバーで動作確認する場合

実際のサーバーで動作確認する場合は、ホームフォルダにWebPushフォルダを配置して仮想ディレクトリを設定するか、お手軽に実行するにはドキュメントルート(/var/www/html等)以下にWebPushフォルダーを配置して、所有者をwww-dataに変更しておきます。ドキュメントルートに配置した場合は管理者権限でないとComposerを実行できないので、WebPushフォルダに移動してから「sudo composer install」と入力します。その後「sudo chown -R www-data .」と入力して所有者を変更しておきます。

また、だれでもアクセスできてしまうとまずいので、以下の様なファイルをフォルダ内に配置してアクセス制限しておくといいかもしれません。

<Limit GET POST PUT>
  order deny,allow
  deny from all
  allow from xxx.xxx.xxx.xxx # アクセス可能なIPアドレスを指定します
</Limit>

公開している実際のサーバーからも問題なくメッセージが送信できる様です。

以上です。よろしかったらお試しください。
(実際にお試しいただくには、公開鍵と秘密鍵の取得と設定が必要です。)