Edited at

Rails4におけるブラウザプッシュ通知(Web Push Notifications)実装


概要

Rails 4上において、一部ブラウザに対するプッシュ通知(Web Push)を実装する

VAPID(Voluntary Application Server Identification)を利用することで事前にプッシュサーバへの事前登録は必要ない

記事執筆時点(2017年04月28日)では


  • Google Chrome

  • Google Chrome for Android

  • Firefox

  • Opera

がWeb Pushに対応している

OperaはVAPIDに対応してない為、本記事ではChromeおよびFirefoxのみ扱うこととする

(本記事のコードをOpera(44.0 OS X)で動かした所、Operaインストール直後は正常にServiceWorker登録および購読登録でき、通知も送られたものの、一度Operaを終了し、再度起動した所、Registration failed - push service errorとなり動かなくなった)


環境


  • Macbook Pro 2015 OS X El Capitan (10.11.6)

  • Ruby 2.2.1

  • Rails 4.2

  • gem serviceworker-rails 0.5.4

  • gem webpush 0.3.1

  • Google Chrome 57.0.2987 (OS X)

  • Google Chrome for Android 57

  • Firefox 52.0.2 (OS X)

https もしくは localhost でしか動かないので注意

VAPID鍵を使った認証を対応しているのは

GCMの登録が不要になったChromeのWeb Pushを試してみる


Chrome 52以降、Chrome for Android 52以降、Firefox 48以降


とのこと

OperaはWeb Push自体に対応したのが v25 以降なものの、記事執筆時点ではVAPIDには対応していない

Opera Developer 25 supports web notifications


実装


ServiceWorker準備

ServiceWorker は自身があるディレクトリ以下の階層しか制御できないので注意

ルーティングすることができるので assets 以下に設置も可能(後述)


  • manifest.json

{

"name": "WEB APP NAME",
"short_name": "WEB APP",
"start_url": "/"
}

最低限この3つ設定すれば作ればいけた

他に icons でアイコン画像を設定できたりするがここでは触れない


  • serviceworker.js(任意のファイル名でOK)

var CACHE_VERSION = 'v1';

var CACHE_NAME = CACHE_VERSION + ':sw-cache-';

function onSWInstall(event) {
return event.waitUntil(
caches.open(CACHE_NAME).then(function prefill(cache) {
return cache.addAll([]);
})
);
}

function onSWActivate(event) {
return event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.filter(function(cacheName) {
// Return true if you want to remove this cache,
// but remember that caches are shared across
// the whole origin
return cacheName.indexOf(CACHE_VERSION) !== 0;
}).map(function(cacheName) {
return caches.delete(cacheName);
})
);
})
);
}

function onSWPush(event) {
// event.data are null when Firefox's debugger push
// event.data are PushMessageData object and cannot exec event.data.json, event.data.text => 'Test push message from DevTools.'
var json = event.data ? event.data.json() : {"title" : "DEBUG TITLE", "body" : "DEBUG BODY"};
return event.waitUntil(
self.registration.showNotification(json.title, {
body: json.body,
icon: json.icon,
data: {
target_url: json.target_url
}
})
);
}

function onSWNotificationClick(event) {
event.notification.close();
return event.waitUntil(
clients.openWindow(event.notification.data != null ? event.notification.data.target_url : '/')
);
}

self.addEventListener('install', onSWInstall);
self.addEventListener('activate', onSWActivate);
self.addEventListener("push", onSWPush);
self.addEventListener("notificationclick", onSWNotificationClick);

今回はオフラインコンテンツを使用しないので return cache.addAll([]); と空にしている

ServiceWorker で通知を受け取った際、 data で任意の値を送ることができる

今回はクリック時に指定のURLに遷移させたい為、

data: {

target_url: json.target_url
}
function onSWNotificationClick(event) { ... }

と指定している

body, icon, data 以外に指定できるオプションは

ServiceWorkerRegistration.showNotification()

等を参照すると良い


Gemインストール

今回は

# Gemfile

gem 'serviceworker-rails'
gem 'webpush'

を選択


webpush はVAPIDの公開鍵、秘密鍵の生成、プッシュサーバ認証用ヘッダを付与して通知送信などをしてくれてとても便利


VAPID公開鍵、秘密鍵設定

コンソールで

require 'webpush'

vapid_key = Webpush.generate_key
puts vapid_key.public_key # 公開鍵
puts vapid_key.private_key # 秘密鍵

を実行し、公開鍵と秘密鍵を取得

