loading...

How to convert a React-Rails Web App to PWA, Push Notification using VAPID

bravemaster619 profile image bravemaster619 Originally published at Medium on ・9 min read

How to convert a React-RoR Web App to a PWA + Push Notification using VAPID

In my previous article, I described how to build a React web app with Ruby on Rails for backend and MongoDB for database.

In this page, I am going to explain how to convert this web app into a Progressive Web App. And I’ll show you how to implement push notification in our app using VAPID.

If you haven’t read my previous article yet, please read it first. Or you can just download the source code of the previous article here and start reading this.

What have we got?

When a user fills up subscription form and click “Subscribe” button, Axios will send a POST request to Rails API. Rails app will store the user information to database.

After registration is done, React app will send a GET request to Rails Api. Rails Api will return JSON array of stored users in MongoDB. And React will render a user table that looks like the following picture:

What are we going to do?

It’s like this:

When a registered user enters a message and clicks “Send” button, a push notification will show in other user’s machine.

How will it work?

When a user enters name and email, our app will request the user’s permission for push notifications. If the user allows it, a service worker will get a subscription object which contains endpoint and keys. Rails Api will receive user information and subscription object from Axios and store them in the database.

React+RoR+MongoDB+PWA+Web Push Notification, Step 1

Later, when the other registered user sends a message to another user, our Rails app will load target user’s subscription from database and send a POST request encrypted with Rails server’s keys to the endpoint of target user’s subscription . The endpoint will receive the request and send a push notification to target users’ machine.

React+RoR+MongoDB+PWA+Web Push Notification, Step 2

In Backend

1. Add a dependency for web push

Google recommends using libraries when sending push notifications from a server:

…web push libraries really shine, as the process of signing and sending a message can be quite complex.

They prepared a library list of push notifications for several languages. Unfortunately, there is no ruby library in there.

For that matter, webpush is a real lifesaver for ruby developers:

# webpush for rails
gem 'webpush'

2. Change user model

Since we need to save subscription information received from service worker to database, we have to change user model.

Change models/user.rb to this:

class User
include Mongoid::Document
  field :name, type: String
  field :email, type: String
  field :subscription, type: Hash
end

Change user_params in users_controller like this:

def user_params
  params.require(:user).permit(:name, :email, subscription: [:endpoint, :expirationTime, keys: [:p256dh, :auth]])
end

3. Generate VAPID keys

Application servers SHOULD generate and maintain a signing key pair usable with elliptic curve digital signature (ECDSA) over the P-256 curve.

Cut and past the following lines to config/application.rb:

require 'webpush' # This line goes to the head of the file

# One-time, on the server  
vapid_key = Webpush.generate_key

# Save these in your application server settings
puts "****** VAPID_PUBLIC_KEY *******"
puts vapid_key.public_key
puts "****** VAPID_PRIVATE_KEY *******"
puts vapid_key_.private_key

Important : you need to require webpush in the head of the file.

Run the following commands in shell:

$ bundle install
$ rails server

The console will output VAPID public key and private key:

****** VAPID_PUBLIC_KEY *******
BL1IfYkFEXmhlVi5VrLIw0Tv_?????????????????????????????????????????ktz7miXzPjeSlWO33RyuuIRg=
****** VAPID_PRIVATE_KEY *******
vyAcYUZMsJRI8GkZnXh6???????????????????y210=

Create a file webpush.yml in config directory and save keys there:

SUBJECT: mailto:johndoe@example.com
VAPID_PUBLIC_KEY: BL1IfYkFEXmhlVi5VrLIw0Tv_?????????????????????
????????????????????ktz7miXzPjeSlWO33RyuuIRg=
VAPID_PRIVATE_KEY: vyAcYUZMsJRI8GkZnXh6???????????????????y210=

Go back to config/application.rb and comment out the code snippet for key generation. Then add following lines to it:

config.before_configuration do
   env_file = File.join(Rails.root, 'config', 'webpush.yml')
      YAML.load(File.open(env_file)).each do |key, value|
      ENV[key.to_s] = value
   end if File.exists?(env_file)
end

4. Make a route and implement a method for push notification

Add the following line to config/routes.rb:

post 'sendMessage', to: 'users#send_message'

Add the following lines to app/controllers/users_controller.rb:

def send_message
  @message = params[:message]
  @user = User.find(params[:user_id])
  subscription = @user[:subscription]
  Webpush.payload_send(
      endpoint: subscription[:endpoint],
      message: @message,
      p256dh: subscription[:keys][:p256dh],
      auth: subscription[:keys][:auth],
      vapid: {
          subject: ENV['SUBJECT'],
          public_key: ENV['VAPID_PUBLIC_KEY'],
          private_key: ENV['VAPID_PRIVATE_KEY'],
          expiration: 12 * 60 * 60
      }
  )
  render json: { success: true }
end

Important : you need to require webpush in the head of the file.

In Frontend

1. Customize default service worker of react-create-app

create-react-app already has a service worker for PWA. Unfortunately, there is nothing for push notification in serviceWorker.js. We are going to customize default service worker with workbox-build and use that in our React app.

