【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