How to Build Progressive Web Apps with Lightning Web Components [Part 2]

Written by MichaelB | Published 2020/12/20
Tech Story Tags: pwa | progressive-web-apps | web-development | software-architecture | heroku | lightning-web-components | push-notification | webdev | web-monetization

TLDRvia the TL;DR App

Earlier this year, a post came out on the Salesforce Developers Blog, entitled “How to Build Progressive Web Apps with Offline Support using Lightning Web Components.” During the post's discussion about using Lightning Web Components (LWC) to build progressive web apps, it mentioned push notifications. My interest was piqued. How simple would it be to use LWC to build an app for push notifications? It turns out — really simple.
In this first part of this series, we covered the basics of Progressive Web Apps and the LWC framework, then we created the basic foundations of our app and deployed it to Heroku.
Let's pick it up from there.

Build Our Subscriptions and Notifications Server

Next, we’re going to build a quick-and-dirty Express server with three endpoints: one for subscribe, one for unsubscribe, and one for getting the server’s public VAPID key (more on that below). The subscribe request will expect specific subscription data (a unique endpoint URL and some authorization keys used for encrypting the push notification content) along with the user’s choices for push notification content and duration. When the server receives the request, it will store this data in a JSON file.
For each user that subscribes, the server will setInterval (for example: every 180 seconds, if that’s what the user chose) to send a push notification regularly to that user.
When a user unsubscribes, the server removes the record from the JSON file, and it calls clearInterval to stop sending push notifications to that user.
This might sound complicated, but all of the server code we write will be in a single file, and you can always reference the server code from this article’s project repository.

Initialize a server Project and Use Express

From our project folder, we’ll create a subfolder called server, initialize a new project, and add a few packages:
~/project$ mkdir server 
~/project$ cd server 
~/project/server$ yarn init --yes 
~/project/server$ yarn add body-parser cors dotenv express node-fetch web-push

Generate VAPID Keys and Store as Environment Variables

When a server sends a push notification to a subscribed user, it needs to authenticate itself as the same server to which the user subscribed. To do this, there is an entire spec (called the VAPID spec) that dictates how this authentication works. Fortunately for us, the web-push package helps to abstract away most of these low-level details.
The one thing we do need to do, however, is generate a VAPID public/private key pair, and store it in a .env file so we can access those keys as environment variables.
At the command line, we’ll dive right into node and use the web-push library to generate a set of keys:
~/project/server$ node 
> var webPush = require('web-push'); 
> webPush.generateVAPIDKeys() 

{
  publicKey: 'BG2J2gPQhdIkxQC-U_j-HCrft3Af1HGuFj-HF7lI9Xa9PS9yj

cYrcWlcwvboiiMpDC3IF8yPEhsxH7vU4KRrmHs',
  privateKey: 'epAv8sAdUbu_HFEC-4JJanEtEMqdq7FEgScDSUAXHcw' 
}

> .exit
With that, we have our newly minted keys. Copy and paste those values into server/.env like so:
VAPID_PUBLIC_KEY='epAv8sAdUbu_HFEC-4JJanEtEMqdq7FEgScDSUAXHcw' 
VAPID_PRIVATE_KEY='BHm3P9ZnxaehLMJKmVgEm8ChOIxlRtr1elzDmX1NAGds

8TUqQiAc5omv1mr1g0IwQkJswNYLDH5xqNveK50Hg14'
Also, it’s a good practice not to store keys and credentials in your git repository, so let’s add .env to a .gitignore file in our server folder:
/project/server$ echo '.env' >> .gitignore

Write Our Server Code

Next, we’ll write our server code in server/index.js :
/* ~/project/server/index.js
*/

const express = require('express')
const fetch = require('node-fetch')
const bodyParser = require('body-parser')
const webPush = require('web-push')
const cors = require('cors')
const fs = require('fs')
const app = express()
require('dotenv').config()
const { VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY } = process.env
const SUBSCRIPTION_FILE_PATH = './subscriptions.json'
const INTERVALS = {}
const PUSH_TYPES = {
  iss: {
    description: 'International Space Station geolocation',
    url: 'http://api.open-notify.org/iss-now.json',
    responseToText: ({ iss_position }) => {
      return `Current position of the International Space Station: ${iss_position.latitude} (lat), ${iss_position.longitude} (long)`
    }
  },

  activity: {
    description: 'Suggestion for an activity',
    url: 'http://www.boredapi.com/api/activity',
    responseToText: ({ type, activity }) => {
      return `${activity} (${type})`
    }
  },

  quote: {
    description: 'Random software development quote',
    url: 'http://quotes.stormconsultancy.co.uk/random.json',
    responseToText: ({ author, quote }) => {
      return `${quote} (${author})`
    }
  }
}

