DEV Community

Cover image for Progressive Web Apps and Lightning Web Components
Michael Bogan for Salesforce Developers

Posted on

Progressive Web Apps and Lightning Web Components

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.

A Quick Review: What Is a Progressive Web App (PWA)?

While a PWA can be used in a web browser like any standard web application, the PWA's power comes from users being able to “install” the PWA to their desktop or mobile device, just like a native app. What you end up with is a kind of pseudo-native app — built and run with standard web-app technologies, but enhanced to do things like caching for offline access and push notifications.

When a user installs PWA to their device, they no longer need to open a web browser to visit your application’s website. They can just open your “app” on their device, just like they would open a social media app or a banking app.

What Are Lightning Web Components (LWC)?

The LWC framework is a lightweight set of reusable components built with JavaScript and HTML. With its own templating system and scaffolding tool for quick initialization of NodeJS projects, building applications with LWC makes for fairly easy work. For developers who need additional support with front-end design, they can easily integrate styles and themes from the Salesforce Lightning Design System into their projects too. Push Notifications Powered by a PWA

Push Notifications

Push notifications help keep users engaged with your application. They provide a way to push meaningful information to your users, rather than waiting for them to pull that information from your application or website. The basic workflow for this interaction looks like this:

  1. Through the web application client, Alissa sends a request to your server that she wants to subscribe to receive push notifications.
  2. The server receives this subscription request and stores it — this involves storing an endpoint URL (unique to Alissa and this application) along with (possibly) information about what kind of content to send and when to send that content.
  3. When it comes time to send a push notification, the server sends a notification to Alissa’s unique endpoint URL.
  4. The web-application client receives the notification from the server and pops up a notification with the appropriate content on Alissa’s device.

Getting Our Hands Dirty — We’re Going to Build One

In this walk-through, we’re going to build a working LWC application that sends push notifications. We’ll be able to use the application in our browser and as a standalone application installed on our device. To do this, we’ll use create-lwc-app to scaffold an LWC application — that’s our client. We’ll also build a lightweight Express server to handle subscribe/unsubscribe requests and to push out notifications. We’ll deploy both the client and the server to Heroku.

What will our application do? We’ll keep it simple. We’ll let our user choose from one of three content sources for their push notification:

  1. Get the current geolocation of the International Space Station.
  2. Get a randomly selected quote about Computer Science.
  3. Get a suggestion for an activity to do when bored.

Also, we’ll let our user choose if they want to receive a push notification every 30, 60, or 180 seconds.

Lastly, we’ll provide users with buttons to subscribe or unsubscribe from these notifications.

Follow Along, Follow the Steps

All the code for this walk-through is available at this Github repository. Here are the steps we’re going to take:

  1. Set up and initialize our LWC application client.
  2. Add push notification functionality to our client’s _service worker _(more on service workers below — don’t worry!).
  3. Test out deploying our client to the web via Heroku.
  4. Build an Express server to handle subscriptions and notifications.
  5. Deploy our server to the web via Heroku.
  6. Build the UI in our client which allows users to select their notification content and duration.
  7. Wire up the “subscribe/unsubscribe” toggle button in the client’s app.js to handle push notifications and send subscription requests to the server.
  8. Deploy our completed client to Heroku.
  9. Test our shiny new PWA, both in the browser and installed on our device.

This walk-through assumes that you have an account with Heroku (it’s free!) for deploying your client and server. If you have an account with Github to store your own code as you go along, that would be helpful too (though not required).

Are you ready to do this? Let’s dive in.

Initial Setup for Our LWC Application

Our main project folder will, in the end, have a client subfolder and a server subfolder. Starting in your project folder, use yarn to initialize the folder and then add the create-lwc-app package. This package scaffolds an LWC project, which makes getting started with LWC super simple.

~/project$ yarn init
~/project$ yarn add create-lwc-app
Enter fullscreen mode Exit fullscreen mode

Next, we’ll use create-lwc-app to set up our client project in the client folder:

~/project$ yarn create-lwc-app client -t pwa --yes
~/project$ cd client

# Install all the dependencies in the client project
~/project/client$ yarn install

# Since we'll be using yarn rather than npm
~/project/client$ rm package-lock.json

# Remove git data for create-lwc-app if using your own git repo
~/project/client$ rm -rf .git
Enter fullscreen mode Exit fullscreen mode

From this point on, all of the files we’ll be working with will be inside the ~/project/client subfolder (until we get to the section on building our server).

Make Some Minor Modifications to Simplify Our Project

The create-lwc-app scaffold provides some niceties that we’re not going to use as we go through this article. We’ll remove some packages just to keep things slim.