$ npm i workbox-bulid

In src directory, create a file sw-build.js then cut and past the following lines:

const workboxBuild = require('workbox-build');
// NOTE: This should be run *AFTER* all your assets are built
const buildSW = () => {
    // This will return a Promise
    return workboxBuild.injectManifest({
        swSrc: 'src/sw-template.js', // this is your sw template file
        swDest: 'build/service-worker.js', // this will be created in the build step
        globDirectory: 'build',
        globPatterns: [
            '**\/*.{js,css,html,png}',
        ]
    }).then(({count, size, warnings}) => {
        // Optionally, log any warnings and details.
        warnings.forEach(console.warn);
        console.log(`${count} files will be precached, totaling ${size} bytes.`);
    });
}

buildSW();

Note : sw-build.js will auto generate a service worker as ordered in sw-template.js and dump the generated code to build/service-worker.js.

Then create a file sw-template.js:

if ('function' === typeof importScripts) {
    importScripts(
        'https://storage.googleapis.com/workbox-cdn/releases/3.5.0/workbox-sw.js'
    );
    /* global workbox */
    if (workbox) {
        console.log('Workbox is loaded');

        /* injection point for manifest files.  */
        workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);

        /* custom cache rules*/
        workbox.routing.registerNavigationRoute('/index.html', {
            blacklist: [/^\/_/, /\/[^\/]+\.[^\/]+$/],
        });

        workbox.routing.registerRoute(
            /\.(?:png|gif|jpg|jpeg)$/,
            workbox.strategies.cacheFirst({
                cacheName: 'images',
                plugins: [
                    new workbox.expiration.Plugin({
                        maxEntries: 60,
                        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days
                    }),
                ],
            })
        );

    } else {
        console.log('Workbox could not be loaded. No Offline support');
    }
}

self.addEventListener('notificationclose', function(e) {
    var notification = e.notification;
    var primaryKey = notification.data.primaryKey;

    console.log('Closed notification: ' + primaryKey);
});

self.addEventListener('notificationclick', function(e) {
    var notification = e.notification;
    var primaryKey = notification.data.primaryKey;
    var action = e.action;

    if (action === 'close') {
        notification.close();
    } else {
        clients.openWindow('https://github.com/bravemaster619');
        notification.close();
    }
});

self.addEventListener('push', function(e) {
    const title = (e.data && e.data.text()) || "Yay a message"
    var options = {
        body: 'This notification was generated from a push!',
        icon: 'images/example.png',
        vibrate: [100, 50, 100],
        data: {
            dateOfArrival: Date.now(),
            primaryKey: '2'
        },
        actions: [
            {action: 'explore', title: 'Learn more',
                icon: 'images/checkmark.png'},
            {action: 'close', title: 'Close',
                icon: 'images/xmark.png'},
        ]
    };
    e.waitUntil(
        self.registration.showNotification(title, options)
    );
});

Note: Here in sw-template.js, we added event listeners for web push notification events.

And then modify scripts configuration in package.json as the following:

"scripts": {
  "start": "react-scripts start",
  "build": "react-scripts build && npm run build-sw",
  "test": "react-scripts test",
  "eject": "react-scripts eject",
  "build-sw": "node ./src/sw-build.js"
},

2. Change UserTable component

Modify src/components/UserTable.jsx as the following:

import React from "react"
import { alertService } from '../services/alert'
import Axios from "axios"
import { API_HOST } from "../config"
class UserTable extends React.Component {

    constructor(props) {
        super(props)
        this.state={
            loading: true,
            users: []
        }
        this.changeMessage = this.changeMessage.bind(this)
        this.sendMessage = this.sendMessage.bind(this)
    }

    changeMessage(e, index) {
        const users = {...this.state.users}
        users[index].message = e.target.value
        this.setState(users)
    }

    sendMessage(e, index) {
        const users = {...this.state.users}
        const message = users[index].message
        if(!message) {
            alertService.showError("Please input message!")
            return
        }
        Axios.post(`${API_HOST}/sendMessage`, { message, user_id: users[index]['_id']['$oid'] }).then(res => {
            console.log(res.data.success)
            if(res.data.success) {
                alertService.showSuccess("Message sent!")
            } else {
                alertService.showError("Message did not send!")
            }
        }).catch(e => {
            console.error(e)
            alertService.showError("Message did not send!")
        })
    }

    componentDidMount() {
        Axios.get(`${API_HOST}/users`).then(res => {
            this.setState({
                users: res.data
            })
        }).catch(e => {
            alertService.showError('Cannot get user data...')
        }).finally(() => {
            this.setState({
                loading: false
            })
        })
    }