環境変数に

WEB_PUSH_VAPID_PUBLIC_KEY="公開鍵"

WEB_PUSH_VAPID_PRIVATE_KEY="秘密鍵"

といった感じで設定し、 config/secrets.yml

web_push:

:vapid_public_key: <%= ENV['WEB_PUSH_VAPID_PUBLIC_KEY'] %>
:vapid_private_key: <%= ENV['WEB_PUSH_VAPID_PRIVATE_KEY'] %>

と設定しておくのが良さそう


ServiceWorkerのルーティング

前述の通り ServiceWorkerscope は自身のいるディレクトリ以下しか設定できないので、 asset pipeline を利用して assets に配置する場合、

config/initializers/serviceworker.rb

Rails.application.configure do

config.serviceworker.routes.draw do
match "/serviceworker-user.js" => "user/serviceworker/serviceworker.js"
end
end

と上記のように記述をすることで任意のパスに設定することができる

config/initializers/assets.rb での precompile 設定も忘れずに


VIEW側にmanifest.json設定

<!-- 自身が設置した manifest.json のパスを記述する -->

<link rel="manifest" href="/manifest.json">

というように、各ページで manifest.json を参照するように設定する


app/views/layouts 以下で設定するのが良いと思う


プッシュ通知購読・解除

app/assets/javascripts/任意のパス/serviceworker-companion.js.erb

あたりに

// WebPushが使えるか

var isEnableWebPushBrowser = function() {
// SWが使えないブラウザ
if (!navigator.serviceWorker) {
return false;
}

var ua = navigator.userAgent.toLowerCase();
// UAにChromeが含まれている為、明示的にEdgeとOpera(Webkit)を弾く
if (ua.indexOf("edge") >= 0 || ua.indexOf("opr") >= 0) {
return false;
}
// Chrome 52 以降OK
if (ua.match(/chrom(e|ium)/)) {
var raw = ua.match(/chrom(e|ium)\/([0-9]+)\./);
if (raw && parseInt(raw[2], 10) >= 52) {
return true;
}
}
// Firefox 48 以降OK
if (ua.indexOf("firefox") >= 0) {
var raw = ua.match(/firefox\/([0-9]+)\./);
if (raw && parseInt(raw[1], 10) >= 48) {
return true;
}
}
return false;
};

// WebPushが使えるブラウザの場合
// NOTE: 今回はWebPushだけなので。他でSW使いたかったら要変更
if (isEnableWebPushBrowser()) {
var convertWebPushSubscriptionJson = function(subscription) {
var jsonData = {};
if (subscription) {
jsonData = {
endpoint: subscription.endpoint,
key: btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('p256dh')))).replace(/\+/g, '-').replace(/\//g, '_'),
auth: btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('auth')))).replace(/\+/g, '-').replace(/\//g, '_')
};
}
return jsonData;
};

// ブラウザPush通知を有効にする
var webPushSubscribe = function() {
return navigator.serviceWorker.getRegistration().then(function(registration) {
// ブラウザPush通知用VAPID
var vapidPublicKey = new Uint8Array(<%= Base64.urlsafe_decode64(Rails.application.secrets.web_push[:vapid_public_key]).bytes %>);
return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidPublicKey
}).then(function(subscription) {
// API叩いてDBに登録するなどの処理
console.log(convertWebPushSubscriptionJson(subscription));
return;
})["catch"](function(err) {
return console.log(err);
});
});
};

// ブラウザPush通知を無効にする
var webPushUnSubscribe = function() {
return navigator.serviceWorker.getRegistration().then(function(registration) {
return registration.pushManager.getSubscription().then(function(subscription) {
if (!subscription) {
return;
}
return subscription.unsubscribe().then(function(success) {
// API叩いて無効にするなどの処理
return;
})["catch"](function(error) {
return console.log(error);
});
});
});
};

// SWインストール
// NOTE: SWインストール済みかどうかはブラウザ側で判断される
navigator.serviceWorker.register('/serviceworker-user.js', { scope: '/' });
}

と書いて、あとは任意のタイミングでそれぞれ叩くと良い


(ブラウザ判定とか結構適当…)

ちなみにブラウザ側で通知をブロックすることができるのだが、

// javascript

if (Notification.permission == "denied") {
console.log('ブロックされています');
}

とブロックされているか判定することができる


プッシュ通知送信

rubyで

require 'webpush'

