knowledge base

マークアップ/フロントエンドエンジニアのWEB制作における備忘録です。平日はWEB屋、休日は社会人劇団の主宰・劇作家をしています。

【PWA】シリーズPWA (10) クライアントからAPIにリクエストを送って通知を受け取る

クライアント側の実装

クライアント側は、基本的に前回までで実装したAPIを叩きにいくように変更します。

サブスクリプション の登録(と古いものがあれば削除)

前回の実装では、サブスクリプションの情報をconsoleに出力するだけでしたが、実際にサブスクリプションの情報をリクエストヘッダーに乗せてAPIを叩きます。

まずmain.jsにおいてこのようになっていた箇所を、

navigator.serviceWorker.ready.then(function(registration) {
	return registration.pushManager.getSubscription().then(function(subscription) {
		if (subscription) return subscription;
		return registration.pushManager.subscribe({
			userVisibleOnly: true,
			applicationServerKey: convertedVapidKey
		}).then(function (subscription) {
			 var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '';
			 var key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : '';
			 var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : '';
			 var auth = rawAuthSecret ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : '';
			 var endpoint = subscription.endpoint;
			 console.log(key);
			 console.log(auth);
			 console.log(endpoint);
		});
	})
});

このように書き換えます。

var sendSubscription = function(subscription){
	var data = {};
	var latestID = localStorage.getItem('latestID');
	var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '';
	data.key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : '';
	var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : '';
	data.auth = rawAuthSecret ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : '';
	data.endpoint = subscription.endpoint;
	data.registrationID = data.endpoint.substring(data.endpoint.lastIndexOf('/')+1, data.endpoint.length);
	localStorage.setItem('latestID', data.registrationID);

	// 新しいサブスクリプションを通知対象に登録する
	fetch('https://*******/add',{
		method: 'POST',
		headers: {
			'Content-Type': 'application/json'
		},
		mode: 'cors',
		body: JSON.stringify(data)
	})
	.then(function(response) { return response.text(); })
	.then(function(res){ console.log(res); });

	// 古いサブスクリプションがあれば通知対象から削除する
	if (latestID) {
		fetch('https://*******/delete',{
			method: 'POST',
			headers: {
				'Content-Type': 'application/json'
			},
			mode: 'cors',
			body: JSON.stringify({ registrationID: latestID})
		})
		.then(function(response) { return response.text(); })
		.then(function(res){ console.log(res); });
	}
}

navigator.serviceWorker.ready.then(function(registration) {
	return registration.pushManager.getSubscription().then(function(subscription) {
		if (subscription) return subscription;
		return registration.pushManager.subscribe({
			userVisibleOnly: true,
			applicationServerKey: convertedVapidKey
		}).then(function (subscription) {
			sendSubscription(subscription);
		});
	})
});

サブスクリプションの情報をJSON形式にしてリクエストヘッダーに持たせています。

また、最新のregistrationIDを常にlocalStorageで管理し、古いものがあれば(つまりlocalStorageに格納されたregistrationIDがあれば)そのIDは削除するようAPIを叩いています。

push用ページを作成する

pushについては任意の通知内容を設定できるように専用のフォームページを作成し、そのフォームがsubmitされたタイミングで、pushするためのAPIを叩きに行くようにします。 仮にファイル名をpush.htmlとします。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<form method="POST" id="form">
	Title <input type="text" name="title">
	Message <input type="text" name="message">
	<button type="submit">Push</button>
</form>
<script>
document.querySelector('#form').addEventListener('submit', function(e){
	e.preventDefault();
	var title = document.querySelector('#form').querySelector('[name="title"]').value;
	var message = document.querySelector('#form').querySelector('[name="message"]').value;
	var data = { title: title, message: message };
	fetch('https://******/push',{
		method: 'POST',
		headers: {
			'Content-Type': 'application/json'
		},
		mode: 'cors',
		body: JSON.stringify(data)
	})
	.then(function(response) {
		return response.text();
	})
	.then(function(res){
		console.log(res)
	})
}, false)
</script>
</body>
</html>

push.htmlにアクセスし、テキストフィールドに任意の値を入力してボタンを押せば、入力した内容で通知が送られてくるはずです。

【PWA】シリーズPWA (9) APIを実装する

Expressが提供するAPI自体を実装します。

基本的には第7回で紹介した内容を、Expressの文法に落とし込んでゆく感じになります。

ここで登場するsubscriptionsという変数には前回でSqualizeによって作成されたORマッパーのオブジェクト(DBにデータの追加や削除や他にもごにょごにょできるオブジェクト)が格納されています

add (レコードの追加)

app.post('/add', function(req, res) {
	let data = req.body;
	let response = res;
	subscriptions.create({
		registrationID: data.registrationID,
		publicKey: data.key,
		auth: data.auth,
		endpoint: data.endpoint
	}).then(function(){
		response.setHeader('Content-Type', 'text/plain');
		response.end('New subscriptions is successfully added.');
	});
});

delete(レコードの削除フラグをTRUEに)

クライアントからは削除するregistrationIDがわたってくる想定です。

app.post('/delete', function(req, res) {
	let data = req.body;
	subscriptions.update({
		deleted: true
	},{
	    where: {
	        registrationID: data.registrationID
	    }
	});
});

push

ここは、これまでに登場したロジックを再利用することができます。

