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.
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.
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
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
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()
})
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.
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']
})
]
}
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.
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:
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.
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:
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!
Here’s a quick recap of what we covered in this article:
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!
Also published at https://dev.to/salesforcedevs/progressive-web-apps-and-lightning-web-components-5eab