if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) {
  console.log('VAPID public/private keys must be set')
  return
}

webPush.setVapidDetails(
  'mailto:REPLACE WITH YOUR EMAIL',
  VAPID_PUBLIC_KEY,
  VAPID_PRIVATE_KEY
)

const readSubscriptions = () => {
  try {
    return JSON.parse(fs.readFileSync(SUBSCRIPTION_FILE_PATH))
  } catch(_) {}
  return {}
}

const writeSubscriptions = (subscriptions = {}) => {
  try {
    fs.writeFileSync(SUBSCRIPTION_FILE_PATH, JSON.stringify(subscriptions))
  } catch (_) {
    console.log('Could not write')
  }
}

const sendNotification = async ({ subscription, pushType }) => {
  const obj = PUSH_TYPES[pushType]
  let notificationContent
  if (obj) {
    const response = await fetch(obj.url)
    notificationContent = obj.responseToText(await response.json())
  } else {
    notificationContent = 'Could not retrieve payload'
  }
  webPush.sendNotification(subscription, notificationContent)
}

const startNotificationInterval = ({ subscription, pushType, duration }) => {
  INTERVALS[subscription.endpoint] = setInterval(
    async () => { sendNotification({ subscription, pushType }) },
    duration * 1000
  )
}

const initializeNotifications = () => {
  const subscriptions = readSubscriptions()
  Object.keys(subscriptions).forEach(key => startNotificationInterval(subscriptions[key]))
}

app
  .use(cors({ 
    origin: ['http://localhost:3001', 'REPLACE WITH HEROKU CLIENT APP URL'],
    optionsSuccessStatus: 200
  }))
  .get('/vapidPublicKey', (_, res) => {
    res.send(VAPID_PUBLIC_KEY)
  })
  .use(bodyParser.json())
  .post('/subscribe', (req, res) => {
    const { subscription, pushType = 'iss', duration = 30 } = req.body
    const subscriptions = readSubscriptions()
    subscriptions[subscription.endpoint] = { subscription, pushType, duration }
    writeSubscriptions(subscriptions)
    webPush.sendNotification(subscription, `OK! You'll receive a "${PUSH_TYPES[pushType].description}" notification every ${duration} seconds.`)
    startNotificationInterval({ subscription, pushType, duration })
    res.status(201).send('Subscribe OK')
  })
  .post('/unsubscribe', (req, res) => {
    const subscriptions = readSubscriptions()
    delete subscriptions[req.body.subscription.endpoint]
    clearInterval(INTERVALS[req.body.subscription.endpoint])
    writeSubscriptions(subscriptions)
    res.status(201).send('Unsubscribe OK')
  })

app.listen(process.env.PORT || 3000, async () => {
  initializeNotifications()
})