app.post('/push', function(req, res) {
	let response = res;
	let data = req.body;
	let subscribers = [];

	// 通知の内容
	const params = {
	    title: data.title,
	    msg: data.message,
	    icon: '/path/to/icon'
	};

	webpush.setVapidDetails(
	    'admin@xxx.jp',
	    'Firebase Consoleから取得した公開鍵',
	    'Firebase Consoleから取得した秘密鍵'
	);

	subscriptions.findAll({
	    where: {
	        deleted : false
	    }
	}).then(function(rows){
		// 利用可能なサブスクリプションを配列に追加
		rows.forEach(function (row) {
			let subscription = {};
			subscription.endpoint = row.endpoint;
			subscription.expirationTime = null;
			subscription.keys = {
				p256dh: row.publicKey,
				auth: row.auth
			};
			subscribers.push(subscription);
		});
		// 利用可能なサブスクリプション全てに対して通知を発信
		Promise.all(subscribers.map(subscription => {
		    return webpush.sendNotification(subscription, JSON.stringify(params), {});
		}))
		.then(function(res){
			response.setHeader('Content-Type', 'text/plain');
			response.end('Request data is successfully pushed.');
		})
		.catch(function(err){
			console.log('ERROR', err);
		});
	});
});

これらを合わせると以下のようになります。

const express = require('express');
const webpush = require('web-push');
const bodyParser = require('body-parser');
const Sequelize = require('sequelize');
const app = express();

// CORSを許可する
app.use(function(req, res, next) {
    res.header('Access-Control-Allow-Origin', '*');
    res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
    next();
});

// urlencodedとjsonは別々に初期化する
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

// DBに接続
const sequelize = new Sequelize('接続文字列', {
	logging: false,
	operatorsAliases: false
})

// テーブルを作成
const subscriptions = sequelize.define('subscriptions', {
	id: {
		type: Sequelize.INTEGER,
		autoIncrement: true,
		primaryKey: true
	},
	registrationID: {
		type: Sequelize.STRING,
		allowNull: false
	},
	publicKey: {
		type: Sequelize.STRING,
		allowNull: false
	},
	auth: {
		type: Sequelize.STRING,
		allowNull: false
	},
	endpoint: {
		type: Sequelize.STRING,
		allowNull: false
	},
	deleted: {
		type: Sequelize.BOOLEAN,
		allowNull: false,
		defaultValue: false
	}
}, {
	freezeTableName: true,
	timestamps: true,
	indexes:[{
		unique: false,
		fields:['deleted']
	}]
});

subscriptions.sync();

// ルートにアクセスされたら文字列を表示
app.get('/', function(req, res) {
	res.setHeader('Content-Type', 'text/plain');
	res.send('This is PWA Test App');
});

// addでアクセスされたらサブスクリプションをDBに追加する
app.post('/add', function(req, res) {
	let data = req.body;
	let response = res;
	subscriptions.create({
		registrationID: data.registrationID,
		publicKey: data.key,
		auth: data.auth,
		endpoint: data.endpoint
	}).then(function(){
		response.setHeader('Content-Type', 'text/plain');
		response.end('New subscriptions is successfully added.');
	});
});

// deleteでアクセスされたら古いサブスクリプションの削除フラグをtrueにする
app.post('/delete', function(req, res) {
	let data = req.body;
	subscriptions.update({
	    deleted: true
	},{
	    where: {
            registrationID: data.registrationID
	    }
	});
});

// pushでアクセスされたら削除フラグがfalseのものに対して通知を送る
app.post('/push', function(req, res) {
	let response = res;
	let data = req.body;
	let subscribers = [];

	// 通知の内容
	const params = {
	    title: data.title,
	    msg: data.message,
	    icon: '/path/to/icon'
	};

	webpush.setVapidDetails(
	    'admin@xxx.jp',
	    'Firebase Consoleから取得した公開鍵',
	    'Firebase Consoleから取得した秘密鍵'
	);

	subscriptions.findAll({
	    where: {
	        deleted : false
	    }
	}).then(function(rows){
		// 利用可能なサブスクリプションを配列に追加
		rows.forEach(function (row) {
			let subscription = {};
			subscription.endpoint = row.endpoint;
			subscription.expirationTime = null;
			subscription.keys = {
				p256dh: row.publicKey,
				auth: row.auth
			};
			subscribers.push(subscription);
		});
		Promise.all(subscribers.map(subscription => {
		    return webpush.sendNotification(subscription, JSON.stringify(params), {});
		}))
		.then(function(res){
			response.setHeader('Content-Type', 'text/plain');
			response.end('Request data is successfully pushed.');
		})
		.catch(function(err){
			console.log('ERROR', err);
		});
	});
});

// サーバ起動
const server = app.listen(process.env.PORT || 8000);

次回予告

以上で、サブスクリプション をDBで管理し、有効なものにだけ通知を発信する仕組みができました。 続いて、このプログラムを叩く、クライアント側の実装を行います。

【PWA】シリーズPWA (8) 複数の端末に通知を送る

ここまでで、任意の内容でプッシュ通知を送ることはできました。

ところが現状では、今後複数のサブスクリプションが増えた時に対応ができません。

push.jsは配列にて複数のサブスクリプションを送れるようになっているため、サブスクリプション が増えること自体は問題ありません。

ところが現状push.jsにハードコーディングされているため、増えれば増えるほどpush.jsに追記をしていかなければいけません。

そこでここからさらに一歩踏み込み、サブスクリプションの情報を永続的に管理できる様に変えてゆきます。

基本的にはこちらで解説されているように、DBを利用してユーザー管理をする要領でサブスクリプション を管理します。

ajike.github.io

今回は、このデータベースとの連携部分を実装してゆきます。 サブスクリプションの登録・更新・プッシュの機能があるので、APIとして機能を提供できるようにします。

  1. push.jsを別環境に切り離してWebアプリ化し、登録・更新・プッシュのAPIを公開。今回はフレームワークとしてExpressを利用。
  2. APIを通じて受け取ったサブスクリプションの情報をDBで管理する。今回はPostgreSQLを使用。
  3. クライアントからはサブスクリプションの情報をヘッダーに含めて、push.jsにリクエストを送るようにする。

