knowledge base

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

gulpでShift_JISのファイルを生成する(Gitでの文字化け対策も行う)

きっかけ

先日、とあるプロジェクトにてShift_JISでHTMLやCSSを作成しなければいけないという場面がありました。

ただそれだけではなく、そのファイルは近いうち(半年から1年後)にUTF-8にして公開しなければいけないとのこと。

エディタの文字コードShift_JISにしただけでは、のちのちUTF-8のものを公開する際に、わざわざ文字コードのコンバート作業が発生してしまいます。

VS Codeでコーディングをする際にもわざわざ毎回Shift_JISに変換してファイルを開き直す必要もあり、そこで、UTF-8で元ファイルを作成して、それをgulpを利用してShift_JISに変換して出力するようにしようと思いました。

HTML

gulp-convert-encoding というモジュールを利用します。

こちらはUTF-8からShift_JISに変換してくれるものです。

また、gulp-replaceを使用して、head内にあるcharsetの表記を置換します。

合わせて、gulp-cr-lf-replace というモジュールも併用して、改行コードも正規化しておきます。

var gulp = require('gulp');
var replace = require('gulp-replace');
var convertEncoding = require('gulp-convert-encoding');
var crLfReplace = require('gulp-cr-lf-replace');

gulp.task('html', function(){
	gulp.src('./html/**/**/*.html')
		.pipe(crLfReplace({changeCode: 'CR+LF'}))  //改行コード変換
		.pipe(replace(/meta charset="utf-8"/g, 'meta charset="shift_jis"')) //charsetの表記を置換
		.pipe(convertEncoding({to: "shift_jis"})) //sjis変換
		.pipe(gulp.dest('../'));
});    

SCSS/CSS

HTMLと同様の方法でコンパイル後にShift_JISに変換します。

var gulp = require('gulp');
var sass = require('gulp-sass');
var header = require('gulp-header');
var replace = require('gulp-replace');
var sassUnicode = require('gulp-sass-unicode');
var convertEncoding = require('gulp-convert-encoding');
var crLfReplace = require('gulp-cr-lf-replace');

gulp.task('scss', function() {
	gulp.src('./scss/**/**/*.scss', { base: './scss/'})
		.pipe(sassUnicode())
		.pipe(replace(/@charset "(Shift_JIS|UTF-8)";/g, ''))
		.pipe(header('@charset "shift_jis";\n\n'))
		.pipe(convertEncoding({to: "shift_jis"}))  //sjis変換
		.pipe(gulp.dest('../assets/css/'));
});   

以前の記事を参考に、CSSファイルの先頭にcharsetの記述を追加したり、擬似要素のcontentプロパティで用いられる数値文字参照が文字化けしない処理も追加しています。

shinimae.hatenablog.com

Gitとの連携

ところが、いざShift_JISで出力したファイルの差分を、Gitクライアントツールを通して見ると、文字化けを起こしてしまいます。

こちらを参考にして、Gitの設定を少しばかり編集します。

qiita.com

.gitattributes

*.html diff=sjis

.git/config

[diff "sjis"]
    textconv = iconv -f sjis

こうすることで、Gitクライアントツールでも文字化けすることなく、開発を行うことができました。

【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にアップデートがあった時は起こらないよう。

次回予告

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