Today we're going to implement a complete Web Push Notification system using Python for the backend and service worker for the frontend.
The system consists of two main components:
- Frontend: A Service Worker that receives and displays notifications
- Backend: A Python script that sends notifications to subscribed clients
It's important to understand how the Web Push Notification system works. The client (browser) subscribes to notifications and receives a unique endpoint URL. This URL is used by the server to send notifications to the client. Each browser has its own endpoint URL, so the server needs to keep track of these URLs for each client. Even that each browser has its own endpoint URL, they use same standard for the Web Push Notification system. The server will send notifications to the endpoint URL using the VAPID keys for authentication.
First we need to create our VAPID keys. VAPID (Voluntary Application Server Identification) is a protocol that allows the server to identify itself to the push service. It consists of a public key and a private key. The public key is sent to the client during the subscription process, and the private key is used by the server to sign the notifications. I'm using a node js package called vapid to generate the keys. You can install it using npm:
npm install -g web-pushThen run the following command to generate the keys:
npx web-push generate-vapid-keys The frontend in this example is a simple HTML page with a button to subscribe to notifications. When the button is clicked, the browser will ask for permission to send notifications. If the user accepts, the browser will generate a subscription object that contains the endpoint URL and the VAPID keys. The subscription object is then sent to the server, which stores it in a database (in this case, a simple JSON file). The server can then use this subscription object to send notifications to the client.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Push demo</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 500px;
margin: 0 auto;
padding: 1rem;
line-height: 1.5;
}
button {
background-color: #4CAF50;
border: none;
color: white;
padding: 10px 20px;
text-align: center;
font-size: 16px;
margin: 10px 0;
cursor: pointer;
border-radius: 4px;
width: 100%;
}
#unsubscribeButton {
background-color: #f44336;
display: none;
}
#subscriptionData {
background-color: #f5f5f5;
padding: 10px;
border-radius: 4px;
margin-top: 20px;
word-break: break-all;
display: none;
}
</style>
</head>
<body>
<button id="subscribeButton">Subscribe</button>
<button id="unsubscribeButton">Cancel subscription</button>
<div id="subscriptionData"></div>
<p id="status"></p>
<script src="main.js"></script>
</body>
</html>The main.js file contains the logic for subscribing and unsubscribing from notifications. It also handles the display of the subscription data and the status messages.
const publicVapidKey = 'BD7XE2RJieJ_FMejZibmBI_Tf0d7l04uDBIIVazO1sn_aB9_6ZIDHT7NGI2KyU-GtrS3W_cY1htmcMoBP0x0UTs';
const subscribeButton = document.getElementById('subscribeButton');
const unsubscribeButton = document.getElementById('unsubscribeButton');
const statusElement = document.getElementById('status');
const subscriptionDataElement = document.getElementById('subscriptionData');
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
statusElement.textContent = 'Your browser does not support push notifications π’';
subscribeButton.disabled = true;
} else {
(async () => {
try {
await navigator.serviceWorker.register('sw.js');
statusElement.textContent = 'Service Worker registered successfully β
';
await checkSubscription();
} catch (error) {
statusElement.textContent = `Error registering Service Worker: ${error}`;
}
})();
}
async function checkSubscription() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
subscribeButton.style.display = 'none';
unsubscribeButton.style.display = 'block';
displaySubscription(subscription);
} else {
subscribeButton.style.display = 'block';
unsubscribeButton.style.display = 'none';
subscriptionDataElement.style.display = 'none';
}
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
subscribeButton.addEventListener('click', async () => {
try {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
throw new Error('Notification permission denied');
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicVapidKey)
});
statusElement.textContent = 'Subscribed to push notifications β
';
displaySubscription(subscription);
subscribeButton.style.display = 'none';
unsubscribeButton.style.display = 'block';
} catch (error) {
statusElement.textContent = `Error subscribing: ${error}`;
}
});
unsubscribeButton.addEventListener('click', async () => {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
}
statusElement.textContent = 'Subscription canceled β
';
subscribeButton.style.display = 'block';
unsubscribeButton.style.display = 'none';
subscriptionDataElement.style.display = 'none';
} catch (error) {
statusElement.textContent = `Error canceling subscription: ${error}`;
}
});
function displaySubscription(subscription) {
subscriptionDataElement.textContent = JSON.stringify(subscription.toJSON(), null, 2);
subscriptionDataElement.style.display = 'block';
}And finally, the service worker (sw.js) that handles the push notifications. It listens for push events and displays the notifications.
self.addEventListener('push', event => {
try {
const data = event.data.json();
const title = data.title || 'Notification';
const options = {
body: data.body || 'You have received a new notification',
icon: data.icon || '/icon-192x192.png',
badge: data.badge || '/badge-72x72.png',
data: {
url: data.url || '/'
}
};
event.waitUntil(
self.registration.showNotification(title, options)
);
} catch (error) {
event.waitUntil(
self.registration.showNotification('New notification', {
body: 'You have received a notification',
icon: '/icon-192x192.png'
})
);
}
});
self.addEventListener('notificationclick', event => {
event.notification.close();
const urlToOpen = event.notification.data && event.notification.data.url
? event.notification.data.url
: '/';
event.waitUntil(
clients.matchAll({type: 'window'}).then(windowClients => {
for (let client of windowClients) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});Now, when we open the browser and click the "Subscribe" button, we should see a notification asking for permission to send notifications. If we accept, we will be subscribed to notifications and we will see the subscription data displayed on the page. We can also unsubscribe from notifications by clicking the "Cancel subscription" button. The browser will show us a json object with the subscription data, which includes the endpoint URL and the VAPID keys. This data is sent to the server when we subscribe to notifications. The server will use this data to send notifications to the client. We need to copy this json object and save it in a file called subscription.json. This file will be used by the server to send notifications to the client. In a real life application, we would send this data to the server using a http request.
{
"endpoint": "https://fcm.googleapis.com/fcm/send/e1...",
"expirationTime": null,
"keys": {
"p256dh": "BGoNCkIqKDXvR6qRYTaSUGxz7bASa-RCmXoysU8B3IW24aGXE51GX2wXzfEshU4--sL26iI3iwZOrMCjkbWoZJ0",
"auth": "LtMQyIB2_RoeqULPaWSd6ACq5SU2C9A-q8XoDlRQBtc"
}
}The server will use this data to send notifications to the client. The server will use the VAPID keys to authenticate the notifications. The server will also use the endpoint URL to send the notifications to the client.
import json
import logging
from pathlib import Path
from pywebpush import webpush, WebPushException
logging.basicConfig(
format='%(asctime)s [%(levelname)s] %(message)s',
level='INFO',
datefmt='%d/%m/%Y %X')
logger = logging.getLogger(__name__)
VAPID_PRIVATE_KEY = "RWbdOcw9sh1vC7L14iAkRhLE_3ZmVU0q0cQ2xqQnFn8"
VAPID_CLAIMS = {
"sub": "mailto:gonzalo123@gmail.com",
"aud": "https://fcm.googleapis.com"
}
BASE_DIR = Path(__file__).resolve().parent
def send_push_notification(subscription_info, message_data):
try:
webpush(
subscription_info=subscription_info,
data=json.dumps(message_data),
vapid_private_key=VAPID_PRIVATE_KEY,
vapid_claims=VAPID_CLAIMS
)
logger.info("Notification sent!")
except WebPushException as ex:
logger.error(f"Error sending push notification: {ex}")
if ex.response and ex.response.json():
logger.error(ex.response.json())
if __name__ == "__main__":
with open(BASE_DIR / "subscription.json", 'r') as f:
subscription_info = json.load(f)
title = 'Title'
body = 'Body'
url = 'https://gonzalo123.dom'
icon = None
message_data = {
"title": title,
"body": body,
"url": url,
"icon": icon
}
send_push_notification(subscription_info, message_data)