まずは、APIを提供するところから始めます。

ExpressでAPIを提供

まずはpush.jsを以下のような骨組みを以下のようにします。

ExpressというWebアプリケーションフレームワークを使用しますので、npmにてインストールください。

expressjs.com

const express = require('express');
const app = express();

// addでアクセスされたらサブスクリプションをDBに追加する
app.post('/add', function(req, res) {
});

// deleteでアクセスされたら古いサブスクリプションの削除フラグをtrueにする
app.post('/delete', function(req, res) {
});

// pushでアクセスされたら削除フラグがfalseのものに対して通知を送る
app.post('/push', function(req, res) {
});

// サーバ起動
const server = app.listen(process.env.PORT || 8000);

Expressの文法は割愛しますが、node push.jsでサーバーが立ち上がるので、その環境(ローカルだとhttp://localhost:8000になります)に対して「/add」「/delete」「/push」というURLでアクセスが会ったときにそれぞれ何かしらの処理を割り振ることができるようになりました。

アクセス時のリクエストヘッダーにサブスクリプションの情報が入っているため、reqオブジェクトのbodyを取得できるようにしなければいけません。

ところがExpressの仕様によると現状はbodyを取得するためにはいちどパーサーを通さないとundefinedになってしまうため、以下の記述を追加します。

const express = require('express');
const bodyParser = require('body-parser');
const app = express();

// CORSを許可する
app.use(function(req, res, next) {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
  next();
});

// urlencodedとjsonは別々に初期化する
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

// addでアクセスされたらサブスクリプションをDBに追加する
app.post('/add', function(req, res) {
  console.log(req.body);
});

// deleteでアクセスされたら古いサブスクリプションの削除フラグをtrueにする
app.post('/delete', function(req, res) {
  console.log(req.body);
});

// pushでアクセスされたら削除フラグがfalseのものに対して通知を送る
app.post('/push', function(req, res) {
  console.log(req.body);
});

// サーバ起動
const server = app.listen(process.env.PORT || 8000);

こうすることでリクエストヘッダーのbodyを取得できるようになります。

また、クライアントからはfetch APIを使ってリクエストを投げるようにする予定なので、今のうちにCORSエラーが起きないようにしておきます。

こちらで(1)の実装は完了しました。

テーブル定義

DBとの連携部分を実装する前に、テーブル定義を行います。

以下のようなテーブルでサブスクリプションのデータを管理します。

id registrationID publicKey auth endpoint deleted
INTEGER
AUTO INCREMENT
TEXT
NOT NULL
TEXT
NOT NULL
TEXT
NOT NULL
TEXT
NOT NULL
BOOLEAN
NOT NULL
デフォルトはFALSE
主キー 通知許可時にクライアントから送られる値を格納 同左 同左 同左 この値によって有効なサブスクリプションかどうかを判別

そして通知送信時にはdeletedカラムがFALSEのものに対して通知を送るため、このカラムに対してインデックスを生成します。

DB作成・DB接続・テーブル作成

DBはプログラムでは作成せず、あらかじめPostgreSQLをインストールし、「subscriptions」という名前でDBを作成します。

その前提で、DBに接続してテーブルを作成するところからプログラムで実装します。

生のSQLを発行しても良いのですが、ここではDBごとの方言を吸収できるよう、SequalizeというORマッパーを利用します。

www.npmjs.com

sequelize.org

const express = require('express');
const bodyParser = require('body-parser');
const Sequelize = require('sequelize');
const app = express();

// CORSを許可する
app.use(function(req, res, next) {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
  next();
});

// urlencodedとjsonは別々に初期化する
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

// DBに接続
const sequelize = new Sequelize('接続文字列', {
	logging: false,
	operatorsAliases: false
})

// テーブルを作成
const subscriptions = sequelize.define('subscriptions', {
	id: {
		type: Sequelize.INTEGER,
		autoIncrement: true,
		primaryKey: true
	},
	registrationID: {
		type: Sequelize.STRING,
		allowNull: false
	},
	publicKey: {
		type: Sequelize.STRING,
		allowNull: false
	},
	auth: {
		type: Sequelize.STRING,
		allowNull: false
	},
	endpoint: {
		type: Sequelize.STRING,
		allowNull: false
	},
	deleted: {
		type: Sequelize.BOOLEAN,
		allowNull: false,
		defaultValue: false
	}
}, {
	freezeTableName: true,
	timestamps: true,
	indexes:[{
		unique: false,
		fields:['deleted']
	}]
});

subscriptions.sync();

// addでアクセスされたらサブスクリプションをDBに追加する
app.post('/add', function(req, res) {
});

// deleteでアクセスされたら古いサブスクリプションの削除フラグをtrueにする
app.post('/delete', function(req, res) {
});

// pushでアクセスされたら削除フラグがfalseのものに対して通知を送る
app.post('/push', function(req, res) {
});

// サーバ起動
const server = app.listen(process.env.PORT || 8000);

こちらでサーバー起動時にDBに接続し、テーブルが存在しなかったらテーブルを作成するようになりました。

次回予告

次回は、サブスクリプションを登録・削除、そしてpushするAPIを作成します。

特定のディレクトリ配下のデフォルトの文字コードを変更する

.htaccessに以下を記述。
今回はShift-JISにする想定です。

AddDefaultCharset shift-jis
AddType "text/html; charset=shift-jis" .html .php

php_value default_charset               Shift_JIS
php_value mbstring.language             Japanese
php_value mbstring.http_input           auto
php_value mbstring.http_output          SJIS
php_value mbstring.internal_encoding    Shift_JIS

【PWA】シリーズPWA (7) 暗号化してみよう

VAPID

やっぱり通知内容をカスタマイズしたい。 今回はそのために、VAPIDと呼ばれる仕組みを利用します。

必要なものは、Firebase Consoleから取得できる公開鍵と秘密鍵のペア。

SSHのようにこのキーペアを利用します。

利用する箇所は(1)通知登録時のPushManager (2)メッセージングサービスに通知を送るプログラム の2箇所です。

 

まず、Firebase Consoleからキーペアを取得します。

取得方法はUIの更新などあるため、今回も割愛しますが、以下のようなbase64形式のキーが取得できるはずです(セキュリティ上、...で省略しています)

公開鍵: BGz_P8oc2IJUlpLst ……
秘密鍵: 85FdjzL1HH52YtvFluR ……

ここまで取得できたら、まずmain.jsから変えてゆきましょう。

pushManager.subscribeにわたすオプションに項目を追加します。

navigator.serviceWorker.ready.then(function(registration) {
    return registration.pushManager.getSubscription().then(function(subscription) {
       if (subscription) return subscription;
        return registration.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: convertedVapidKey
        }).then(function (subscription) {
            console.log(subscription.endpoint);  //この値の「https://fcm.googleapis.com/fcm/send/」以下の文字列がトークンになります。
        });
    })
});