Let’s briefly walk through each piece of this server file:

  1. After importing (require) the packages we’ll need, we call require('dotenv').config(). This loads our VAPID keys from .env as environment variables.
  2. We define an object called PUSH_TYPES which basically holds all the specifics about each of our three possible push-notification content options. Each type has a description, the external URL we’ll need to hit in order to fetch meaningful data to pass up to our user, and a small function callback that converts the fetch-data response into a string that will become the content for our push notification.
  3. Remember, web-push is the package we use to send push notifications. We initialize it by calling setVapidDetails and passing in our keys. This ensures that push notifications have proper keys attached, which will authenticate our server and ensure the notification content is properly encrypted.
  4. We’ll keep a file called subscriptions.json which will serve as our “database” for storing all our subscription records. The readSubscriptions function opens the file and parses the JSON content into a JavaScript object, while the writeSubscriptions file takes a JavaScript article and then overwrites the file with that object converted to JSON.
  5. sendNotification takes the subscription data (endpoint and keys) and a pushType. Then, it fetches the appropriate payload based on the pushType, crafting the content of our push notification. We then use webPush.sendNotification to package the notification and send it off. Remember: The web-push package does all of the heavy lifting (signing the headers for authentication, encrypting the payload, etc.) for us.
  6. startNotificationInterval sets the repeating timer for a subscription, so sendNotification is called for that subscription every X number of seconds. For each subscription, we store the setInterval ID in an object called INTERVALS. This allows us — when a user unsubscribes — to find that user’s repeating timer and cancel it.
  7. initializeNotifications is simply called when our server starts up. It reads the subscriptions file and starts up all of the interval timers for the subscriptions. It goes without saying that, if our server is stopped, push notifications won’t get sent.
  8. Finally, we set up the express app. We set up cors middleware to ensure that our LWC client is allowed to make requests of this server. We set up cors to allow requests from localhost (if we’re testing on the local development environment) and from our client’s Heroku deployment at herokuapp.com.
  9. We set up our server’s three endpoints. The GET /vapidPublicKey provides our server’s public key, which a subscribing client stores in order to authenticate incoming push notifications. The POST /subscribe endpoint takes information about the subscriber, stores it in our “database” and then starts up the interval timer for sending this new subscriber their push notifications. Lastly, the POST /unsubscribe endpoint removes the subscription from our database and stops their interval timer.

Deploy Our Server to Heroku

Just like we did for our client, we’ll create a new app with Heroku:
And again, we’ll create a git remote, this time named heroku-server:
~/project$ git remote add heroku-server https://git.heroku.com/
[REPLACE WITH HEROKU APP NAME].git
We’ll create a Procfile so that Heroku knows how to spin up our server:
~/project/server$ echo 'web: node index.js' > Procfile
We also need to configure our Heroku app with our VAPID keys as environment variables, since our .env file will not be pushed to Heroku. For the commands below, copy/paste the VAPID keys from your .env file, and make sure to use the Heroku app name for your server:
~/project/server$ heroku config:set -a HEROKU-APP-NAME-GOES-HERE

VAPID_PUBLIC_KEY=PUBLIC-KEY-GOES-HERE 
~/project/server$ heroku config:set -a HEROKU-APP-NAME-GOES-HERE

VAPID_PRIVATE_KEY=PRIVATE_KEY_GOES_HERE
Let’s add and commit our files:
~/project$ git add . 
~/project$ git commit -m "Implemented server, prepared for Heroku deploy"
Finally, similar to how we pushed our client, we use git subtree to push only the server folder to our Heroku remote:
~/project$ git subtree push --prefix server master heroku-server
That’s it. Our subscription and push notification server is up and running. To test, we can visit the /vapidPublicKey endpoint in our browser:
At the very least, we know that our server runs and our GET endpoint works. Now, it’s time to finish up our LWC client application.

Building the UI for Our Client

If you’re not a front-end developer by trade, you probably know that pre-built design frameworks are a huge time-saver when you just need some clean and functional UI. For our client, we’re going to take advantage of the Salesforce Lightning Design System. It’s filled with clean-looking components, brings consistency with Salesforce’s general UI, and also plays nicely with LWC.
Integrating the Salesforce Lightning Design System (SLDS)
When we initialized our project, we already added the @salesforce-ux/design-system package. To ensure that we use the system across our components, there are two more things we need to do.
First, we’re going to extend the standard LightningElement as our own class, which we’ll call LightningElementWithSLDS. This class will do everything that LightningElement does, but will also inject styles from SLDS. From there, all other components we build will extend this newly created class, giving them access to SLDS styles. To do this, we’ll add a new file, client/src/modules/LightningElementWithSLDS.js:
/* ~/project/client/src/modules/LightningElementWithSLDS.js
*/

import { LightningElement } from 'lwc'

export default class LightningElementWithSLDS extends LightningElement {
  constructor() {
    super()
    const path = '/resources/SLDS/assets/styles/salesforce-lightning-design-system.css'
    const styles = document.createElement('link');
    styles.href = path;
    styles.rel = 'stylesheet';
    this.template.appendChild(styles);
  }
}
Next, we want to ensure that our SLDS assets get built to the dist folder, which will be served up on the web. To do this, we add another line to lwc-services.config.js, which governs what gets copied when we call yarn build:
/* PATH: client/lwc-services.config.js
*/

