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を試してみる](http://qiita.com/tomoyukilabs/items/9346eb44b5a48b294762)
> Chrome 52以降、Chrome for Android 52以降、Firefox 48以降

とのこと

OperaはWeb Push自体に対応したのが `v25` 以降なものの、記事執筆時点ではVAPIDには対応していない
[Opera Developer 25 supports web notifications](http://www.opera.com/blogs/desktop/2014/08/opera-developer-25-supports-web-notifications/)

## 実装

### ServiceWorker準備

`ServiceWorker` は自身があるディレクトリ以下の階層しか制御できないので注意
ルーティングすることができるので `assets` 以下に設置も可能（後述）

- manifest.json

```json
{
  "name": "WEB APP NAME",
  "short_name": "WEB APP",
  "start_url": "/"
}
```
最低限この3つ設定すれば作ればいけた
他に `icons` でアイコン画像を設定できたりするがここでは触れない

- serviceworker.js（任意のファイル名でOK）

```javascript
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に遷移させたい為、

```javascript
data: {
  target_url: json.target_url
}
function onSWNotificationClick(event) { ... }
```
と指定している
`body`, `icon`, `data` 以外に指定できるオプションは
[ServiceWorkerRegistration.showNotification()](https://developer.mozilla.org/ja/docs/Web/API/ServiceWorkerRegistration/showNotification)
等を参照すると良い


### Gemインストール

今回は

```ruby
# Gemfile
gem 'serviceworker-rails'
gem 'webpush'
```

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

### VAPID公開鍵、秘密鍵設定

コンソールで

```ruby
require 'webpush'
vapid_key = Webpush.generate_key
puts vapid_key.public_key # 公開鍵
puts vapid_key.private_key # 秘密鍵
```
を実行し、公開鍵と秘密鍵を取得

環境変数に

```bash
WEB_PUSH_VAPID_PUBLIC_KEY="公開鍵"
WEB_PUSH_VAPID_PRIVATE_KEY="秘密鍵"
```
といった感じで設定し、 `config/secrets.yml` に

```yaml
web_push:
  :vapid_public_key: <%= ENV['WEB_PUSH_VAPID_PUBLIC_KEY'] %>
  :vapid_private_key: <%= ENV['WEB_PUSH_VAPID_PRIVATE_KEY'] %>
```
と設定しておくのが良さそう

### ServiceWorkerのルーティング

前述の通り `ServiceWorker` の `scope` は自身のいるディレクトリ以下しか設定できないので、 `asset pipeline` を利用して `assets` に配置する場合、
`config/initializers/serviceworker.rb`で

```ruby
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設定

```html
<!-- 自身が設置した manifest.json のパスを記述する -->
<link rel="manifest" href="/manifest.json">
```
というように、各ページで `manifest.json` を参照するように設定する  
`app/views/layouts` 以下で設定するのが良いと思う

### プッシュ通知購読・解除

`app/assets/javascripts/任意のパス/serviceworker-companion.js.erb`
あたりに

```javascript
// 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
// javascript
if (Notification.permission == "denied") {
  console.log('ブロックされています');
}
```
とブロックされているか判定することができる

### プッシュ通知送信

rubyで

```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）で

```javascript
function onSWPush(event) {
  var json = event.data.json(); // payloadのmessage
```
と受け取るので、とりあえず使いたいデータがあれば `:message` に混ぜると良い

ちなみに `icon` で指定するアイコンサイズは、Google的には以前は `192x192 px` を推奨していて（[Add recommendations for icon size on Web Push](https://github.com/google/WebFundamentals/issues/1966)）
記事執筆時点では `192px以上` を推奨し、例文では `512x512 px` を使っている（[Displaying a Notification](https://github.com/google/WebFundamentals/blob/master/src/content/en/fundamentals/engage-and-retain/push-notifications/display-a-notification.md)）

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

## デバッグ

[ブラウザのプッシュ通知のデバッグ](http://qiita.com/tomoyukilabs/items/704c61dbfb2bdbb5d6a5)
を参考に
あと最近ではブラウザ付属のデベロッパーツールでもデバッグできるようになっている

ちなみにChromeの `デベロッパーツール>Application>ServiceWorker` には登録された `ServiceWorker` の動作確認用の機能があるのだが、`Push` を押すと
`function onSWPush(event) { ... }` の `event.data` が `PushMessageData` オブジェクトできて、中身がテキストデータで `event.data.json` でパースできずエラーになるのが辛い
Firefoxでは `about:debugging#workers` の `プッシュ` ボタンを押すと `event.data` が `undefined` でくるので三項演算子で制御できるのが助かる


## その他

上記の記述では `ServiceWorker` インストール直後は `active` にはならなかったので注意
ゆくゆくはインストール後すぐに `active` にしたい

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

```javascript
navigator.serviceWorker
```

が未定義になって困った
再起動したら直った

## 追記(2018/10/5)

```
#<Net::HTTPBadRequest 400 UnauthorizedRegistration readbody=true>
```
というエラーが出る場合があるとのことで、そのようなエラーが出た場合は下記記事を読むと解決するようだ
[Railsでwebpush(vapid)しようと思ったら、"Unauthorized"エラーが出た話](https://qiita.com/erb_owl/items/7edbc9c997a2601101d8)
@erb_owl++

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

```ruby
expiration: 12 * 60 * 60 # defaultは 24 * 60 * 60
```
と12時間程度にすると直るようだ
このオプションは、下記の通りgem内のJWTトークン発行にて使われている

```ruby
    # 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](https://developers.google.com/web/fundamentals/push-notifications/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](http://blog.nhiroki.jp/2015/07/05/service-worker-registration)

### Web Push

[Opera Developer 25 supports web notifications](http://www.opera.com/blogs/desktop/2014/08/opera-developer-25-supports-web-notifications/)
[Service Workerを利用したWeb Push通知環境の構築【導入編】](https://tech.drecom.co.jp/introduction-of-the-web-push-notification-system-using-the-service-worker/)
[GCMの登録が不要になったChromeのWeb Pushを試してみる](http://qiita.com/tomoyukilabs/items/9346eb44b5a48b294762)
[Sending Web Push Notifications from Rails](https://rossta.net/blog/web-push-notifications-from-rails.html)
[Using Web Push Notifcations with VAPID](https://rossta.net/blog/using-the-web-push-api-with-vapid.html)
[ServiceWorkerRegistration.showNotification()](https://developer.mozilla.org/ja/docs/Web/API/ServiceWorkerRegistration/showNotification)
[Displaying a Notification](https://github.com/google/WebFundamentals/blob/master/src/content/en/fundamentals/engage-and-retain/push-notifications/display-a-notification.md)
[Gem WebPush](https://github.com/zaru/webpush)
[Github WebFundamentals/src/content/en/fundamentals/engage-and-retain/push-notifications/](https://github.com/google/WebFundamentals/tree/master/src/content/en/fundamentals/engage-and-retain/push-notifications)
[Web Notifications W3C Recommendation 22 October 2015](https://www.w3.org/TR/notifications/)
[ブラウザのプッシュ通知のデバッグ](http://qiita.com/tomoyukilabs/items/704c61dbfb2bdbb5d6a5)