applicationServerKeyという項目が追加されたのはわかりましたが、convertedVapidKeyという新登場の変数がありますね。

こちらの変数に格納する値を設定します。 先ほどFirebase Consoleから取得した公開鍵を埋め込んでいる箇所がありますので、ご確認ください。

// Base64形式をUnit8形式に変換する
var urlBase64ToUint8Array = function(base64String) {
  var padding = '='.repeat((4 - base64String.length % 4) % 4);
  var base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
  var rawData = window.atob(base64);
  var outputArray = new Uint8Array(rawData.length);
  for (var i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}
var vapidPublicKey = '******';  // Firebase Consoleから取得した公開鍵を指定
var convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey);

urlBase64ToUint8Arrayという関数がありますがここでは説明を割愛します。

スニペット的なものと捉えてください。

まとめると以下のようになります。

// Base64形式をUnit8形式に変換する
var urlBase64ToUint8Array = function(base64String) {
  var padding = '='.repeat((4 - base64String.length % 4) % 4);
  var base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
  var rawData = window.atob(base64);
  var outputArray = new Uint8Array(rawData.length);
  for (var i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}
var vapidPublicKey = '******';  // Firebase Consoleから取得した公開鍵を指定
var convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey);

navigator.serviceWorker.ready.then(function(registration) {
    return registration.pushManager.getSubscription().then(function(subscription) {
        if (subscription) {
            //viewSubscription(subscription);
            return subscription
        }
        return registration.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: convertedVapidKey
        }).then(function (subscription) {
            console.log(subscription.endpoint);  //この値の「https://fcm.googleapis.com/fcm/send/」以下の文字列がトークンになります。
        });
    })
});

更に一手間加えます。

暗号化前は購読時に取得できるendpointの値をpush.jsに埋め込んでいましたが、今回は追加で値を取得して埋め込みます。

// Base64形式をUnit8形式に変換する
var urlBase64ToUint8Array = function(base64String) {
  var padding = '='.repeat((4 - base64String.length % 4) % 4);
  var base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
  var rawData = window.atob(base64);
  var outputArray = new Uint8Array(rawData.length);
  for (var i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}
var vapidPublicKey = '******';  // Firebase Consoleから取得した公開鍵を指定
var convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey);

navigator.serviceWorker.ready.then(function(registration) {
    return registration.pushManager.getSubscription().then(function(subscription) {
        if (subscription) return subscription
        return registration.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: convertedVapidKey
        }).then(function (subscription) {
            var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '';
            var key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : '';
            var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : '';
            var auth = rawAuthSecret ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : '';
            var endpoint = subscription.endpoint;
            console.log(key);
            console.log(auth);
            console.log(endpoint);
        });
    })
});

keyとauthという値を取得しています。

サーバーのキーペア以外にも、この端末へ通知を届けるためのサブスクリプションのキーペアが存在し、そちらを取得しています。

こちらもスニペット的に機械的に利用してしまったほうが今は幸せかもしれません。

メッセージングサービスに暗号化した情報を送る

push.jsはガラリと変わります。

web-pushモジュールを使用しますので、あらかじめnpmでインストールください。

www.npmjs.com

const webpush = require('web-push');

webpush.setVapidDetails(
  'admin@xxxxx.jp',
  '*******', // Firebase Consoleから取得した公開鍵を指定
  '*******'  // Firebase Consoleから取得した秘密鍵を指定
);

// 複数指定可能 サブスクリプションが有効であればここに登録した全てのエンドポイントに通知が送られます
const subscribers = [{
  endpoint: '*******', // main.jsのconsoleに表示させたエンドポイントを指定
  expirationTime: null,
  keys:{
    p256dh: '*******', // main.jsのconsoleに表示させたkeyを指定
    auth: '*******' // main.jsのconsoleに表示させたauthを指定
  }
}];

// 通知の内容
const params = {
  title: 'プッシュ通知です!',
  msg: 'これはサーバから送っています.メッセージとアイコンも送っています ',
  icon:  '/icon/launcher-icon-144x144.png'
};

// 通知を送信
Promise.all(subscribers.map(subscription => {
  return webpush.sendNotification(subscription, JSON.stringify(params), {});
}))
.then(function(res) { console.log(res); })
.catch(function(err) { console.log(err); });

また、serviceworker.jsでは前回通知内容に固定の文字列を渡していたので、以下に変更します。