module.exports = {
  resources: [
    { from: 'src/resources/', to: 'dist/resources/' },
    { from: 'src/index.html', to: 'dist/' },
    { from: 'src/manifest.json', to: 'dist/' },
    { from: 'src/pushSW.js', to: 'dist/pushSW.js' },
    { from: 'node_modules/@salesforce-ux/design-system/assets',
      to: 'dist/resources/SLDS/assets' }
  ]
};
For the last part of getting SLDS integrated, we also want to exclude the SLDS assets folder from the set of folders that our service worker will precache. This helps to keep our PWA slim (since we’ll only use a few styles and icons, ignoring the majority of the SLDS assets). To do this, we add an exclude configuration to our GenerateSW call in scripts/webpack.config.js:
/* PATH: client/scripts/webpack.config.js
*/

const { GenerateSW } = require('workbox-webpack-plugin')
module.exports = {
  plugins: [
    new GenerateSW({
      swDest: 'sw.js',
      importScripts: ['pushSW.js'],
      exclude: ['resources/SLDS']
    })
  ]
}

Quick Overview of Form-Related Components

We’re going to build most of our subscribe/unsubscribe logic in app.js. To keep our focus there in app.js— where the meat is — we’re not going to walk through all of the other form-related components in detail, but you can always take a closer look by inspecting the project repository
To give you a visual, this is what our UI looks like, nicely styled with SLDS:
Our final folder structure for client/src/modules will look like this:
.

├── jsconfig.json
├── LightningElementWithSLDS.js
├── my
│   ├── app
│   │   ├── app.css
│   │   ├── app.html
│   │   └── app.js
│   ├── notificationDuration
│   │   ├── notificationDuration.html
│   │   └── notificationDuration.js
│   ├── notificationType
│   │   ├── notificationType.html
│   │   └── notificationType.js
│   ├── radioOption
│   │   ├── radioOption.css
│   │   ├── radioOption.html
│   │   └── radioOption.js
│   └── subscribe
│       ├── subscribe.html
│       └── subscribe.js
└── RadioGroup.js
RadioGroup is a class that handles the user’s interactions with a group of radio options, making use of the radioOption component. We have two groups of radio options: the user needs to choose from a set of “notification type” choices, and choose from a set of “notification duration” choices. So, the notificationType and notificationDuration components both extend the RadioGroup class.
The subscribe component is a simple toggle button that lets the user know if they are currently subscribed to push notifications or not. When the user clicks on the button, this dispatches an event up to app, which tells app either to subscribe or unsubscribe the user.

Where It All Happens: app.js

Our main app contains our three simple components, and the markup looks like this:
<!-- ~/project/client/src/modules/my/app/app.html -->
<template>
  <div>
    <my-notification-type></my-notification-type>
    <my-notification-duration></my-notification-duration>
    <my-subscribe
      class="slds-align_absolute-center"
      is-subscribed={isSubscribed}
      ontoggle={handleSubscribeToggle}></my-subscribe>
  </div>
</template>
You can see that we pass the value of isSubscribed to the subscribe element as a way for communicating state to the button. And, when the subscribe button is pushed, it dispatches an event which app will handle in the handleSubscribeToggle function.
Here is the entirety of app.js, which we will walk through in more detail below:
/* ~/project/client/src/modules/my/app/app.js
*/

import LightningElementWithSLDS from '../../LightningElementWithSLDS'
const SERVER_ENDPOINT = 'REPLACE WITH HEROKU SERVER APP URL'
export default class App extends LightningElementWithSLDS {
  swRegistration = null
  subscription = null
  vapidKey = null


