How to Quickly Build a Progressive Web App Using Lightning Web Components
How simple could it be to use LWC to build an app for push notifications? It turns out — really simple.
Join the DZone community and get the full member experience.
Join For FreeEarlier 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:
- Through the web application client, Alissa sends a request to your server that she wants to subscribe to receive push notifications.
- 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.
- When it comes time to send a push notification, the server sends a notification to Alissa’s unique endpoint URL.
- 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:
- Get the current geolocation of the International Space Station.
- Get a randomly selected quote about Computer Science.
- 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:
- Set up and initialize our LWC application client.
- Add push notification functionality to our client’s service worker (more on service workers below — don’t worry!).
- Test out deploying our client to the web via Heroku.
- Build an Express server to handle subscriptions and notifications.
- Deploy our server to the web via Heroku.
- Build the UI in our client which allows users to select their notification content and duration.
- Wire up the “subscribe/unsubscribe” toggle button in the client’s
app.js
to handle push notifications and send subscription requests to the server. - Deploy our completed client to Heroku.
- 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
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
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
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
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\""
}
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
})
);
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 })
)
});
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' }
]
};
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']
})
]
};
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:
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.)
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
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
Let’s add and commit our files:
~/project/client$ cd ..
~/project$ git add .
~/project$ git commit -m "Prepared client for initial Heroku deploy"
Make sure you have installed the Heroku CLI and are logged in:
~/project$ heroku login
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
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:
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.”
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
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-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:
- After importing (
require
) the packages we’ll need, we callrequire('dotenv').config()
. This loads our VAPID keys from.env
as environment variables. - 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. - Remember,
web-push
is the package we use to send push notifications. We initialize it by callingsetVapidDetails
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. - We’ll keep a file called
subscriptions.json
which will serve as our “database” for storing all our subscription records. ThereadSubscriptions
function opens the file and parses the JSON content into a JavaScript object, while thewriteSubscriptions
file takes a JavaScript article and then overwrites the file with that object converted to JSON. sendNotification
takes thesubscription
data (endpoint and keys) and apushType
. Then, it fetches the appropriate payload based on thepushType
, crafting the content of our push notification. We then usewebPush.sendNotification
to package the notification and send it off. Remember: Theweb-push
package does all of the heavy lifting (signing the headers for authentication, encrypting the payload, etc.) for us.startNotificationInterval
sets the repeating timer for a subscription, sosendNotification
is called for that subscription every X number of seconds. For each subscription, we store thesetInterval
ID in an object calledINTERVALS
. This allows us — when a user unsubscribed — to find that user’s repeating timer and cancel it.initializeNotifications
is simply called when our server starts up. It reads the subscription 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.- Finally, we set up the express
app
. We set upcors
middleware to ensure that our LWC client is allowed to make requests of this server. We set upcors
to allow requests fromlocalhost
(if we’re testing on the local development environment) and from our client’s Heroku deployment atherokuapp.com
. - 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. ThePOST /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, thePOST /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
:
xxxxxxxxxx
/* ~/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
:
xxxxxxxxxx
/* 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
:
xxxxxxxxxx
/* 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:
xxxxxxxxxx
.
├── 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:
xxxxxxxxxx
<!-- ~/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:
xxxxxxxxxx
/* ~/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:
connectedCallback
is part of the LWC component lifecycle, and is invoked when a component is inserted into the DOM. We wait for theserviceWorker
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.- When the user clicks on the button in our
subscribe
component,handleSubscribeToggle
gets called. If the user is presently subscribed, then theapp
will callunsubscribe
. If the user is not subscribed, then theapp
will callsubscribe
. After that completes,setOptionsState()
is called again to enable or disable the form options accordingly. - The
subscribe
function inapp
does two main things: First, it has to set up a subscription with the service worker (by callingthis.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 thissubscription
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 fornotificationType
andnotificationDuration
. It then sends all of this in a request to our server’s/subscribe
endpoint. - The
unsubscribe
function inapp
is a bit simpler. It takes thesubscription
that’s stored in the service worker’spushManager
, 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 thesubscription
object itself tounsubscribe
, effectively removing it from the service worker’spushManager
. - 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 on 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:
- 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.
- 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.
- 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.
- To facilitate the subscription actions and pushing of notifications, we built a small Express server and deployed it to Heroku.
- We completed our LWC PWA, taking note to ensure necessary files were built to the
dist
folder by configuringlwc-services.config.js
. We also made sure that we calledGenerateSW
with the right configuration options for setting up our service worker inwebpack.config.js
. We wired all of the interactions and server requests together inapp.js
. - We deployed our client to Heroku and then tested for successful subscribing, receiving, and unsubscribing from push notifications.
- 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!
Opinions expressed by DZone contributors are their own.
Comments