self.addEventListener('push', function(event) {
  var data = event.data.json();
  var title = data.title;
  var icon = data.icon;
  var msg = data.msg;
  event.waitUntil(
    self.registration.showNotification(title, {
      icon: icon,
      body: msg
    })
  )
});

暗号化前と同様にコンソールからこのプログラムを実行することで通知が送られるはずです。

通知のメッセージやタイトルがこちらで指定した内容になっていることも確認いただけると思います。

次回予告

次回は、この仕組みの上で複数端末に同時に通知を送る方法について解説します。

【PWA】シリーズPWA (6) プッシュ通知を送ってみる

はじめに

WebPushと呼びます。 ServiceWorkerを使わなくてもJavascriptでプッシュ通知自体は実装できます。

しかしそれはサイトをブラウザ表示したときのみ通知されるもので、そうでなくても送られるプッシュ通知とは異なるものです。

(厳密にいうと参照先のオブジェクトが異なります。通常の通知機能はNotificationオブジェクトを利用しますが、プッシュ通知機能はServiceWorkerRegistration.showNotificationメソッドを利用します。ただ引数はNotificationオブジェクトのコンストラクタと同じだとか。プッシュ通知を実装場合は、後者を使います)

ただし、ネイティブアプリケーションのそれとは異なり、WebPush によるプッシュ通知はブラウザが起動しているときでないと受信できません。

ブラウザを起動していない間にアプリケーションサーバーからプッシュ通知が送信された場合は、その次にブラウザを起動したタイミングで受信されます。

今回はキャッシュ制御よりも若干複雑になりますが、一つ一つをゆっくり紐解きつつ丁寧に追ってゆきます。

前提知識としてのWebPush APIの仕様やPushManagerの仕様についてはあえて解説はしませんので、適宜下記のリファレンスや参考記事等も合わせてご覧ください。

developer.mozilla.org

developer.mozilla.org

developers.google.com

qiita.com

基本的な仕組み

このような流れになります。

  1. メッセージングサービスでプロジェクトの登録
  2. manifest.jsonにプロジェクトとの紐付けを記述
  3. クライアント側ではServiceWorker登録時に通知の購読をしておく
  4. メッセージングサービスに対してリクエストを送る。この時にメッセージングサービスを利用するための認証情報やエンドポイントなどをリクエストヘッダーに混ぜて送る。この時に正しく暗号化されていれば通知に表示するメッセージなどをpayloadで送ることもできる。
  5. メッセージングサービスが、送信されてきたエンドポイントへ通知を送る。
  6. ServiceWorkerが通知を受け取ったら、showNotificationメソッドを利用してクライアントアプリ(ブラウザ)に通知

フロントエンドでの対応が必要となるのはこのうち 1 / 2 / 6 です。 特に 4 はバックエンドの開発やDB・インフラ設計が必要になります。

なにを用意すればWebPushが利用できるの?

先述の通りフロントエンドのみでは完結しません。WebPush実装に必要なものは次の3要素です。

  • プッシュサーバ:メッセージングサービスを提供するサーバ。インフラやサービスを構築する代わりにFirebaseが提供するFirebase Cloud Messagingを利用するのが近道。
  • アプリケーションサーバ:プッシュサーバに通知をリクエストするサーバ。今回でいうとHTMLをアップしているWebサーバーです。
  • クライアントアプリ:HTMLとServiceWorker。

アプリとメッセージングサービスを紐付けましょう

まずはアプリとメッセージングサービスとを紐づけるために、 というのもこれを最初に行なっていないと(後述するvapidを使用していない実装方法の場合) 通知を許可するダイアログを表示することも、通知を購読したり表示するロジックの検証をすることもできません。

現状メッセージングサービスはFirebase Cloud Messaging一択と言っても差し支えないので、Firebaseでプロジェクトを登録します。

プロジェクトの登録や必要な情報を取得するためのUIや手順は、各種サードパーティのコンソールと同様に頻繁に変更されるのでここでは割愛します。

無事に取得できたら、manifest.jsonにてgcm_sender_idというキーに対応する値としてメッセージングサービスに登録したプロジェクトの送信者ID番号を記述します(ここでは01234567が送信者ID番号であると仮定しています)。

"gcm_sender_id" : "01234567"

これでアプリとメッセージングサービスが紐づいたので、通知を許可するダイアログの表示と、通知の購読を実装します。

通知を許可するダイアログの表示と、通知の購読

こちらは至極シンプルで、ServiceWorkerRegistration.pushManager.subscribeメソッドを利用します。

基本的にプッシュ通知まわりはこのPushManagerが提供するインターフェイスを利用すことになります。

ServiceWorkerが登録されるとPushManagerも利用できるため、そのタイミングでsubscribeメソッドを呼び出します。

navigator.serviceWorker.ready.then(function(registration) {
	return registration.pushManager.subscribe({
		userVisibleOnly: true
	}).then(function (subscription) {
		console.log('通知の購読を開始します');
	});
});

subscribeメソッドを実行した時にメッセージングサービスとアプリが紐づいていれば、通知の許可をたずねるダイアログが表示され、許可したら購読が始まります。

ですがこのままだと、次回以降の来訪時にも、通知の購読を行なってしまうため、すでに購読済みの通知があるかを判定します。

こちらはmain.jsに記述をします。

navigator.serviceWorker.ready.then(function(registration) {
	return registration.pushManager.getSubscription().then(function(subscription) {
		if (subscription) return subscription;
		registration.pushManager.subscribe({
			userVisibleOnly: true
		}).then(function (subscription) {
			console.log('購読を登録しました');
		});
	})
});

getSubscriptionメソッドにて、すでに購読済みの通知があるかどうか判定します。