  connectedCallback() {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.ready.then(async () => {
        this.swRegistration = await navigator.serviceWorker.getRegistration()
        this.subscription = await this.swRegistration.pushManager.getSubscription()
        this.setOptionsState()
        this.vapidKey = await this.getVapidKey()
      })
    } else {
      console.log('service worker support is required for this client')
    }
  }

  async getVapidKey() {
    const result = await fetch(`${SERVER_ENDPOINT}/vapidPublicKey`)
    return result.text()
  }

  async handleSubscribeToggle () {
    if (this.subscription) {
      await this.unsubscribe()
    } else {
      await this.subscribe()
    }
    this.setOptionsState()
  }

  async subscribe() {
    if (this.subscription) {
      console.log('Already subscribed')
      return
    }
    this.subscription = await this.swRegistration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: this.vapidKey
    })
    try {
      const requestBody = {
        subscription: this.subscription,
        pushType: this.notificationType().value,
        duration: this.notificationDuration().value
      }
      const result = await fetch(`${SERVER_ENDPOINT}/subscribe`, {
        method: 'POST',
        headers: { 'Content-type': 'application/json' },
        body: JSON.stringify(requestBody)
      })
      console.log(requestBody, await result.text(), this.subscription)
    } catch (err) {
      console.log(err)
    }
  }

  async unsubscribe() {
    if (!this.subscription) {
      console.warn('No subscription found. Nothing to unsubscribe')
      return
    }
    try {
      const result = await fetch(`${SERVER_ENDPOINT}/unsubscribe`, {
        method: 'POST',
        headers: { 'Content-type': 'application/json' },
        body: JSON.stringify({
          subscription: this.subscription
        })
      })
      await this.subscription.unsubscribe()
      this.subscription = null
      console.log(await result.text())
    } catch (err) {
      console.log(err)
    }
  }

  setOptionsState () {
    if (this.subscription) {
      this.notificationType().disable()
      this.notificationDuration().disable()
    } else {
      this.setOptionDefaultsIfUnset()
      this.notificationType().enable()
      this.notificationDuration().enable()
    }
  }

  setOptionDefaultsIfUnset () {
    if (typeof this.notificationType().value !== 'string') {
      this.notificationType().setValue('iss')
    }
    if (typeof this.notificationDuration().value !== 'string') {
      this.notificationDuration().setValue('30')
    }
  }

  notificationType () {
    return this.template.querySelector('my-notification-type')
  }

  notificationDuration () {
    return this.template.querySelector('my-notification-duration')
  }

  get isSubscribed () {
    return (this.subscription !== null)
  }
}
Piece by piece, here is what app.js does:
  1. connectedCallback is part of the LWC component lifecycle, and is invoked when a component is inserted into the DOM. We wait for the serviceWorker to be ready, and then we retrieve the existing push notification subscription (if there is one) from the service worker. Based on the whether there’s a subscription, setOptionsState enables or disables the form options for the user. Lastly, we fetch the VAPID public key from the server, which we might use later on for subscribing to push notifications.
  2. When the user clicks on the button in our subscribe component, handleSubscribeToggle gets called. If the user is presently subscribed, then the app will call unsubscribe. If the user is not subscribed, then the app will call subscribe. After that completes, setOptionsState() is called again to enable or disable the form options accordingly.
  3. The subscribe function in app does two main things: First, it has to set up a subscription with the service worker (by calling this.swRegistration.pushManager.subscribe) and has to pass in the server’s VAPID public key. This key, again, pairs with the server’s private key, which it will use to sign its outgoing push notifications, authenticating the server itself as the sender of the notification. Next, subscribe takes this subscription object (which also now contains some keys unique to the client, which will be used by the server to encrypt the push notification) along with what the user has selected for notificationType and notificationDuration. It then sends all of this in a request to our server’s /subscribe endpoint.
  4. The unsubscribe function in app is a bit simpler. It takes the subscription that’s stored in the service worker’s pushManager, and it passes this in a request to our server’s /unsubscribe endpoint. The server will be able to look up its list of current subscribers and remove this user from that list, effectively turning off push notifications for this user. Next, we tell the subscription object itself to unsubscribe, effectively removing it from the service worker’s pushManager.
  5. Since LWC gets us quickly up and running with a web client, the remaining methods in app.js deal with UI and components — enabling or disabling form options or setting defaults or states.
And that’s it. When we use LWC — which gets us quickly up and running with a client application — and we couple it with service-worker function calls, the actual meat of our code in app.js turns out to be pretty straightforward. Most of the work is actually just in setup and in crafting the UI components.

Deploy the Completed Client to Heroku

