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 コードにて 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 の情報をサーバサイドが把握できたので、

から

へとプッシュ通知が送れるようになり、

というワケである。

ブラウザの 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 の通知から、バックグラウンドでの通知受信、そしてバナー表示までが実装できた。

繰り返しになるが、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 通知も動作確認できたのでコレにて終了。