Edit on GitHub

Build a Progressive Web App

“A Progressive Web App(PWA) uses modern web capabilities to deliver an app-like user experience.” – Progressive Web Apps

PWA are incredibly powerful and provide functionalities like Offline first, Push notifications, Background sync, GPU Rendering, 60fps scrolls and Add to home screen for a native app like experience.

PWA are purely based on Service Workers and their ability to work independently in the background without interfering your web app's life cycle.

Benefits of using Electrode PWA #

Low friction of distribution #

If your progressive web app is online, it’s already accessible for Chrome on Android (and other mobile). Your customers won't have to download an "app" from the app store. 65.5% of US smartphone users don’t download any new apps each month. PWAs eliminate the need to go to the app store, search for the app, click Install, wait for the download, then open the app. Each of these steps loses 20% of the potential users.

From a developer's point of view, you don't need to visit the play store to publish your app! Push new changes to your web app and the service worker will take care of updating the app shell.

Frictionless shopping experience #

For example:- for an e-commerce business, PWA offers our customers a more frictionless shopping experience that allows shoppers to search, buy, and checkout quickly without downloading the native-app.
Further it also allows you to bring app-like experiences to mobile websites, including personalization and targeted offers.

Faster mobile experience #

Web Apps built with Electrode + PWA will be significantly faster. Also, these websites enable an offline mode, that allows customers to continue browsing in poor wireless reception areas (for example, while they are on public transit). Given the fact that, faster websites have higher conversion rates, websites built with PWA could result in increased revenue.

Personalized push notifications #

Electrode Push notifications can be used to interact effectively with our mobile customers, as these notifications are native to the mobile device and can be personal and timely. Similar notification can be sent to desktop websites too.

Getting Started #

In the first section, we are going to make your previously build electrode app into a PWA with content caching, push notifications and an option for the user to save your web app to their home screen.

If you are starting out with a new PWA, just answer "Y" to the following question

$ yo electrode
# ... answer questions ...
# Would you like to make a Progressive Web App? (Y/n)
# ... answer rest of the questions and wait for app to be generated ...

follow Prerequisites and skip to Push Notifications

Prerequisites #

  1. We need certain API keys for push notifications. To generate these values, visit Firebase and create a new project.
    Click on the setting icons and open Project settings.
    Navigate to the CLOUD MESSAGING tab and note down your Server key and the Sender ID.

  2. Add the following icons inside the client/images directory
    logo 192x192 and logo 72x72.
    We will be using these logos for Add to Homescreen banner and Push Notifications.

Generating a Service Worker #

Generating a service worker in an electrode app is as simple as adding a config file. Navigate to <your-awesome-app>/config and create a new sw-config.js:

module.exports = {
  cache: {
    cacheId: "<your-awesome-app>",
    runtimeCaching: [{
      handler: "fastest",
      urlPattern: /\/$/
    }],
    staticFileGlobs: ['dist/**/*']
  },
  manifest: {
    title: "<your-awesome-app>",
    logo: "./images/logo-192x192.png",
    short_name: "EPA",
    background: "#FFFFFF",
    theme_color: "#FFFFFF"
  }
};

This will generate a sw.js in the dist folder when you build the app.

Registering the Service Worker #

We need to create a server plugin to access the dist/sw.js. Create the following file server/plugins/pwa/index.js

"use strict";
exports.register = function (server, options, next) {
  server.route({
    method: "GET",
    path: "/sw.js",
    handler: { file: "dist/sw.js" }
  });
  next();
};
exports.register.attributes = {
  name: "pwa",
  version: "0.0.1"
};

And add the following to plugins inside config/default.json

"pwa": { "module": "./server/plugins/pwa" }

To register the service worker on the browser, create a new file sw-registration.js inside the client directory:

module.exports = () => {
  // Exit early if the navigator isn't available
  if (typeof navigator === "undefined") {
    return;
  }
  // Feature check if service workers are supported.
  if ("serviceWorker" in navigator) {
    navigator.serviceWorker.register("sw.js", { scope: "./" })
      // Service worker registration was successful
      .then((registration) => {
        // The updatefound event is dispatched when the installing
        // worker changes. This new worker will potentially become
        // the active worker if the install process completes.
        registration.onupdatefound = function () {
          const installingWorker = registration.installing;
          // Listen for state changes on the installing worker so
          // we know when it has completed.
          installingWorker.onstatechange = function () {
            switch (installingWorker.state) {
            case "installing":
              console.log("Installing a new service worker...");
              break;
            case "installed":
              console.log(navigator.serviceWorker.controller);
              // We check the active controller which tells us if
              // new content is available, or the current service worker
              // is up to date (?)
              // TODO: Figure out why this is the case
              if (navigator.serviceWorker.controller) {
                console.log("New or updated content is available, refresh!");
              } else {
                console.log("Content is now available offline!");
              }
              break;
            case "activating":
              console.log("Activating a service worker...");
              break;
            case "activated":
              console.log("Successfully activated service worker.");
              break;
            case "redundant":
              console.log("Service worker has become redundant");
              break;
            }
          };
        };
      })
      // Service worker registration failed
      .catch((err) => {
        console.log("Service worker registration failed: ", err);
      });
  }
};