In package.json, remove the "husky" and "lint-staged" sections. Then, in the "scripts" section, remove the "prettier" and "test:*" lines. Husky gives us some nice-to-have, pre-commit code cleanup, but that will slow us down unnecessarily when we’re just trying to play and learn. Also, while we would ordinarily want to take advantage of how unit testing is baked right into create-lwc-app, we want to keep this article focused on building something quickly; so we’ll forego testing.

Remove the husky and prettier packages altogether:

~/project/client$ yarn remove husky prettier
Enter fullscreen mode Exit fullscreen mode

Next, we’ll add some packages that we are going to need in our project:

~/project/client$ yarn add nodemon node-fetch @salesforce-ux/design-system
Enter fullscreen mode Exit fullscreen mode

In package.json, change the "watch" script to make use of nodemon to rebuild and serve our project whenever our code changes:

"scripts": {
    ... 
    "watch": "nodemon -e js,html,css --watch src --exec \"yarn build && yarn serve\""
}
Enter fullscreen mode Exit fullscreen mode

Our final package.json file should look like this.

Remove the Content Security Policy

Typically, the LWC framework is served up with a strict Content Security Policy. The rationale is to protect against cross-site scripting vulnerabilities since LWC applications normally access Salesforce account data. For our purposes here, though, we want to turn off this restrictive policy, so that our client’s JavaScript code can send HTTP requests to our server when subscribing to push notifications.

To turn off this policy, we’ll edit scripts/server.js. In this file at line eight, you’ll notice app.use(helmet()). Helmet is a middleware package that sets security-related headers. We’ll simply configure Helmet not to use the default ContentSecurityPolicy, by editing that line to look like this instead:

/* ~/project/client/scripts/server.js
*/

app.use(
    helmet({
        contentSecurityPolicy: false
    })
);
Enter fullscreen mode Exit fullscreen mode

After the edit, our entire server.js file should look like this.

Push Notifications and the Service Worker

What Is a Service Worker?

A service worker is a small piece of code that the browser starts up. Then, the piece of code spins off to run as its own thread, separate from the browser. It gives browsers (and PWAs) the ability to cache data for access even if the user isn’t online, and to listen for push notifications even if their browser is closed. The service worker is fundamental to the building of feature-rich PWAs. Mozilla has put out an excellent resource — the Service Worker Cookbook — with lots of examples for how to use service workers effectively.

Our LWC Application Has a Service Worker Baked In!

You’ll recall that, when we called yarn create-lwc-app, we included a -t pwa flag. This flag results in the generation of scripts/webpack.config.js. This script is called whenever your client project is built, and it uses a method in workbox-webpack-plugin called GenerateSW. Ultimately, this builds a boilerplate service-worker script, which you’ll find in dist/sw.js if you run yarn build.

Additionally, the service worker is registered with the browser at src/index.html (at line 82).

The boilerplate service worker, however, is not set up to handle push notifications. We’ll do that here.

Enabling Our Service Worker to Handle Push Notifications

In our src subfolder, create a file called src/pushSW.js, with the following contents:

/* PATH: ~/project/client/src/pushSW.js
*/

self.addEventListener('push', (event) => {
  const body = event.data ? event.data.text() : 'no payload';
  event.waitUntil(
    self.registration.showNotification('LWC Push Notifications PWA', { body })
  )
});
Enter fullscreen mode Exit fullscreen mode

This tells our service worker to listen for a push event and then react by popping up a notification on the user’s device with the data from that event.

We’ll want to make sure our yarn build script properly copies src/pushSW.js to the dist folder. The dist folder contains all the files that will be served up as our client. To ensure pushSW.js is included, we need to modify lwc-services.config.js, adding the line below for exporting pushSW.js:

/* 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' }
  ]
};
Enter fullscreen mode Exit fullscreen mode

Lastly, we want to make sure that our pushSW.js code also gets loaded with our service worker. To do this, we want to modify scripts/webpack.config.js, telling GenerateSW to import our pushSW.js code as part of the sw.js file that it generates. We do this like so:

/* PATH: client/scripts/webpack.config.js
*/

const { GenerateSW } = require('workbox-webpack-plugin');
module.exports = {
  plugins: [
    new GenerateSW({
      swDest: 'sw.js',
      importScripts: ['pushSW.js']
    })
  ]
};
Enter fullscreen mode Exit fullscreen mode

This tells GenerateSW, while it’s generating sw.js, to bundle in our code from pushSW.js. Up above, since we wrote pushSW.js and made sure that it is copied to the dist folder, this call to GenerateSW will successfully incorporate our push notification functionality into our service worker.

Now, our LWC application is all set up as a full-fledged PWA that can handle push notifications. Let’s make sure we can deploy it to the web, and then the real fun begins!

