【Service Worker】Laravel + vue のSPAサイトをPWA化させてWebPushまで実装 - inokawablog

【Service Worker】Laravel + vue のSPAサイトをPWA化させてWebPushまで実装

 

簡単なサンプルと、NativeとPWAでできることの説明などはこちらが綺麗にまとまっています。

Androidは大抵の機能が使えるのですが、iOS は全然ダメです。肝心のWebPushが使えないです。

アイコンのバッジとWebPushがiOSで対応したら一気に広まるとは思うのですが、今の所は機能面ではNativeに軍配が上がります。

 

PWA化させるだけなら

  1. Localhost or HTTPS対応
  2. manifest.json
  3. Service Workerを登録

これだけで、できます。今回はそれに加えてWebPushを実装します。

 

Service Worker登録しホーム画面に追加する

public/manifest.json

{
  "short_name": "Sample",
  "name": "Sample",
  "display": "standalone",
  "icons": [
    {
      "src": "/images/icon-48x48.png",
      "sizes": "48x48",
      "type": "image/png"
    }, {
      "src": "/images/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    }, {
      "src": "/images/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    }, {
      "src": "/images/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    }, {
      "src": "/images/icon-168x168.png",
      "sizes": "168x168",
      "type": "image/png"
    }, {
      "src": "/images/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    }, {
      "src": "/images/icon-256x256.png",
      "sizes": "256x256",
      "type": "image/png"
    }, {
      "src": "/images/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "background_color": "#ffffff",
  "start_url": "http://localhost:8000/",
  "orientation": "portrait"
}

 

start_urlは本番環境に合わせて変えてください。

画像は書いてあるものは全て必要でない場合はエラーが出ます。

 

 

作成したmanifest.jsonをviewで読み込みます。

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

 

public/sw.js

'use strict'

self.addEventListener('install', function (e) {
  // console.log('ServiceWorker install')
})

self.addEventListener('activate', function (e) {
  // console.log('Serviceworker activated')
})

self.addEventListener('fetch', (e) => {
});

const WebPush = {
  init () {
    self.addEventListener('push', this.notificationPush.bind(this))
    self.addEventListener('notificationclick', this.notificationClick.bind(this))
    self.addEventListener('notificationclose', this.notificationClose.bind(this))
  },

  /**
   * handle notification push event!
   * @param {NotificationEvent} event
   */
  notificationPush(event) {
    if (!(self.Notification && self.Notification.permission === 'granted')) return
    
    const data = event.data.json()
    const options = {
      title: data.title,
      body: (data.body) ? data.body : '',
      icon: '/images/app_logo.png',
      vibrate: [300, 200, 300],
      badge: '/images/app_logo.png',
      data: data.data,
    }

    if (event.data) {
      event.waitUntil(
        this.showNotification(data.title, options)
      )
    }
  },

  /**
   * handle notification click event
   * @param {NotificationEvent} event
   */
  notificationClick(event) {
    // 通知を閉じる
    event.notification.close()
    // クリックした時のリンクを設定
    var clickLink = '/';
    // data.linkはcontroller側で設定しています。
    if (event.notification.data.link) {
      clickLink = event.notification.data.link
    }
    self.clients.openWindow(clickLink)
  },


  /**
   * handle notification close event
   * @param {NotificationEvent} event
   */
  notificationClose(event) {
    self.clients.openWindow('/')
  },

  /**
   * show notification on display
   * @param {PushMessageData|Object} data
   */
  showNotification(title, data) {
    return self.registration.showNotification(title, data)
  },
}

WebPush.init()

 

次に、sw.jsを登録します。登録させたい場所で登録してください。今回はapp.js

app.js

async created(){
      // サービスワーカーとして、public/sw.js を登録する
      if ('Notification' in window && 'serviceWorker' in navigator) {
        try {
          let swReg = await navigator.serviceWorker.register('/sw.js')
          // ログインしている場合のみ
          this.initialiseServiceWorker()
        } catch (e) {
          // console.error('Service Worker Error', e)
        }
      }
    },

 

initialiseServiceWorkerや他の処理をまとめたものをが以下になります。少し長いですが、コメントアウトしてあるので、順を追って見ていけば大丈夫です。

<script>
  export default {
    data: () => ({
      serviceWorkerRegistation: null,
    }),

    async created(){
      // サービスワーカーとして、public/sw.js を登録する
      if ('Notification' in window && 'serviceWorker' in navigator) {
        try {
          let swReg = await navigator.serviceWorker.register('/sw.js')
          // ログインしている場合のみ
          this.initialiseServiceWorker()
        } catch (e) {
          // console.error('Service Worker Error', e)
        }
      }
    },

    methods: {
    /**
     * サーバに自身の情報を送付し、プッシュ通知を送れるようにする
     */
      createSubscription() {
        if (this.serviceWorkerRegistation === null) {
          return navigator.serviceWorker.ready // returns a Promise, the active SW registration
          .then(swreg => {
            this.serviceWorkerRegistation = swreg
            return this.subscribe(this.serviceWorkerRegistation)
          })
        } else {
          return this.subscribe(this.serviceWorkerRegistation)
        }
      },

      /**
       * サービスワーカーを初期化する
       * 初期化では、プッシュ通知用の情報をサーバに送ることになる
       */
      async initialiseServiceWorker() {
        if (!('showNotification' in ServiceWorkerRegistration.prototype)) {
          return;
        }

        if (Notification.permission == 'denied') {
          // console.log('user block notification')
          return;
        }

        if (!('PushManager' in window)) {
          // console.log('push messaging not supported')
          return;
        }

        const sub = await this.findSubscription()
        if (sub == null) {
          // サブスクリプションを持っていない
          const sub = await this.createSubscription()
          // subscription登録
          const response = await axios.post('/subscription', { subscription: sub, userId: user_id, })
        } else {
          // すでにサブスクリプションを持っている場合の処理
        }
      },

      getSubscription(swreg) {
        // console.log('ask for available subscription');
        return swreg.pushManager.getSubscription()
      },


      subscribe(swreg) {
        // console.log('create new subscription for this browser on this device');
        // create new subscription for this browser on this device
        const vapidPublicKey = VUE_APP_VAPID_PUBLIC_KEY
        const convertedVapidPublicKey = this.urlBase64ToUint8Array(vapidPublicKey)
        // return the subscription promise, we chain another then where we can send it to the server
        return swreg.pushManager.subscribe({
          userVisibleOnly: true,
          // This is for security. On the backend, we need to do something with the VAPID_PRIVATE_KEY
          // that you can find in .env to make this work in the end
          applicationServerKey: convertedVapidPublicKey
        })
      },

      findSubscription() {
        // console.log('get active service worker registration');
        return navigator.serviceWorker.ready
        .then(swreg => {
          // console.log('haal active subscription op');
          this.serviceWorkerRegistation = swreg
          return this.getSubscription(swreg)
        })
      },

      urlBase64ToUint8Array(base64String) {
        const padding = '='.repeat((4 - base64String.length % 4) % 4);
        const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
        const rawData = window.atob(base64);
        let outputArray = new Uint8Array(rawData.length);
        for (let i = 0; i < rawData.length; ++i) {
          outputArray[i] = rawData.charCodeAt(i);
        }
        return outputArray;
      },
    }
  }
</script>

 

subscribe()のconst vapidPublicKey = VUE_APP_VAPID_PUBLIC_KEYはあとで生成する.enbのVAPID_PUBLIC_KEYを入れてください。

 

次にcontroller側の設定をします

use App\Notifications\WebPush\WebPush;

$user->notify(new WebPush($this->user));

 

必要なパッケージのインストール

laravel側の設定をしていきます

php gmpをインストール

sudo apt-get install php-gmp

https://stackoverflow.com/questions/40010197/how-to-install-gmp-for-php7-on-ubuntu

 

push通知のライブラリ

composer require laravel-notification-channels/webpush

 

プロバイダ追加

config\app.php

NotificationChannels\WebPush\WebPushServiceProvider::class,

 

通知のエンドポイントを格納するテーブルが作成されます。

php artisan vendor:publish --provider="NotificationChannels\WebPush\WebPushServiceProvider" --tag="migrations"
php artisan migrate

 

configファイル生成

php artisan vendor:publish --provider="NotificationChannels\WebPush\WebPushServiceProvider" --tag="config"

 

VAPID生成

.envに追加されます。

php artisan webpush:vapid

 

ユーザモデルがプッシュ通知を該当ユーザに送れるよう、trait を追加

app\User.php

/** 前略 */
use NotificationChannels\WebPush\HasPushSubscriptions;

class User extends Authenticatable
{
    use Notifiable, HasPushSubscriptions;
/** 後略 */

 

エンドポイントを保存するためのSubscriptionController.phpを作成し以下のように変更。

subscription破棄の処理も一応書いておきます。

app/Http/Controllers/SubscriptionController.php

<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use NotificationChannels\WebPush\PushSubscription;
use App\User;
class SubscriptionController extends Controller
{
    /**
     * Store (or update) a subscription for a user. All information is in the $request which has the following format:
     *     $request->user: an integer, the user ID
     *     $request->subscription: an array with keys 'endpoint' and 'keys'
     *         $request->subscription['endpoint']: (string) the endpoint for the browser our user is using
     *         $request->subscription['keys']: an array with two keys 'p256dh' and 'auth', used for encrypting the notifications
     *             $request->subscription['keys']['p256dh']: 2nd argument in updatePushSubscription() below
     *             $request->subscription['keys']['auth']: 3nd argument in updatePushSubscription() below
     *
     * From the Webpush package docs: "The $key and $token are optional and are used to encrypt your notifications. Only encrypted notifications can have a payload."
     *
     * @param  Request $request [description]
     * @return [type]           [description]
     */
    public function store(Request $request)
    {
        // get user from request
        $user = User::findOrFail($request->userId);
        // create PushSubscription and connect to this user
        $pushsub = $user->updatePushSubscription(
          $request->subscription['endpoint'],
          $request->subscription['keys']['p256dh'],
          $request->subscription['keys']['auth']
        );
        return $pushsub;
    }
    
    /**
     * Based on the endpoint which needs to be available in the
     * $request, find the correct subscription and delete it from the DB
     * @param  Request $request [description]
     * @return [type]           [description]
     */
    public function destroy(Request $request)
    {
        $this->validate($request, ['endpoint' => 'required']);
        // PushSubscription::findByEndpoint is a static function on the PushSubscription model from the package.
        // This will return the PushSubscription model instance corresponding to the given endpoint.
        // We than retrieve the user instance from the belongsTo relation also defined in the PushSubscription model
        // Note that we did add the use statement with correct namespace on top of this file.
        $user = PushSubscription::findByEndpoint($request->endpoint)->user;
        $user->deletePushSubscription($request->endpoint);
        return response()->json(null, 204);
    }
}

 

routes\web.php

// create or update a subscription for a user
Route::post('subscription', 'SubscriptionController@store');
// delete a subscription for a user
Route::post('subscription/delete', 'SubscriptionController@destroy');

 

WebPushの通知の部分です。

app\Notifications\WebPush.php

<?php

namespace App\Notifications\WebPush;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;

use NotificationChannels\WebPush\WebPushMessage;
use NotificationChannels\WebPush\WebPushChannel;

class WebPush extends Notification
{
    use Queueable;

    public $user;

    public function __construct($user)
    {
      $this->user = $user;
    }

    /**
     * Get the notification's delivery channels.
     *
     * @param  mixed  $notifiable
     * @return array
     */
    public function via($notifiable)
    {
      return [WebPushChannel::class];
    }

    /**
     * プッシュ通知をする
     *
     * @param [type] $notifiable
     * @param [type] $notification
     * @return void
     */
    public function toWebPush($notifiable, $notification)
    {
      $data = [];
      // クリックした時のurl
      // いいね、コメント、フォローなどでそれぞれのページへ飛ばしたいときはリンクを分けなければいけないので、リンクをdataに格納
      $data['link'] = $url;
      return (new WebPushMessage)
          ->title($this->user['name'])
          ->icon('/logo.ico')
          ->data($data);
    }

    /**
     * Get the array representation of the notification.
     *
     * @param  mixed  $notifiable
     * @return array
     */
    public function toArray($notifiable)
    {
        return [
            //
        ];
    }
}

 

まとめ

長いですが、そんなに難しくはありません、面倒なだけです。

 

参考

https://kawax.biz/laravel-webpush-2019/

https://qiita.com/niisan-tokyo/items/1ecfd31f17ae79283789