    render() {
        return (
            <div className="row mt-5 justify-content-center">
                <div className="col-12 col-lg-8">
                    <table className="table table-hover table-striped">
                        <thead>
                            <tr>
                                <th>Name</th>
                                <th>Email</th>
                                <th>Message</th>
                                <th/>
                            </tr>
                        </thead>
                        <tbody>
                        {this.state.loading ? (
                            <tr><td>Loading...</td></tr>
                        ) : (
                            <>
                                {this.state.users.map((user, index) => {
                                    return (
                                        <tr key={index}>
                                            <td>{user.name}</td>
                                            <td>{user.email}</td>
                                            <td>
                                                <input
                                                    type="text"
                                                    className="form-control"
                                                    onChange={(e) => this.changeMessage(e, index)}
                                                />
                                            </td>
                                            <td>
                                                <button
                                                    type="button"
                                                    className="btn btn-primary"
                                                    onClick={(e) => this.sendMessage(e, index)}
                                                >
                                                    Send
                                                </button>
                                            </td>
                                        </tr>
                                    )
                                })}
                                {!this.state.users.length && (
                                    <tr><td>Loading...</td></tr>
                                )}
                            </>
                        )}
                        </tbody>
                    </table>
                </div>
            </div>
        )
    }

}

export default UserTable

3. Change Root Component

Modify src/components/Root.jsx as the following:

import React from "react"
import Axios from "axios"
import { alertService } from '../services/alert'
import SubscribeForm from "./SubscribeForm"
import UserTable from "./UserTable"
import { API_HOST, VAPID_PUBLIC_KEY } from "../config"

class Root extends React.Component {

    constructor(props) {
        super(props)
        this.state = {
            name: '',
            email: '',
            sendingRequest: false,
            subscription: null,
        }
        this.changeName = this.changeName.bind(this)
        this.changeEmail = this.changeEmail.bind(this)
        this.subscribe = this.subscribe.bind(this)
    }

    changeName(e) {
        let name = e.target.value
        this.setState({name})
    }

    changeEmail(e) {
        let email = e.target.value
        this.setState({email})
    }

     urlBase64ToUint8Array(base64String) {
        const padding = '='.repeat((4 - base64String.length % 4) % 4);
        const base64 = (base64String + padding)
            .replace(/-/g, '+')
            .replace(/_/g, '/');

        const rawData = window.atob(base64);
        const outputArray = new Uint8Array(rawData.length);

        for (let i = 0; i < rawData.length; ++i) {
            outputArray[i] = rawData.charCodeAt(i);
        }
        return outputArray;
    }

    subscribe() {
        if (!this.state.name) {
            return alertService.showError('Please input name!')
        }
        if (!this.state.email) {
            return alertService.showError('Please input email!')
        }
        if (!window.Notification) {
            return alertService.showError("You cannot use notification service")
        }
        if (!('serviceWorker' in navigator)) {
            return alertService.showError('Service worker not registered')
        }
        window.Notification.requestPermission().then(res => {
            if (res === "granted") {
                let context = this
                window.navigator.serviceWorker.ready.then(function (reg) {
                    reg.pushManager.subscribe({
                        userVisibleOnly: true,
                        applicationServerKey: context.urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
                    }).then(sub => {
                        Axios.post(`${API_HOST}/users`, {
                            name: context.state.name,
                            email: context.state.email,
                            subscription: sub
                        }).then(res => {
                            if (res.data && res.data._id) {
                                context.setState({
                                    subscription: sub
                                })
                            } else {
                                alertService.showError('Subscribing failed!')
                            }
                        })
                    })
                })
            } else {
                alertService.showError("You blocked notification.")
            }
        })
    }

    render() {
        return (
            <div className="container">
                {this.state.subscription ? (
                    <UserTable
                        subscription={this.state.subscription}
                    />
                ) : (
                    <SubscribeForm
                        name={this.state.name}
                        email={this.state.email}
                        changeName={this.changeName}
                        changeEmail={this.changeEmail}
                        subscribe={this.subscribe}
                        sendingRequest={this.state.sendingRequest}
                    />
                )}
            </div>
        )
    }

}

export default Root

4. Add VAPID public key to React app

Modify src/config.js as the following:

export const API_HOST = 'http://localhost:3000'
export const VAPID_PUBLIC_KEY= 'BL1IfYkFEXmhlVi5VrLIw0Tv_??????
???????????????????????????????????ktz7miXzPjeSlWO33RyuuIRg='

VAPID_PUBLIC_KEY was generated earlier by webpush gem.

5. Enable service worker in React app

Go to src/index.js and change the following line:

serviceWorker.unregister();

to this one:

serviceWorker.register();

6. Deploy React App to server

Since service worker is running on only production environment, it is a good idea to build our React app and host it to a server.

Note : The built project must be located directly under the WebRoot directory, i.e. http://localhost is OK but http://localhost/rails-react-app won’t work.

If you enter name and email and press “Subscribe” button the browser will ask your permission. Allow it and start sending messages!

If you’re seeing this, well done! You implemented Push Notification in your web app! Notice that the deployed web app is PWA, too.

Note: PWA audit might not be fully passed in http. If you’re using https and fail on http to https redirect test, add the following to .htaccess file:

RewriteEngine On
RewriteCond %{SERVER\_PORT} !^443$
RewriteRule .\* https://%{SERVER\_NAME}%{REQUEST\_URI} [R,L]

Useful Links

  • You can get the working source code here.
  • You can read my previous article of building a React web app with Rails Api.

Discussion

pic
Editor guide