その際に購読済みの通知がある場合はそこで終了し、なければ先述した通り購読の開始を行います。

こちらで、通知を許可するダイアログの表示と、通知の購読が実装できました。

通知を表示する

実際に通知が来た時に、通知を表示するのはServiceWorkerの役割です。

通知がくるとpushイベントが発火するため、serviceworker.jsに以下のように記述します。

self.addEventListener('push', function(event) {});

ここに通知が来たときの処理を追記してゆきます。

eventオブジェクトにて(然るべき暗号化を行なっていれば)通知の内容を参照できるのですが、今回は暗号化については割愛するので、eventオブジェクトは参照せず、固定のタイトルやテキストを表示させることにします。

self.addEventListener('push', function(event) {
	self.registration.showNotification('PWA Test', {
		icon: '/icon/launcher-icon-144x144.png',
		body: 'Lorem ipsum dolor sit amet, consectetur adipisicing elit'
	})
})

通知の表示はshowNotificationメソッドで行います。

第一引数に通知のタイトルを、第二引数にアイコンとテキストなどを指定することができます。

これで、メッセージングサービスから通知が来た時に、ブラウザにその内容を表示することができます。

ちなみに、もし然るべき暗号化がされており、eventオブジェクトに通知の内容が格納されている場合は、以下のようにすることができます。

self.addEventListener('push', function(event) {
	var data = event.data.json();
	var title = data.title;
	var icon = data.icon;
	var body = data.body;
	self.registration.showNotification(title, {
		icon: icon,
		body: body
	})
})

アプリケーションサーバーからメッセージングサービスへリクエストを送る(からの、送って通知を表示する)

今回はまだ実装も初期段階なので、

(1)直接プログラムを叩いてリクエストを送ります

(2)また複数端末に同時に通知を送ることはせず自身の端末にのみ通知を送ります。

理由は(1)(2)ともバックエンドの開発が必要になるためです。 比較的実装が楽なNode.jsにて(1)のプログラムを「push.js」というファイル名で作成してみます。

//requestモジュールをrequire
var request = require('request');

//オプションを定義
var url = 'https://fcm.googleapis.com/fcm/send';
var serverkey = '*****'; // Firebaseコンソールから取得できるサーバーキー
var token = '*****'; // 購読登録時にエンドポイントから取得できるトークン(端末ひとつひとつを識別する)

// HTTP header
var headers = {
	'Content-Type': 'application/json',
	'Authorization': 'key=' + serverkey
};

// request options
var options = {
	url: url,
	method: 'POST',
	headers: headers,
	json: {
		'to': token
	}
};

//リクエスト送信
request(options, function(error, response, body) {
	if (body) {
		console.log(body);
	}
	if (error) {
		console.log(error);
	}
});

こちらが実装できたらコンソールから node push.js で実行することで通知が行くはずです。

ちなみにrequest optionsとしてわたすjsonには通知内容となるpayloadも送れると書かれている記事がありますが、現在は(暗号化しないとpayloadをおくれないという)仕様変更により割愛しています。

さてここでサーバーキーはFirebaseコンソールから取得できるとして、トークンはどうやって取得するのでしょうか。

main.js側に一手間加えます。

navigator.serviceWorker.ready.then(function(registration) {
	return registration.pushManager.getSubscription().then(function(subscription) {
		if (subscription) return subscription;
		registration.pushManager.subscribe({
			userVisibleOnly: true
		}).then(function (subscription) {
			console.log(subscription.endpoint);  //この値の「https://fcm.googleapis.com/fcm/send/」以下の文字列がトークンになります。
		});
	})
});

コンソールに、subscription.endpoint プロパティを出力しています。

Push API の PushSubscriptionにはその端末を識別するエンドポイントや、このあと暗号化に用いる認証キーなどが格納されています。

ちなみにこのエンドポイントは端末によって異なりますので、複数の端末に通知を送りたい時は、全てのエンドポイントをDBなどで管理する必要があります。

今回の実装でいうと、コンソールに出力したエンドポイントからトークンとなる文字列をコピーし、push.jsに貼り付けることになります。

ちなみに

予備知識として補足

Basic認証が設定されていても通知は届く?
届きます。ただしミニ情報バーの実装について触れている通り、manifest.jsonを正しく読めているようにしましょう(マニフェストのみ認証を解除するか、usecredential属性を設定)
また、OSの通知UIがアイコンを取得しにいけない場合があるので、アイコンが置かれているディレクトリは認証を解除してあげたほうがベターです
通知のサブスクリプションが変わるタイミングはいつ?
以下のとおりです。
(1)通知のパーミッションに変更があった時
(2)ServiceWorkerがUnregisterされた時
意外にもServiceWorkerにアップデートがあった時は起こらないよう。

次回予告

次回は通知内容をカスタマイズするため、暗号化について触れたいと思います。

【PWA】シリーズPWA (5) キャッシュを制御して高速化やオフライン対応をしてみる

ServiceWorkerを動かすための最低限のベースができたので、ServiceWorkerに仕事をさせてみましょう。

まずは、メジャーな実装となるキャッシュ制御、つまるところ高速化やオフライン対応の実装を行います。

リクエストがあったらキャッシュを返すようにしてみる

今回の肝となる仕組みです。

ちなみに以前策定されていたHTML5のApplication Cache APIは、セキュリティ的な理由により廃止されました。

基本的な仕組み

  1. Cache API を用いて、指定されたファイルのキャッシュを構築する。ServiceWorkerのinstall時(installイベントのコールバック)にて行うのがお作法なよう。
  2. ブラウザからのリクエストがきたときに、そのリクエストを横取りして、構築してあったキャッシュからリクエストと同じURLのリソースを返す。リクエストの横取りはfetchイベントのコールバックで行うことができる。
  3. ServiceWorkerが更新されたとき、またはキャッシュしたいファイルに変更があった時に、古いキャッシュを破棄する。