And import it in client/app.jsx

require.ensure(["./sw-registration"], (require) => {
  require("./sw-registration")();
}, "sw-registration");

We achieved couple of things here:

1 - Offline First with the cache property

Precache your static assets generated by webpack using the staticFileGlobs property. Or use the runtimeCaching property to cache specific react routes in from your routes.jsx

2 - Add to Home with the manifest property

After visiting your website, users will get a prompt (if the user has visited your site at least twice, with at least five minutes between visits.) to add your application to their homescreen. manifest gives you control over how your web app is installed on user's home screen with short_name, title and logo properties.

Build your app and start the server with

$ gulp pwa
Note: Service worker currently does not work with webpack dev server. You need to build first and then run the server. #

Navigate to http://localhost:3000, open Developer tools and click on the Application tab. You should see your Service Worker activated and running!

screenshot

Go ahead and click on the Offline checkbox in the Developer tools. Terminate your server. Refresh your web page.

NOTE: Add to Homescreen banner will pop up only on Android devices with Chrome 42+. To simulate the banner on your desktop Chrome, navigate to Developer tools -> Applications -> Manifest and click on Add to homescreen. #

Push notifications #

The Push API requires a registered service worker so it can send notifications in the background when the web application isn't running.
We already have our Service Worker generated with the help of sw-config.js. We only need to add a Push event to it.
Create a new file sw-events.js inside the client directory and add the following to it:

/* eslint-env serviceworker */

import icon from "./images/logo-192x192.png";
import badge from "./images/logo-72x72.png";

self.addEventListener("push", (event) => {
  const title = "It worked!";
  const options = {
    body: "Great job sending that push notification!",
    tag: "electrode-push-notification-test",
    icon,
    badge
  };
  event.waitUntil(
    self.registration.showNotification(title, options)
  );
});

Check out the Adding Push Notifications to a Web App Codelab provided by Google for an in-depth guide on how push notifications and service workers work together.

Now we need to add this file to our webpack bundle by referencing it in the cache property of sw-config.js:

module.exports = {
  cache: {
    importScripts: ['./sw-events.js']
  }
}

Now we have a registered service worker installed, activated and ready to accept push from the server.
But before we can push we need to request permissions from the user and subscribe them to the notifications.

Navigate to client/components/home.jsx and replace it with:

/* eslint-disable react/no-did-mount-set-state */
/* global navigator */

import React, {PropTypes} from "react";
import {connect} from "react-redux";
import {toggleCheck, incNumber, decNumber} from "../actions";