payload = {
endpoint: @endpoint, # ブラウザでregistration.pushManager.getSubscription()で取得したsubscriptionのendpoint
p256dh: @p256dh, # 同じくsubscriptionのp256dh
auth: @auth, # 同じくsubscriptionのauth
ttl: @ttl, # 任意の値
vapid: {
subject: 'mailto:info@example.org', # APPサーバのコンタクト用URIとか('mailto:' もしくは 'https://')
public_key: Rails.application.secrets.web_push[:vapid_public_key],
private_key: Rails.application.secrets.web_push[:vapid_private_key]
},
message: {
icon: 'https://example.com/images/demos/icon-512x512.png',
title: title,
body: body,
target_url: target_url # 任意のキー、値
}.to_json
}
# NOTE: payload_sendの例外
# RuntimeError の場合 Webpush::Error
# Error の場合 Webpush::ConfigurationError
# Net::HTTPGone 410 の場合 Webpush::InvalidSubscription
# Net::HTTPSuccess 2XX 以外の場合 Webpush::ResponseError
Webpush.payload_send(payload)

で送れる

受取側(ServiceWorker)で

function onSWPush(event) {

var json = event.data.json(); // payloadのmessage

と受け取るので、とりあえず使いたいデータがあれば :message に混ぜると良い

ちなみに icon で指定するアイコンサイズは、Google的には以前は 192x192 px を推奨していて(Add recommendations for icon size on Web Push

記事執筆時点では 192px以上 を推奨し、例文では 512x512 px を使っている(Displaying a Notification

上記ドキュメントや WebFundamentals/src/content/en/fundamentals/engage-and-retain/push-notifications/ 以下のページでは他のオプションや挙動など解説されているので興味があればぜひ


デバッグ

ブラウザのプッシュ通知のデバッグ

を参考に

あと最近ではブラウザ付属のデベロッパーツールでもデバッグできるようになっている

ちなみにChromeの デベロッパーツール>Application>ServiceWorker には登録された ServiceWorker の動作確認用の機能があるのだが、Push を押すと

function onSWPush(event) { ... }event.dataPushMessageData オブジェクトできて、中身がテキストデータで event.data.json でパースできずエラーになるのが辛い

Firefoxでは about:debugging#workersプッシュ ボタンを押すと event.dataundefined でくるので三項演算子で制御できるのが助かる


その他

上記の記述では ServiceWorker インストール直後は active にはならなかったので注意

ゆくゆくはインストール後すぐに active にしたい

Firefoxでデバッグ中、アップデートで再起動が必要となった際に、何故か

navigator.serviceWorker

が未定義になって困った

再起動したら直った


追記(2018/10/5)

#<Net::HTTPBadRequest 400 UnauthorizedRegistration readbody=true>

というエラーが出る場合があるとのことで、そのようなエラーが出た場合は下記記事を読むと解決するようだ

Railsでwebpush(vapid)しようと思ったら、"Unauthorized"エラーが出た話

@erb_owl++

このエラーはvapidのオプションで

expiration: 12 * 60 * 60 # defaultは 24 * 60 * 60

と12時間程度にすると直るようだ

このオプションは、下記の通りgem内のJWTトークン発行にて使われている

    # lib/webpush/request.rb

def jwt_payload
{
aud: audience,
exp: Time.now.to_i + expiration,
sub: subject,
}
end

def expiration
@vapid_options.fetch(:expiration, 24*60*60)
end

Googleが公開しているWeb Fundamentals内の Web Push: Common Issues and Reporting Bugs によると


You'll receive an UnauthorizedRegistration error in any of the following situations:

The expiration is invalid in your JWT, i.e. the expiration exceeds 24 hours or the JWT has expired.


と記載があるので24時間以内を設定すればOKなのだが、時計がずれてるとか何らかの理由で24時間以上後の時刻が設定されてしまっているのだろう

エラーが出るのなら24時間以内に収まるように設定する必要があるので注意


参照


Service Worker

Service Worker の Registration


Web Push

Opera Developer 25 supports web notifications

Service Workerを利用したWeb Push通知環境の構築【導入編】

GCMの登録が不要になったChromeのWeb Pushを試してみる

Sending Web Push Notifications from Rails

Using Web Push Notifcations with VAPID

ServiceWorkerRegistration.showNotification()

Displaying a Notification

Gem WebPush

Github WebFundamentals/src/content/en/fundamentals/engage-and-retain/push-notifications/

Web Notifications W3C Recommendation 22 October 2015

ブラウザのプッシュ通知のデバッグ