Cache API

このAPIの概要や仕様はこちらを参照ください。

developer.mozilla.org

キャッシュの構築

新しいキャッシュを作るための CacheStorage.open の呼び出しと、キャッシュを追加するための addAll() の使用で構成されています。

caches.open('cache-v1').then(function(cache) {
  return cache.addAll([
    '/index.html',
    '/style.css',
    '/app.js',
    '/images/logo.jpg'
  ]);
});

インストール時に構築するのがお作法らしいので、こちらをinstallイベントに紐付けます。

self.addEventListener('install', function(event) {
   caches.open('cache-v1').then(function(cache) {
     return cache.addAll([
        '/index.html',
        '/style.css',
        '/app.js',
        '/images/logo.jpg'
     ]);
   })
});

addAllメソッドはPromiseを返すため(つまり全てのキャッシュを追加した終わったら、というタイミングが取れる)、addAllが完了するまで次のライフサイクルに遷移しないようにします。

そのため、waitUntilメソッドでラップします。

self.addEventListener('install', function(event) {
 event.waitUntil(
   caches.open('cache-v1').then(function(cache) {
     return cache.addAll([
        '/index.html',
        '/style.css',
        '/app.js',
        '/images/logo.jpg'
     ]);
   })
 );
});

キャッシュが追加されたかどうかは、Google Chromeの開発者ツールでApplicationタブ「 Cache > Cache Storage」から確認できます。

f:id:ShinImae:20190810155415p:plain

リクエストを横取りする

ServiceWorkerはブラウザとサーバーの間に立つものなので、ブラウザからのリクエストを横取りできる。

横取りした際に、先に構築したキャッシュのうちリクエストとマッチするものをレスポンスとして返すことができる。

ブラウザからのリクエストを受けた時に処理を紐づけるにはfetchイベントを使います。

fetchイベント

このイベントの仕様についてはこちらを参照ください。

developer.mozilla.org

self.addEventListener('fetch', function(event) {
  // ブラウザからのリクエストを横取りできる。
  // リクエストに対して何もしない場合は単にreturnするだけでよい。
  return;
});
コード中の引数eventにはFetchEventオブジェクトが渡ってきます。このFetchEventの.respondWith()メソッドを使うことで、ブラウザに対するレスポンスをすり替えられます。

とのことなので、たとえばどんなリクエストに対しても’Hello World'という文字列を返すこともできます。

self.addEventListener('fetch', function(event) {
  return event.respondWith(new Response('Hello world'));
});

ちなみに、respondWithメソッドの仕様についてはこちらを参照ください。

developer.mozilla.org

この要領で、キャッシュの中からリクエストのURLにマッチするものを探し出してreturnをすれば良いのです。

matchメソッドについては先のリファレンスを参照ください。

リクエストされたURLは、fetchイベントのコールバック関数内でevent.requestで参照できます。

それをもとに、キャッシュの中からリクエストにマッチするものを探し出す方法は次の通り。

caches.open('cache-v1').then(function(cache){
  return cache.match(event.request).then(function(response) {
    return response;
  });
})

ただ、もしキャッシュの中にリクエストされたURLがなかったときはネットワークエラーとなってしまいます。

そこでもしキャッシュの中にリクエストされたURLがなかったときは、リクエストされたURLをネットワークに取得しにゆきます。

caches.open('cache-v1').then(function(cache){
  return cache.match(event.request).then(function(response) {
    return response || fetch(event.request);
  });
})

取得にはfetch(ServiceWorkerのfetchイベントとは異なるのでご注意を)を用います。

event.request は先述したfetchイベントのコールバック関数内で参照できる変数であることを思い出してください。

これらを組み合わせると、次のようになります。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open('cache-v1').then(function(cache) {
      return cache.match(event.request).then(function(response) {
        return response || fetch(event.request);
      });
    })
  );
});

これで、リクエストがキャッシュと同じであれば、サーバーではなくキャッシュを返すようになりました。

また、さらにもう一手間かけるとするならば、リクエストされたURLがキャッシュに存在しなかった時、つまり fetch(event.request) によってリソースが取得できた時に、キャッシュに追加しておくということが可能です。

fetch(event.request).then(function(response) {
  return caches.open('cache-v1').then(function(cache) {
    cache.put(event.request, response.clone());
    return response;
  });
});  

response(またはrequest)は一度しか参照できないそうなので、複製したものを参照しています。

キャッシュに追加するのがresponseではなくresponse.clone()なのはそのためです。

このフォールバックも含めると以下のようになります。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open('cache-v1').then(function(cache) {
      return cache.match(event.request).then(function(response) {
        return response || fetch(event.request).then(function(response) {
          return caches.open('cache-v1').then(function(cache) {
            cache.put(event.request, response.clone());
            return response;
          });
        });
      });
    })
  );
});

古いキャッシュを削除する

ですがこれだけで終わりではありません。

このままではキャッシュのバージョン(「cache-v1」で表されるキー)が新しくなるたびに、古いキャッシュが残り続けてしまいます。

キャッシュもストレージの一種なので、キャッシュのバージョンが変わったタイミングで、古いキャッシュを破棄する必要があります。

新しいキャッシュが構築されてから破棄することになるので、ServiceWorkerが変更された時に発火するactivateイベントを利用します。

キャッシュのバージョンはserviceworker.jsに書かれているため、この文字列の変更はそのままServiceWorkerの変更となります。

