概要
Polymer Japan のサイトが更新されたら自動でプッシュ通知を送るようにしました。
ユーザはトップページ右上のアイコンをクリックしてプッシュ通知をトグルします。
※ サイト訪問時にいきなりダイアログで聞かないように (Web Fundamentals: パーミッションの UX)
サイト管理者側は、
- コンテンツを追加、更新したら「PUBLISH」ボタンをクリック → /feed.xmlが更新される
- TriggerでPubSubHubbub/publish に更新通知を送られ、
- Callbackがきたら、最新の記事を取得し、/topics/feedにプッシュ通知が送られます
だいぶやっつけで作ってしまってますが、稼動しているサイトのソースは こちらです。
全体的な流れ
プッシュ通知のリクエスト
フロントエンド側はPolymerfireを使うと簡単にTokenを取得できます。取得できたTokenはiron-ajax経由でサーバサイドのCloud Functions for Firebaseで処理します。
<!-- Firebase のライブラリ読み込み -->
<link rel="import" href="../bower_components/polymerfire/firebase-app.html">
<link rel="import" href="../bower_components/polymerfire/firebase-messaging.html">
<!-- プッシュ通知の許諾を保存していくlocalStorage用のライブラリ読み込み -->
<link rel="import" href="../bower_components/app-storage/app-localstorage/app-localstorage-document.html">
<!-- Firebase Cloud Messagingへ送信するAjax用のライブラリ読み込み -->
<link rel="import" href="../bower_components/iron-ajax/iron-ajax.html">
...
<!-- Firebaseのサーバ設定 -->
<firebase-app auth-domain=...></firebase-app>
<!-- メッセージTokenの格納先設定 -->
<firebase-messaging token="{{_token}}"></firebase-messaging>
<!-- プッシュ通知の許諾をlocalStorageに保存 -->
<app-localstorage-document key="polymer-jp:v0:firebase-push-msg" data="{{_shouldPush}}"></app-localstorage-document>
<!-- autoがTrueになったらその値とともに/pushへtokenを投げる -->
<iron-ajax auto="[[_shouldPushRequest(_pushRequest,_token,_shouldPush)]]"
url="/push?add=[[_shouldPush]]&token=[[_token]]"></iron-ajax>
<!--
1. プッシュ通知のアイコンを表示して、タップされたら_pushToggleを呼ぶ
2. _shouldPushの値によってアイコンの表示を変える
-->
<paper-icon-button icon="i:[[_iconPush(_shouldPush)]]" toggles on-tap="_pushToggle"></paper-icon-button>
...
<script>
class PolymerJp extends Polymer.FirestoreElement(Polymer.Element) {
/**
* ブラウザ側のプッシュ通知の許諾(Notification.permission[granted,denied,default])
* に合わせてアイコンを変更する
*/
_iconPush(shouldPush){
return shouldPush && Notification.permission == 'granted' ? 'notifications' : 'notifications-none';
}
/**
* アイコンがクリックされたらトグルで_pushRequestと_shouldPushを変更する
*/
_pushToggle(e){
if(! this._shouldPush){
this.shadowRoot.querySelector('firebase-messaging').requestPermission().then(_=>{
this.setProperties({_pushRequest: true, _shouldPush: true});
});
}else{
this.setProperties({_pushRequest: true, _shouldPush: false});
}
}
/**
* アイコンがクリックされ、Tokenが取得できて、shouldPushの値が確定した時にAjaxリクエストを投げる
*/
_shouldPushRequest(pushRequest, token, shouldPush) {
return pushRequest && token && shouldPush !== undefined;
}
</script>
サーバサイド側もFirebaseのライブラリ(FCM)があって楽です。
// 各モジュールの読み込み
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const fcm = admin.messaging();
/**
* iron-ajaxで送信されてきたフラッグで処理を振り分け、tokenを登録
*/
exports.push = functions.https.onRequest((req, res) => {
if(req.query.add === 'true'){
// /topics/feedへ登録
fcm.subscribeToTopic(req.query.token, 'feed');
}else{
// /topics/feedから削除
fcm.unsubscribeFromTopic(req.query.token, 'feed');
}
res.send('OK');
});
サイト更新からプッシュ通知の送信
編集画面からは明示的に「PUBLISH」ボタンをクリックし、feed情報をCloud Firestoreに登録します。
<!-- ボタンクリックで_publishを呼び出す -->
<paper-button raised on-tap="_publish">PUBLISH</paper-button>
<script>
class PolymerJpMarkedEdit extends Polymer.Element {
...
_publish(e){
// feedコレクションからURLで検索
firebase.firestore().collection('feed').where('path','==',location.pathname).get().then(s=>{
// 一致するものがあったら消す
s.forEach(d=>firebase.firestore().collection('feed').doc(d.id).delete());
}).then(_=>{
// feedにコンテンツを登録
firebase.firestore().collection('feed').add({
date: firebase.firestore.FieldValue.serverTimestamp(),
id: 'https://polymer-jp.org'+location.pathname,
link: 'https://polymer-jp.org'+location.pathname,
description: this.doc.desc || '',
path: location.pathname,
title: this.doc.title
});
});
}
...
サーバサイド側は、事前にSubscriberの登録を手動でしておき、Firestoreのfeedコレクションが更新されたらupdateFeed
が起動、PubSubHubbubへPublish通知を送ります。
PubSubHubbub側からfeed.xmlの取得リクエストがくるのでfeed
で記事を返します。PubSubHubbubは記事のSubscriberへ更新通知を飛ばしてくるのでsubs
で最新記事を取得して、FCM側へプッシュ通知配信をリクエストします。
// 各モジュールの読み込み
const functions = require('firebase-functions');
const admin = require('firebase-admin');
// Firestore
const db = admin.firestore();
// Firebase Cloud Messaging(FCM)
const fcm = admin.messaging();
// Feed生成用ライブラリ
const Feed = require('feed');
// HTTP Request送信用ライブラリ
const request = require('request');
// Functionsのサイト毎設定を保存できるConfigライブラリ
const config = functions.config();
// PubSubHubbubの講読開始時に使用する暗号化ライブラリ
const crypto = require('crypto');
/**
* /feedコレクションの更新時に起動し、PubSubHubbubへPublishリクエストを送る
*/
exports.updateFeed = functions.firestore.document('/feed/{feedId}')
.onWrite(event => {
request({
url: 'https://pubsubhubbub.appspot.com/publish',
method: 'POST',
body: 'hub.mode=publish&hub.url='+config.server.url+'feed.xml'
}, (err, resp, body) => {
console.log(err,body);
});
return true;
});
/**
* /feedコレクションの更新時に起動し、PubSubHubbubへPublishリクエストを送る
*/
exports.feed = functions.https.onRequest((req, res) => {
// 仕様上PubSubHubbubに対応させるには`hub`プロパティを設定しておく
const feed = new Feed({
title: 'Polymer Japan',
id: config.server.url,
link: config.server.url,
hub: 'https://pubsubhubbub.appspot.com',
feedLinks: {
atom: config.server.url+'feed.xml',
}
});
db.collection('feed').orderBy('date','desc').limit(10).get()
.then(s=>s.docs.map(d=>{
const doc = d.data();
if(! feed.options.updated)
feed.options.updated=doc.date;
feed.addItem(doc);
}))
.then(_=>{
res.header('Content-Type', 'application/xml');
res.send( feed.rss2() );
});
});
/**
* Subscriber: PubSubHubbubからのリクエストは2種類
* - GET
* 講読開始時に適当なパスワードを`verify_token`にいれ、その一致を確認する
* - POST
* feed.xmlが更新されている場合にfeed.xmlがREQUEST BODYに入ってPOSTされてくる
* こちらも適当なパスワードを`HMAC secret`として登録しておき、HMAC署名での一致を確認する
* Firestoreのfeedコレクションから最新の記事をとりだし、FCM経由でプッシュ通知を送信する
*/
exports.subs = functions.https.onRequest((req, res) => {
// Subscriber登録時の処理
if(req.method=='GET'){
// 上記Subscriber登録フォームに入力する`verify_token`とサーバ側の設定の`verify_token`を一致させる
if(config.server.verify_token == req.query['hub.verify_token']){
// PubSubHubbub側から飛んでくる`hub.challenge`のパラメータをそのまま表示すれば完了
res.send(req.query['hub.challenge']);
}else{
res.status(404).end();
}
}else{
// 上記Subscriber登録フォームに入力する`HMAC secret`とサーバ側の設定の`hmac_secret`を一致させる
const hmac = crypto.createHmac('sha1', config.server.hmac_secret);
// 署名検証はPOSTされてきたREQUEST BODY(payload)に対して生成させる
hmac.update(req.body.toString());
// secretはX-HUB-SIGNATUREヘッダにsha1=XXXとう形式で入ってるので一致するか検証する
const hmacSignature = 'sha1='+hmac.digest('hex');
if(req.headers['x-hub-signature'] == hmacSignature){
db.collection('feed').orderBy('date','desc').limit(1).get()
.then(s=>s.docs.map(d=>{
const doc = d.data();
// 取得したコンテンツを/topics/feedに送信する
fcm.sendToTopic('feed',{
notification: {
title: doc.title,
body: doc.description,
icon: config.server.url+'assets/logos/polymer-jp-logo-192.png',
click_action: doc.link
}
});
}));
}
res.send('OK');
}
})
課題・問題点
- プッシュ通知(feedトピックス)の講読者数がわからず、無効になったtokenも追えない
→ tokenをDBに保存しておけばよいのだろうけど、これはちゃんとやるべきなのか自信ない - Publish後PubSubHubbub側からのPOSTにはfeedの内容が飛んでくるので、毎回DBから読み出す必要はない
→ それほど呼ばれる頻度はなく、アクセスが多くなることもないと思われるので、いったんこのままで
よい方法、一般的によく使われているアプローチなど教えてもらえると嬉しいです!
Polymer Advent Calendar 2017
7日目 lit-htmlをTODO Exampleを通して紹介する @hirodeath
9日目 Polymer コミュニティの紹介 @sizuhiko