Setup Deployment of the Client to Heroku

Next, we’re going to set up a Heroku app so that we can serve up our client on the web. Once you have logged in to Heroku, go to Create new app:

1pwa1

Choose a name for your app. (By the way, app names need to be unique across the herokuapp.com domain, so the example app name shown in this article may not be available to you.)

pwa2

Click on “Create app.” That’s all there is to setting up your Heroku app. The rest of the work will be at the command line, using the Heroku CLI and git.

Set Up git remote for Heroku Client App

Back at the command line, we’re going to set up a new git remote, and we’re going to call it heroku-client:

~/project$ git remote add heroku-client https://git.heroku.com/[REPLACE WITH HEROKU APP NAME].git
Enter fullscreen mode Exit fullscreen mode

You may have noticed that we’re doing something a little unconventional here. Ordinarily, if your git repository has a single project you want to deploy to Heroku, then you can just follow the “Deploy using Heroku Git” instructions on your Heroku app’s deployment page. In our setup, however, we have a single git repository which contains both a client project which needs to be deployed (git push) as one Heroku app and a separate server project which needs to be deployed as a different Heroku app. So, we will be creating different git remotes (one for client, one for server), and we’ll use git subtree to push our client application to one remote (called heroku-client), and our server application to the other (called heroku-server).

(If that’s confusing for you, you can absolutely choose to separate the client project and the server project into two separate git repositories. From there, just deploy the standard Heroku Git way.)

We need to add a Procfile to our client folder. This lets Heroku know what command to run in order to spin up the application to serve up the client. The Procfile is one line and can be created like this:

~/project/client$ echo 'web: yarn serve' > Procfile
Enter fullscreen mode Exit fullscreen mode

Let’s add and commit our files:

~/project/client$ cd ..
~/project$ git add .
~/project$ git commit -m "Prepared client for initial Heroku deploy"
Enter fullscreen mode Exit fullscreen mode

Make sure you have installed the Heroku CLI and are logged in:

~/project$ heroku login
Enter fullscreen mode Exit fullscreen mode

Now, to push only our client subfolder to the heroku-client remote, we use the following command (rather than the standard git push command):

~/project$ git subtree push --prefix client heroku-client master
Enter fullscreen mode Exit fullscreen mode

Test Our Client Deployment

After your code is pushed to Heroku, you’ll notice on the command line that Heroku goes through a build process and then calls yarn serve to serve up the client application on the web.

Let’s check our browser to see what we have:

pwa3

Excellent. Our initial LWC application is live!

Let’s look a little closer to see if the service worker with push notifications is properly registered. In your browser (we’ll be using Google Chrome for our example), open your developer tools and find the “Application” tab. In the left sidebar of the developer tools, click on “Service Workers.”

pwa4

You should see the sw.js service worker active and running. You can test the push-notification functionality by clicking on the “Push” button. You should have received a notification from your browser with the content “test?” embedded. If you didn’t see a notification, you may want to check your browser and your site settings to ensure that you’ve allowed notifications from this site.

Now that the client has been set up for basic push notifications, we’re going to take a short detour to build our server, which will handle subscriptions and pushing notifications.

Build Our Subscriptions and Notifications Server

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
Enter fullscreen mode Exit fullscreen mode

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) which 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-HF7lI9Xa9PS9yjcYrcWlcwvboiiMpDC3IF8yPEhsxH7vU4KRrmHs',
  privateKey: 'epAv8sAdUbu_HFEC-4JJanEtEMqdq7FEgScDSUAXHcw'
}
> .exit
Enter fullscreen mode Exit fullscreen mode

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='BHm3P9ZnxaehLMJKmVgEm8ChOIxlRtr1elzDmX1NAGds8TUqQiAc5omv1mr1g0IwQkJswNYLDH5xqNveK50Hg14'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
})
Enter fullscreen mode Exit fullscreen mode

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:

pwa5

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
Enter fullscreen mode Exit fullscreen mode

We’ll create a Procfile so that Heroku knows how to spin up our server:

~/project/server$ echo 'web: node index.js' > Procfile
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Let’s add and commit our files:

~/project$ git add .
~/project$ git commit -m "Implemented server, prepared for Heroku deploy"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

That’s it. Our subscription and push notification server is up and running. To test, we can visit the /vapidPublicKey endpoint in our browser:

pwa6

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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' }
  ]
};
Enter fullscreen mode Exit fullscreen mode

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']
    })
  ]
}
Enter fullscreen mode Exit fullscreen mode

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:

pwa7

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

pwa8

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:

pwa9

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:

pwa10

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:

pwa11

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:

pwa12

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 foundation 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!

Top comments (0)