self.addEventListener('activate', function(event) { 
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.filter(function(cacheName) {
          return cacheName !== 'cache-v2';
        }).map(function(cacheName) {
          return caches.delete(cacheName);
        })
      );
    })
  );
});

keys / deleteメソッドについては先のリファレンスを参照ください。

ここでは消したくないキャッシュのバージョンが「cache-v2」とハードコーディングされています。

そこで、このキャッシュ名を変数に格納しつつ、まとめます。

const CACHE_NAME = 'cache-v1';
const cacheList = [ '/index.html', '/style.css', '/app.js', '/images/logo.jpg' ];

self.addEventListener('install', function(event) {
  event.waitUntil(
   caches.open(CACHE_NAME).then(function(cache) {
     return cache.addAll(cacheList);
   }).then(function(){
     self.skipWaiting();
   })
  );
});

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open(CACHE_NAME).then(function(cache) {
      return cache.match(event.request).then(function(response) {
        return response || fetch(event.request).then(function(response) {
          return caches.open(CACHE_NAME).then(function(cache) {
            cache.put(event.request, response.clone());
            return response;
          });
        });
      });
    })
  );
});

self.addEventListener('activate', function(event) { 
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.filter(function(cacheName) {
          return cacheName !== CACHE_NAME;
        }).map(function(cacheName) {
          return caches.delete(cacheName);
        })
      );
    }).then(function(){
      clients.claim();
    })
  );
});

キャッシュ取得後にアクセスして、Google Chromeの開発者ツールのNetworkタブで、以下のようにServiceWorkerからレスポンスが返されていれば成功です。

f:id:ShinImae:20190810155451p:plain

なぜ「キャッシュ取得後にアクセス」と書いたかは後述しますので、今はちょっとだけご留意ください。

ともあれ、こちらでリクエストに対してキャッシュを返せるようになりました。

下層コンテンツが大量にあるサイトなどではすぐにキャッシュがいっぱいになってしまうので、そう言った場合はオフライン用のエラーページを返すようにしたり、またはフォントなどサイズの大きなリソースのみを返すでも良いかもしれません。

どれくらいまでキャッシュできるのかという制限についてはこちらを参照ください。

love2dev.com

基本的にはそのマシンのハードディクスの容量に対する割合になりますが、一般的な目安は20GBに対してキャッシュストーレージのクオータは50MBです。

オフラインのときは

オフライン対応も、上記と同じ要領になります。

オンライン時のリソースをそのまま返すのも一つの手法ですが、情報の鮮度に差異が出てしまうので、よくあるのがオフライン用のリソースを返すやり方です。

下記はtrivagoでの手法を少し噛み砕いて改変したものです。

/offline/ 配下にある同じ名前のリソースを返しています(もちろんこのリソースもキャッシュに追加されている前提です)。

caches.open('cache-v1').then(function(cache){
 let req = new URL('/offline/', event.request);
  return cache.match(req).then(function(response) {
    return response;
  });
})

オフライン状態の判別は、navigator.onLineの値または、いちど試しにfetchをかけてその結果で判定します。

navigator.onLineプロパティの仕様についてはこちらを参照ください。

developer.mozilla.org

ちなみにtrivagoは、情報の鮮度に左右されないよう、オフライン時はブラウザで楽しめる迷路ゲームをレスポンスとして返しています。

だが、実際にCSSを編集して更新してみると

先に結論を述べてしまいますが、CSSを編集してキャッシュ名を変更したのちにリロードをしても、CSSの変更は反映されません。

反映されるのはそののち もう一度リロードをした時 、つまるところ編集したCSSがキャッシュとして取得されたのちです。

これについては下記の記事で述べられている通り、オフラインファーストという考え方に基づいているそうです。

qiita.com

つまりアプリ起動時は既にストアしてあるバージョンで起動し、その際にアップデートがあれば取得しておく。

そして次の起動時にアップデートを適用するという、オフライン時でも確実に起動できることを優先した考えかたです。

・まずアプリケーションをインストールする
・起動されたらとにかくインストール済みのバージョンで動き出す
・起動後にバックグラウンドで更新の確認を行う
・更新バージョンがある場合はバックグラウンドでダウンロードとインストール処理を行う
・完全にシャットダウンして次に起動した時、更新済みのバージョンで起動する https://qiita.com/masato_makino/items/abc3866f367bfeb81576

リソースがServiceWorkerから返されているか確認するために「キャッシュ取得後にアクセス」と書いたのはこのような理由のためです。

WebサイトであればCSSの更新が即時反映されているべきですが、これはWebサイトではなくあくまでもPWAですので、ご注意ください。

さらにBASIC認証下の環境だと

どうやらrespondWithメソッドの引数に認証の必要なリクエストを渡してしまうと、403エラーを返してしまい、どうしてもキャッシュを返せませんでした。

例えばキャッシュを返す際にRequestオブジェクトを生成して、認証済みである旨を伝えるこんなやり方などが提示されていますが、残念ながら成功しませんでした。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open('cache-v1').then(function(cache) {
      return cache.match(event.request).then(function(response) {
        return response || fetch(new Request(event.request, {credentials: 'same-origin'})).then(function(response) {
          // 以下省略
        });
      });
    })
  );
});

この部分の記述です

new Request(event.request, {credentials: 'same-origin'})

出展はこちら

stackoverflow.com

Requestオブジェクトにおけるcredentialsプロパティについてはこちら

developer.mozilla.org

これが本当の答えなのかわからないのですが、Chromiumのバグかもしれないという説を見つけました。

demoed this to one of the Google engineers responsible for implementing ServiceWorker in Chrome, and he determined that it was a Chromium bug. Filed here: https://stackoverflow.com/questions/37934972/serviceworker-conflict-with-http-basic-auth

この件については、調査継続中です。