class Home extends React.Component {
  constructor() {
      super();
      this.state = {
        // Whether ServiceWorkers are supported
        supported: false,
        // Did something fail?
        error: null,
        // Waiting on the service worker to be ready
        loading: true,
        // Whether we"ve got a push notification subscription
        subscribed: false,
        // The actual subscription itself
        subscription: null,
        title: "",
        body: ""
      };
      this.sendNotification = this.sendNotification.bind(this);
      this.handleInputChange = this.handleInputChange.bind(this);
      this.subscribe = this.subscribe.bind(this);
    }
    componentDidMount() {
      if ("serviceWorker" in navigator) {
        navigator.serviceWorker.ready.then((registration) => {
          // Check for any existing subscriptions
          registration.pushManager.getSubscription().then((subscription) => {
            // No current subscription, let the user subscribe
            if (!subscription) {
              this.setState({
                loading: false,
                subscribed: false,
                supported: true
              });
            } else {
              this.setState({
                subscription,
                subscribed: true,
                loading: false,
                supported: true
              });
            }
          })
          .catch((error) => {
            this.setState({loading: false, error});
          });
        })
        .catch((error) => {
          this.setState({loading: false, error});
        });
      } else {
        // ServiceWorkers are not supported, let the user know.
        this.setState({loading: false, supported: false});
      }
    }
    subscribe() {
      navigator.serviceWorker.ready.then((registration) => {
        registration.pushManager.subscribe({ userVisibleOnly: true })
          .then((subscription) => {
            this.setState({subscription, subscribed: true});
          })
          .catch((error) => {
            this.setState({error});
          });
      });
    }
    handleInputChange(event) {
      this.setState({
        [event.target.name]: event.target.value
      });
    }
    sendNotification() {
      const {title, body} = this.state;
      // you can add badge, icon images in the options.
      const options = {body};
      navigator.serviceWorker.ready.then((registration) => {
        registration.showNotification(title, options);
      });
    }
    render() {
    const props = this.props;
    const {checked, value} = props;
    const {
      error,
      loading,
      supported,
      subscribed,
      subscription
    } = this.state;

    if (!loading && !supported) {
      return (
        <div>Sorry, service workers are not supported in this browser.</div>
      );
    }
    if (error) {
      return (
        <div>Woops! Looks like there was an error:
          <span style=>
            {error.name}: {error.message}
          </span>
        </div>
      );
    }
    if (loading) {
      return (<div>Checking push notification subscription status...</div>);
    }
    if (!subscribed) {
      return (
        <div>Click below to subscribe to push notifications
          <button onClick={this.subscribe}>Subscribe</button>
        </div>
      );
    }

    const API_KEY = "AIzaSyDAL_a1Hswn8QRRICDlh5PIIbEbFN7Aih0";
    const GCM_ENDPOINT = "https://android.googleapis.com/gcm/send";
    const endpointSections = subscription.endpoint.split("/");
    const subscriptionId = endpointSections[endpointSections.length - 1];
    const curlCommand = `curl --header "Authorization: key=${API_KEY}"
    --header Content-Type:"application/json" ${GCM_ENDPOINT} -d
    "{\\"registration_ids\\":[\\"${subscriptionId}\\"]}"`;

    return (
      <div>
        <h1>Hello <a href={"https://github.com/electrode-io"}>{"Electrode"}</a></h1>
        <div>
          <h2>Managing States with Redux</h2>
          <label>
            <input onChange={props.onChangeCheck} type={"checkbox"} checked={checked}/>
            Checkbox
          </label>
          <div>
            <button type={"button"} onClick={props.onDecrease}>-</button>
            &nbsp;{value}&nbsp;
            <button type={"button"} onClick={props.onIncrease}>+</button>
          </div>
        </div>
        <br/>
        <h2>Push Notifications with Service Workers</h2>
        Fill the form below and click on send for a push notification.
        <label htmlFor="title">Title</label>
        <input onChange={this.handleInputChange} name="title"/>
        <label htmlFor="body">Body</label>
        <input onChange={this.handleInputChange} name="body"/>
        <br/>
        <button onClick={this.sendNotification}>Send</button>
        <h3>Subscription Endpoint</h3>
        <code>{this.state.subscription.endpoint}</code>
        <h3>Curl Command</h3>
        <code>{curlCommand}</code>
      </div>
    );
  }
}

Home.propTypes = {
  checked: PropTypes.bool,
  value: PropTypes.number.isRequired
};

const mapStateToProps = (state) => {
  return {
    checked: state.checkBox.checked, value: state.number.value
  };
};

const mapDispatchToProps = (dispatch) => {
  return {
    onChangeCheck: () => {
      dispatch(toggleCheck());
    },
    onIncrease: () => {
      dispatch(incNumber());
    },
    onDecrease: () => {
      dispatch(decNumber());
    }
  };
};

export default connect(mapStateToProps, mapDispatchToProps)(Home);

Make sure you update the API_KEY with the one you previously generated in Prerequisites.

navigator.serviceWorker.ready is a Promise that will resolve once a service worker is registered, and it returns a reference to the active ServiceWorkerRegistration. The showNotification() method of the ServiceWorkerRegistration interface creates a notification and returns a Promise that resolves to a NotificationEvent.

We also need to update sw-config.js with the sender_id, so the final sw-config.js should look like:

module.exports = {
  cache: {
    cacheId: "electrode",
    runtimeCaching: [{
      handler: "fastest",
      urlPattern: "\/$"
    }],
    staticFileGlobs: ['dist/**/*'],
    importScripts: ['./sw-events.js']
  },
  manifest: {
    title: "Electrode Progressive App",
    short_name: "EPA",
    background: "#FFFFFF",
    theme_color: "#FFFFFF",
    gcm_sender_id: "YOUR SENDER ID"
  }
};

Rebuild your app and run the server with

$ gulp pwa

With all the code in place, we are ready to see push notifications in action.

Navigate to http://localhost:3000. Accept the permission for subscribing and you will see a curl command rendered on the page. You can either run the curl command from terminal to see the push notification or fill out the form and click on the Send button to trigger it!

For more Electrode PWA code examples checkout electrode-pwa-examples repo.