Although we haven't shown all of our code in this article, we’ve included and walked through the most important parts, leaving the rest available in the project repository for your own review and usage.
With that, however, our client is complete. We’re ready for one final deploy to Heroku:
~/project$ git add .
~/project$ git commit -m "Completes LWC client"
~/project$ git subtree push --prefix client heroku-client master
A “Gotcha” When Testing/Refreshing PWAs — Delete the Precache!
Earlier, you saw how we used Chrome’s developer tools to see details about our PWA and the service worker that was registered. When you’re developing a PWA that uses precaching (like ours does), you’ll want to keep in mind a possible “gotcha.” When you update your PWA code and redeploy, and then refresh your browser, you might find that nothing has changed. That’s likely because your browser is still loading the cached version of the PWA.
To ensure that you are not loading from the cache, you should delete the entire PWA precache. You can do this in the developer tools, under the “Application” tab, by looking in “Cache Storage” in the left sidebar. Once you find the workbox-precache listing, you can right-click and delete it. Then, refresh your browser to get the latest version of your client:

Test Our PWA With Push Notifications

And now: the moment of truth.
We’ll test our client in the browser first, and then open it on a mobile device to see how it installs and runs.
Load the client in the browser by visiting the Heroku client app URL. On the client, once we have chosen a notification type and a notification duration, we click on the button to subscribe to push notifications.
Your browser may ask for you to allow receiving notifications from this website. (You’ll also want to make sure that notifications for your browser have been turned on.) Upon subscribing, we immediately receive a push notification telling us that we are subscribed:
In my example, I chose to receive the “International Space Station geolocation” notification “every 30 seconds.” About 30 seconds after I subscribed, this is what I got:
Install to Device
As we mentioned at the beginning of this article, the PWA’s power comes from its ability to appear on the client's device, so that it shows up in their app shelf, and they don’t need to visit your site URL in the browser. Ultimately, you’re providing them a quick and direct way to access your web application without any of the browser chrome.
This time, in the browser on our mobile device, we visit the same Heroku client app URL. Depending on your mobile OS, you’ll likely get a notification similar to the one below, asking if you would like to add this site to your home screen:
You can either “install” the PWA to your device by clicking on that notification link, or by clicking on the browser’s menu (three dots) and then choosing “Install to home screen.”
Once added, you’ll find your app available in your list of applications:
Open the application, and you’ll find the exact same UI/UX as if you were working on the client in your browser—push notifications and all!

Wrap Up and Review

Here’s a quick recap of what we covered in this article:
  1. We discussed how progressive web applications (PWAs) can be installed to user devices, giving your web application the look and feel of a pseudo-native app.
  2. We discussed how push notifications work, serving as one of the key features of service workers, which are fragments of JavaScript served up by the PWA which the browser launches in a separate thread process.
  3. We embarked on a project to use Lightning Web Components (and the Salesforce Lightning Design System) to produce a small PWA which could be installed and then push notifications to your user’s device.
  4. To facilitate the subscription actions and pushing of notifications, we built a small Express server and deployed it to Heroku.
  5. We completed our LWC PWA, taking note to ensure necessary files were built to the dist folder by configuring lwc-services.config.js. We also made sure that we called GenerateSW with the right configuration options for setting up our service worker in webpack.config.js. We wired all of the interactions and server requests together in app.js.
  6. We deployed our client to Heroku and then tested for successful subscribing, receiving, and unsubscribing from push notifications.
  7. Finally, we took full advantage of our PWA by installing it to our mobile device and running it as an application.
Progressive web applications are powerful. When you add in push notifications, you dramatically increase the ability of your application to engage your users. By leveraging Lightning Web Components, you ramp up the speed and ease with which you can develop your application. Coupled together, these technologies make for feature-rich and highly-engaging applications in a fraction of the typical time.
In this article, we’ve covered a lot of ground. Nice work! From here, you have all of the foundations you need — either using the project repository as a springboard, or launching on your own — to use push notifications in richer or more targeted ways, and to build them into LWC-backed PWAs that meaningfully address real-world business problems. Now get out there and build!

Written by MichaelB | I run Dev Spotlight - we write tech content for tech companies. Email at michael@devspotlight.com.
Published by HackerNoon on 2020/12/20