Service Worker と Push API でブラウザタブを閉じていても通知を受け取る
今更ながら Service Worker とやらを触ってみた。Web Worker の一種で、通常 HTML 内から呼び出される JavaScript とは別に、バックグラウンドで処理ができる JS の世界のこと。
先日、new Notification()
で発火させられる通知バナーの話をしたが、Service Worker と Push API を組合せると、ブラウザタブを閉じていても Service Worker がバックグランドで起動しているので、サーバから送信した通知をクライアントのブラウザで受け取れるのである。
この辺、最初は関係性を理解するのが大変だが、動作するサンプルプロジェクトも作ったので、それも使いながら整理していく。
Service Worker 周りは API 仕様がちょこちょこ変わっていたりして、ネット上の文献がそのまま参考にならない場合があって調べるのが大変だった。実装中は TypeScript のように「型」の情報が欲しくなるなぁーと思った。型をよく確認しておこう。
↑ コチラがサンプルプロジェクト。git clone
したら npm install
して動作確認してみてほしい。
Service Worker をインストールする
まず、サーバサイドの話は抜きにして、「ブラウザに Service Worker をインストールする」部分を整理する。
登場人物は次のとおり。
- HTML … ブラウザが表示するページ
- クライアント JS コード … HTML 内にインラインで書いても、別ファイルに避けても良い。Service Worker の登録処理を行う
- サンプルプロジェクトの場合、このファイル … practice-service-worker-notification/index.html
- Service Worker 用の JS ファイル
- サンプルプロジェクトの場合、このファイルのこと … practice-service-worker-notification/service-worker.js
最初は、HTML 内の JS コードにて Service Worker 用の JS ファイルをインストール (登録) する。
/** @type {ServiceWorkerRegistration} 登録した Service Worker */
const serviceWorkerRegistration = await window.navigator.serviceWorker.register('./service-worker.js', { scope: '/' });
if(serviceWorkerRegistration.installing) {
console.log(' Service Worker を初回登録しています…', serviceWorkerRegistration);
/** @type {ServiceWorker} インストール中の Service Worker */
const installingServiceWorker = serviceWorkerRegistration.installing;
installingServiceWorker.addEventListener('statechange', (event) => {
console.log(' Service Worker の初回インストール状況 : ', installingServiceWorker.state, event); // (installing) → installed → activating → activated
});
}
else {
// 2回目以降は `serviceWorkerRegistration.installing` は `null` になっており `active` プロパティの方に ServiceWorker が格納されている模様
console.log(' Service Worker はインストール済のようです', serviceWorkerRegistration);
}
await navigator.serviceWorker.ready; // Service Worker の準備を待機する : 戻り値は `serviceWorkerRegistration` と同一なのでココでは再取得しなくて良い
1行目の navigator.serviceWorker.register()
がそれ。相対パスで Service Worker の JS ファイルを指定し、Service Worker を有効にするスコープを第2引数で指定する。Service Worker のファイルがある階層より上の階層はスコープとして指定できなかったりして制約がある。
register()
メソッド自体は特にユーザに見えるような反応はないが、裏で JS ファイルを読み込んでバックグラウンドプロセスとして起動したりするので、ちょっとだけ時間がかかる。
Service Worker が使えるようになったかどうかを確認するには、await navigator.serviceWorker.ready
で待機すれば良い。ちなみにこの戻り値は serviceWorkerRegistration
そのモノなので、以降はコレを利用して ServiceWorkerRegistration
を取得することで処理ができる。Service Worker を控えるグローバル変数は用意しなくてもなんとかなりそう。
Service Worker のコード
今回用意した Service Worker のコートはとてもシンプル。実際 Push 通知に必要なのは push
イベントの部分のみ。
self.addEventListener('install', (event) => {
console.log('【SW】Install : Service Worker のインストールが開始された', event);
// `navigator.serviceWorker.register()` の時に呼ばれる
});
self.addEventListener('push', (event) => {
console.log('【SW】Push : メッセージを受信した', event, event.data.json());
event.waitUntil(self.registration.showNotification('Message From Service Worker', {
body: 'Service Worker からのメッセージです',
requireInteraction: true, // ユーザが操作するまで閉じなくなる
actions: [ // 選択肢を表示する。Mac Chrome の場合、「オプション」の中に格納されている
{ action: 'Action 1', title: 'Action Title 1' },
{ action: 'Action 2', title: 'Action Title 2' }
],
data: event.data.json()
}));
});
self.addEventListener('notificationclick', (event) => {
console.log('【SW】Notification Click : 通知がクリックされた', event);
// `event.action` プロパティに `actions[].action` の値が設定されている。選択肢以外のバナー領域をクリックした場合は空文字で発火する
});
self.addEventListener('fetch', (event) => {
console.log('【SW】Fetch', event);
});
Service Worker 自身を指定するのは self
というグローバル変数。ブラウザ側でいう window
相当な感じ。push
イベントの部分は後でまた説明するとして、とりあえずこういうファイルを用意しておけば、Service Worker のインストールができる。
Push 通知先をサーバに知らせる
続いて、Service Worker が Push 通知を行うことをユーザに許可してもらい、サーバサイドにクライアントの情報を知らせる。
// Service Worker を取得する
const serviceWorkerRegistration = await navigator.serviceWorker.ready;
// 公開鍵の文字列 : 別途用意しておく
const applicationServerPublicKey = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
/** @type {PushSubscription} Push サービスを開始する : ココで Push 通知の許可ウィンドウが表示される */
const pushSubscription = await serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(applicationServerPublicKey)
});
// サーバに PushSubscription 情報を送信する
const response = await window.fetch('/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pushSubscription.toJSON())
});
serviceWorkerRegistration.pushManager.subscribe()
の部分で、初回はブラウザ上に通知を許可するかどうかのダイアログが表示される。
ココで applicationServerPublicKey
という文字列を何やら変換して指定しているのだが、ココはもうお決まりのパターンで実装するみたい。変換用の関数は以下を参考にした。
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;
}
そして、pushManager.subscribe()
の戻り値である PushNotification
の情報をサーバサイドに送っている。コレで Service Worker の情報をサーバサイドが把握できたので、
- 通知用のサーバ (自分で用意する・詳細は後述)
から
- クライアントのブラウザにインストールされた Service Worker (先程
register()
したコード)
へとプッシュ通知が送れるようになり、
- Service Worker がバナー通知を表示する
- → クライアントのデスクトップにバナー通知が表示される
というワケである。
ブラウザの HTML と JS コード、そして Service Worker のコードとしてはココまで。
プッシュ通知するサーバを用意する
サンプルプロジェクトでプッシュ通知を実現するためには、web-push という npm パッケージを利用している。サンプルプロジェクトのインストール時に、プッシュ通知に必要な鍵ペアを生成するようにしてある。
サーバ自体は Express で作ったが、ココは何で作っても良い。
const path = require('path');
const express = require('express');
const webPush = require('web-push');
// Push 通知に使用する鍵ペアを読み込んでおく
const applicationServerKeys = require('./application-server-keys.json');
webPush.setVapidDetails('mailto:example@example.com', applicationServerKeys.publicKey, applicationServerKeys.privateKey);
// サーバ準備
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use('/', express.static(path.resolve(__dirname, '../client'))); // 静的ファイル
/** @type {Array<PushSubscription>} 通知の送信先情報を控えておく */
const pushSubscriptions = [];
// Subscribe : 通知の送信先情報を控える
app.post('/subscribe', (req, res) => {
/** @type {PushSubscription} `webPush.PushSubscription` インターフェースとも同型 */
const pushSubscription = req.body;
pushSubscriptions.push(pushSubscription);
res.json({ result: '登録しました!' });
});
// Push : 控えておいた通知先に一斉送信する
app.get('/push', async (_req, res) => {
// Payload はテキトーに用意しておく
const payload = JSON.stringify({
title: 'Message From Server',
body: 'サーバからの通知メッセージです。Service Worker が受け取ってくれるはず'
});
/** @type {Array<webPush.SendResult>} Push 送信の結果 */
const sendResults = await Promise.all(pushSubscriptions.map((pushSubscription) => webPush.sendNotification(pushSubscription, payload)));
res.json({ result: '一斉通知しました!', sendResults });
});
重要なのは webPush.setVapidDetails()
で事前準備をしておくこと。
クライアントから呼び出した /subscribe
という API パスでは、pushSubscriptions.push(pushSubscription);
だけしか行っていない。コレは単純に、インメモリの配列 pushSubscriptions
に情報を控えているだけ。なのでサーバを停止させたらクライアント情報は消失してしまう。二重登録なども制御していないので、クライアントから複数回 /subscribe
を叩いてしまうと、後で通知する際にバナー通知が連続で出力されてしまったりする。この辺の制御は実際はもっと丁寧にやってもらいたい。w
で、/push
というエンドポイントでは、こうして控えた配列 pushSubscriptions
の情報を利用して、webPush.sendNotification()
を実行している。裏では FCM (Firebase Cloud Messaging) が勝手に用意されており、コレを介してサーバからクライアントの Service Worker へと通知が送られているようだ。
Service Worker からバナー通知を表示する
サンプルページに用意した「Push」ボタンや、$ curl http://localhost:8080/push
などの方法で /push
エンドポイントを叩くと、クライアントの Service Worker に対してプッシュ通知が届く。ココでもう一度、Service Worker のコードを再掲する。
self.addEventListener('push', (event) => {
console.log('【SW】Push : メッセージを受信した', event, event.data.json());
event.waitUntil(self.registration.showNotification('Message From Service Worker', {
body: 'Service Worker からのメッセージです',
requireInteraction: true, // ユーザが操作するまで閉じなくなる
actions: [ // 選択肢を表示する。Mac Chrome の場合、「オプション」の中に格納されている
{ action: 'Action 1', title: 'Action Title 1' },
{ action: 'Action 2', title: 'Action Title 2' }
],
data: event.data.json()
}));
});
event.data.json()
の中身が、サーバから送った payload
の内容になっている。つまりサーバサイドの Payload をそのまま通知バナーに表示したいのであれば、このオブジェクトを利用すれば良いワケだ。ココでは event.data.json()
の値は特に利用せず、固定値でバナー通知を表示している。
バナー通知の表示は new Notification()
ではなく、self.registration.showNotification()
という別のメソッドを利用している。そしてその処理を待機するために event.waitUntil()
でラップしているが、このラップはしなくても大丈夫そうだ。
また、サンプルとして actions
を実装したが、コレはバナー通知に選択肢が表示されるモノ。ただ通知を出すだけなら必須ではない。
タブを閉じても通知が受け取れる
さて、
- ユーザが、ブラウザに Service Worker を登録する (登録を許可する) (
navigator.serviceWorker.register()
)- Service Worker はこの時点で、表示中のブラウザタブとは別に、バックグランドプロセスで起動する
- Service Worker が Push 通知を受け取れるように Subscribe する (
serviceWorkerRegistration.pushManager.subscribe()
) - Subscribe した内容をサーバサイドに知らせておく (サンプルでは
/subscribe
エンドポイントへの POST) - サーバサイドは取得したクライアント情報に対して Push 通知を送る (サンプルでは
/push
エンドポイント、webPush.sendNotification()
) - Service Worker がバックグラウンドでサーバサイドからの Push 通知を受け取る (
self.addEventListener('push')
) - Service Worker が受け取った通知を利用して、バナー通知をデスクトップに表示する (
self.registration.showNotification()
)
…このような流れで Service Worker の通知から、バックグラウンドでの通知受信、そしてバナー表示までが実装できた。
繰り返しになるが、Service Worker は開いているブラウザタブなどとは別に、ブラウザが管理するバックグラウンドプロセスとして動作している。そこで、http://localhost:8080/index.html
を開いているブラウザタブを閉じてから、$ curl http://localhost:8080/push
をコールして動作確認してみよう。ココまでの処理がうまくいっていれば、new Notification()
と同様のバナー通知が表示されるはずだ。
「ブラウザを閉じた際にバックグラウンドアプリの処理を続行する」というブラウザ設定を On にしていれば、ブラウザタブを全て閉じた状態でも通知が表示される。そうでない場合、Windows だとウィンドウを全て閉じてタスクバーからブラウザアプリが消えたら通知は受信できなくなる。Mac の場合は、全てのタブを閉じても Dock 内に中黒「・」が表示されていればそのブラウザアプリのプロセスが落ちてはいないので、バナー通知が受け取れるはずだ。Cmd + Q
でプロセスを終了してしまうと、バナー通知が受信できなくなる。
やはりデスクトップアプリとはワケが違うので、親元であるブラウザプロセス自体が終了してしまうとリアルタイムな通知は受け取れなくなるが、最近は基本的に何かしらのブラウザタブは開いた状態で生活していることだろう。「Gmail を開いて置いておくタブ」とか「Google カレンダーを開いて置いておくタブ」みたいなのを用意せずとも、Service Worker に任せればブラウザタブは開かなくとも、必要な時にバナー通知が受け取れるということなので、使いどころによっては便利な感じがする。
今回は localhost で作業したので簡単に動作したように見えたが、実際は HTTPS じゃないと実行できないとか、最近のブラウザはデフォルトで通知や Service Worker を無効化しているような設定があったりして、実際の利用というか、サービスプロバイダとして通知機能を提供する方法として Service Worker と Push API を採用するのはなかなか面倒臭そうではある。Push 通知のためにサーバの準備が必要なのがダルいか。w
まぁとりあえず、今回は Service Worker なるものを初めて触って、Push 通知も動作確認できたのでコレにて終了。