Skip to content

Implementation

Stefan Kientzler edited this page Jul 26, 2021 · 2 revisions

After the general consideration in the previous chapter, we now turn to the actual programming.

The subscription on the client side

Since the client side runs in the web browser, everything is implemented here in Javascript. The integration of the functions within the UI of the website is not dealt with here! Some functions in connection with the Web Push API are processed asynchronously, which is why the promise pattern is used several times in the following code. Numerous articles on this topic can be found on the internet - at https://web.dev/promises/ beginners can find a very good explanation.

Check whether PUSH messages are available

First of all, it must be checked whether all requirements are met in order to be able to receive PUSH notifications. To do this, the browser must support the push API and the website must run in a secure context (HTTPS).

For more info see https://www.w3.org/TR/push-api/ and https://caniuse.com/#feat=push-api.

function pnAvailable() 
{
    var bAvailable = false;
    if (window.isSecureContext) {
        // running in secure context - check for available Push-API
        bAvailable = (('serviceWorker' in navigator) && 
                      ('PushManager' in window) && 
                      ('Notification' in window)); 
    } else {
        console.log('site have to run in secure context!');
    }
    return bAvailable;
}

Obtain permission to deliver PUSH messages to the user

Due to the misuse of push notifications in the past, consent to display notifications should only be requested after the user has deliberately acted on it (e.g. by clicking a button - not automatically when the page loads!).

The following function should therefore best be integrated on your own website using a link / button in a separate area with corresponding explanations for the PUSH notifications. This should be seen as a rule and not just as 'best practice'. As a provider, it should be borne in mind that many (... most) users are more likely to reject an early request without detailed explanations. And once the request has been rejected, it is difficult to get the user back on board later.

If the user has already rejected the display of notifications, he should no longer be bothered with further information regarding the subscription to the push notifications, since he must first deactivate the blocking of the push notifications via the respective browser function! If necessary, this can be explained in more detail at a suitable point (e.g. under FAQ's).

async function pnSubscribe() 
{
    if (pnAvailable()) {
        // if not granted or denied so far...
        if (window.Notification.permission === "default") {
            await window.Notification.requestPermission();
        }
        if (Notification.permission === 'granted') {
            // register service worker
            await pnRegisterSW();
        }
    }
}

The browser remembers the user's last selection. This can be determined at any time via the notification.permission property. If the user has not yet made a decision, this property is set to 'default', otherwise to 'denied' or 'granted'. With most browsers, the decision can be reset by the user in the title bar or in the page settings.

Registration of the service-worker

In order to receive and display push notifications, even if the user is not on that homepage, a service must be registered running in the background outside the context of the website and is therefore always ready to respond to notifications. The so called service-worker is a separate javascript file that the browser copies copied from the origin location on the web to the local computer and executed there.

async function pnRegisterSW() 
{
    navigator.serviceWorker.register('PNServiceWorker.js')
        .then((swReg) => {
            // registration worked
            console.log('Registration succeeded. Scope is ' + swReg.scope);
        }).catch((error) => {
            // registration failed
            console.log('Registration failed with ' + error);
        });
}

It is not necessary to check whether the service-worker has already been registered. The browser (… the web push API) takes care of it. After registration, everything within the context of the website is done. All further steps are carried out within the service-worker.

All required functions and some helpers while testing included in PNClient.js.

Implementation of the service-worker

In the context of our homepage, we have registered the service-worker so far and are now dedicated to his tasks. When registering, the specified javascript file was downloaded and executed. The registration succeeds only if the script could be executed without errors. The only code in the service worker that is executed directly is to register listeners for several events:

// add event listener to subscribe and send subscription to server
self.addEventListener('activate', pnSubscribe);
// and listen to incomming push notifications
self.addEventListener('push', pnPopupNotification);
// ... and listen to the click
self.addEventListener('notificationclick', pnNotificationClick);

The use of the self variable is remarkable for these calls. In contrast to javascript in the context of a website, where this variable represents the current window, this variable refers to the worker himself within a service-worker.

Subscribe to push notifications and send subscription to the server

In the listener of the 'activate' event, the notification is subscribed using the web push API. The public VAPID key (see 1.2.3) is required for this. In addition, the function requires the boolean value 'userVisibleOnly' as parameter, which must always be set to true.

Comment on this parameter

When designing the web push API, there was a consideration if this parameter can be used to control whether a message generally has to be displayed to the user or whether certain actions can only be carried out in the background. However, there were concerns that this would create the possibility for developers to perform unwanted actions without the user's knowledge. This parameter can therefore be regarded as a 'silent agreement' that the user always get a message when a push notification arrives.

async function pnSubscribe(event) 
{
    console.log('Serviceworker: activate event');
    try {
        var appPublicKey = encodeToUint8Array(strAppPublicKey);
        var opt = {
                applicationServerKey: appPublicKey, 
                userVisibleOnly: true
            };
        
        self.registration.pushManager.subscribe(opt)
            .then((sub) => {
                // subscription succeeded - send to server
                pnSaveSubscription(sub)
                    .then((response) => {
                        console.log(response);
                    }).catch((e) => {
                        // registration failed
                        console.log('SaveSubscription failed with: ' + e);
                    });
            }, ).catch((e) => {
                // registration failed
                console.log('Subscription failed with: ' + e);
            });
        
    } catch (e) {
        console.log('Error subscribing notifications: ' + e);
    }
}

The public VAPID key must be transferred to the push manager as a UInt8 array. If successful, the push manager returns a subscription object. This object contains all information the server needs in addition to its own VAPID keys to be able to encrypt and send push notifications to this client. For this purpose, the information received must be sent to the server.

The data is sent to the server as JSON-formatted text in the body of a POST HTTP request. For transmission, we use the fetch() method of the javascript Fetch API. This method allows resources easily to be accessed or sent asynchronously over the network.

async function pnSaveSubscription(sub) 
{
    // stringify object to post as body with HTTP-request
    var fetchdata = {
            method: 'post',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(sub),
          };
    // we're using fetch() to post the data to the server
    var response = await fetch(strSubscriberURL, fetchdata);
    return response.json();
}

The target 'strSubscriberURL' is a service that has to be provided on your server. It accepts the transmitted data and stores it in a database. The implementation of this service is described in section 2.2. If more specific information is required in addition to the current subscription (e.g. login data of the user, a reference to a specific order or reservation, ...), this should also be transferred here, since this is the only direct link between client and server.

Displaying the received PUSH notifications

In contrast to the server side, on the client side the web push API (and the push services) takes over all tasks regarding verification and decryption, which enables us to concentrate on the display of the notification.

When a push notification arrives, a corresponding 'push' event is triggered to the service worker. So the first thing to do is to set up a listener to this event. All relevant information is passed to the listener in the 'event' parameter. In addition to some internal properties, this object primarily contains the data sent by the server. The format in which the data is transmitted is the sole responsibility of the sender. At this point, pure text is assumed - this may also can be sent for test purposes from some tools or from some browsers developer tools. (See appendix).

After we have implemented the sending of the notifications in chapter 2.3 we will switch to an object encoded as a JSON string. Through this object we are able to control most of the properties of the notification to be displayed. First of all, it's just about displaying a simple text message.

function pnPushNotification(event) 
{
    console.log('push event: ' + event);
    var strTitle = 'Notification';
    var strText = 'empty Notification received!';
    if (event.data) {
        strText = event.data.text();
    }
    var promise = self.registration.showNotification(strTitle, opt);
    event.waitUntil(promise);
}

To display the message the showNotification() function have to be used, which is passed the title and an option object. This option object can contain properties to control the content, format and behavior of the notification. A more detailed description follows in Chapter 2.3 when notifications are sent from the server. The final call of waitUntil() ensures that the (asynchronously generated) notification was actually displayed before the function exits and the browser terminates the service-worker.

Respond to user actions

In order to be able to react to user actions, a listener for the notificationclick event must be set up. With the event parameter, this function receives all data for the notification in the event.notification property. User-specific data can be passed within event.notification.data. This data for example can contain an URL to be opened when the user clicks on the notification.

function pnNotificationClick(event) 
{
    console.log('notificationclick event: ' + event);
    if (event.notification.data && event.notification.data.url) {
        const promise = clients.openWindow(event.notification.data.url);
        event.waitUntil(promise);
    }
}

The function clients.openWindow() is available for opening a URL in the browser. Here, too, the waitUntil() must be used to wait for the call to end correctly before the service-worker can be terminated. Further possible actions inside of the notificationclick event are discussed in chapter 2.3 when messages are sent from the server.

Receive and save subscriptions on the server

To receive and save the subscriptions, a service is set up on the server that receives the data posted by the HTTP request from the client and stores it in a database. It must therefore first be checked whether it is a POST request. In addition, it must be checked whether the content of the request has actually been identified as JSON data. If the request is correct, the data will be saved. For the sake of simplicity, we use a SQLite data provider here, since it creates its own data file and can be used without further configuration. By using the same data provider, the subscriptions will be accessed later to send the notifications. To integrate the package into your own system, you can use the MySQL data provider or your own data provider that implements the PNDataProvider interface.

// only serve POST request containing valid json data
if (strtolower($_SERVER['REQUEST_METHOD']) == 'post') {
    if (isset($_SERVER['CONTENT_TYPE']) && 
        trim(strtolower($_SERVER['CONTENT_TYPE']) == 'application/json')) {
        // get posted json data
        if (($strJSON = trim(file_get_contents('php://input'))) === false) {
            $result['msg'] = 'invalid JSON data!';
        } else {
            $oDP = new PNDataProviderSQLite();
            if ($oDP->saveSubscription($strJSON) !== false) {
                $result['msg'] = 'subscription saved on server!';
            } else {
                $result['msg'] = 'error saving subscription!';
            }
        }
    } else {
        $result['error'] = 'invalid content type!';
    }
} else {
    $result['error'] = 'no post request!';
}
// let the service-worker know the result
echo json_encode($result);

Create and send notifications

To send push notifications we have to follow the definitions of the web push protocol (see https://tools.ietf.org/html/draft-ietf-webpush-protocol-12). Basically, two steps are necessary when creating the push notification. In order to identify yourself with the push service, a signature must be transferred using the VAPID key in the header of the request. The notification itself is transmitted in encrypted form and corresponding information is also passed in the header so that the browser can decrypt the received data. If the notification was decrypted correctly, the browser triggers the 'push' event to the service-worker.

The VAPID header

In order to identify with the push service, the server has to sign some information in JSON format with its private VAPID key and pass it in the header. The push service verifies this and, if successful, forwards the notification to the user. The signature is given in the form of a JSON Web Token (JWT). A signed JWT is nothing more than a string, which consists of three components separated by dots.

  JWTInfo . JWTData . Signature

The first two strings are JSON formatted data, which have to be 'URL safe base64' encoded, the third part contains the encrypted signature:

JWT Info
This contains information about the JWT itself and the encryption algorithm used.
JWT Data
Contains information about the sender, the recipient (not the final recipient, but the push service!) and how long the message is valid.
Signature
The signature is generated from the first two unsigned parts. To do this, they are encrypted with the ES256 algorithm (short for: ECDSA using the P-256 curve and the SHA-256 hash algorithm) using the VAPID key.

The push service now validate the JWT by decrypting the signature using the public VAPID key and comparing it with the first two parts. The complete JWT (i.e. all three parts separated by a dot) is passed as authorization in the header. In addition, the public VAPID key 'URL safe base64' coded must be transferred in the crypto-key value.

The required VAPID headers are generated with the PNVapid class. The VAPID keys are passed once in the constructor since they do not change. The end point (i.e. the recipient) is passed on again for each notification to be generated.

// info	
$aJwtInfo = array("typ" => "JWT", "alg" => "ES256");
$strJwtInfo = self::encodeBase64URL(json_encode($aJwtInfo));

// data
// - origin from endpoint
// - timeout 12h from now
// - subject (e-mail or URL to invoker of VAPID-keys)
$aJwtData = array(
        'aud' => PNSubscription::getOrigin($strEndpoint),
        'exp' => time() + 43200,
        'sub' => $this->strSubject
    );
$strJwtData = self::encodeBase64URL(json_encode($aJwtData));

// signature
// ECDSA encrypting "JwtInfo.JwtData" using the P-256 curve 
// and the SHA-256 hash algorithm
$strData = $strJwtInfo . '.' . $strJwtData;
$pem = self::getP256PEM($this->strPublicKey, $this->strPrivateKey);

$this->strError = 'Error creating signature!';
if (\openssl_sign($strData, $strSignature, $pem, 'sha256')) {
    if (($sig = self::signatureFromDER($strSignature)) !== false) {
        $this->strError = '';
        $strSignature = self::encodeBase64URL($sig);			
        $aHeaders = array( 
                'Authorization' => 'WebPush ' . 
                                   $strJwtInfo . '.' . 
                                   $strJwtData . '.' . 
                                   $strSignature,
                'Crypto-Key' 	=> 'p256ecdsa=' . 
                                   self::encodeBase64URL($this->strPublicKey)
            );
    }
}

Encrypt the payload

Since the push notifications are sent by various push service providers, the actual user data is transmitted in encrypted form. The push service is unable to decrypt and read this data. This is defined in the 'Message Encryption for Web Push' (see https://tools.ietf.org/html/draft-ietf-webpush-encryption-09).

The techniques that are used during encryption are beyond the scope of this tutorial and are therefore not explained in detail. You will find a good explanation in the web push book by Matt Gaunt (https://web-push-book.gauntface.com) in chapter 4.2.

All required functions are provided by the PNEncryption class. This class also provides the additional request headers that are required so that the notification can be decrypted. In the constructor, this class requires the public key and the authentication code that was generated by the browser when subscribing, and of course the user data to be encrypted.

The payload

At this point we are now going to take a closer look at the user data that we want to send with the notification. As mentioned in section 2.1.4, the possible options that can be passed to the showNotification() function in the service-worker are explained in more detail now. Since the format and content of the payload can be freely defined (as long as the length of the user data does not exceed approx. 4000 characters), I have decided to include all information for displaying the notification on the server side together in an object. In addition to the title and the target URL to which we want to direct the user, this object also contains the complete options for the showNotification() function. Everything together is then JSON-encoded and sent as payload. This gives us the greatest flexibility to be able to determine the display and behavior of the notification from server side without having to make changes to the service worker.

The options of showNotification()

In order to address the user with a clear notification, this should consist at least of a short, meaningful title, a symbol with recognition value (-> preferably a company or product logo) and a short, precise text. The title is passed directly as a parameter, the other two values are part of the option object. A clear recommendation regarding the format of the symbol cannot be made. In any case, a square format should be chosen, since most browsers or platforms crop other formats accordingly. A size of 64dp (px * device pixel ratio - this gives 192px for a value of 3) has proven itself. The text should not be longer than about 200 characters. Here, too, the browsers and platforms differ when a longer text is provided. Some limit the text to a certain number of characters, others to a certain number of lines. It should also be keep in mind here that a text that is too long usually does not receive the necessary attention from the user.

With the tag option, notifications can be grouped for the user. This ensures that only the most recently received notifications with the same indicator are displayed to the user and the user is not "flooded" with a sequence of several messages of the same type. If the renotify option is also set, the user will be notified, and the notifications will still be grouped in the display list. If not set, no notification will be displayed. The support of the following properties, which can be defined to format the notification or its behaviour, varies widely between the several browsers/platforms and should therefore be used with caution.

Image
URL to a larger image, which is usually displayed below the text. Again, it is difficult to give a rule about size or format.
Badge
URL to a (often monochrome) badge. The badge is used to better classify the sender of the message. So far, this is only supported by a few browsers - most of them display their own icon.
Additional actions
Some browsers allow certain actions to be displayed within the notification so the user can select one of it. The respective javascript code must be defined in the service worker in the 'notificationclick' event. If this functionality is used, an alternative display and handling should always be provided if the browser or the target system does not support this function.

An action is defined by:

name description
action internal ID used in 'notificationclick' event.
title text to be displayed.
icon [optional] URL to an icon assigned to the action.

The count of actions that can be displayed within a notification vary as well. An interesting article on this topic can be found at https://developers.google.com/web/updates/2016/01/notification-actions.

Timestamp
This allows you to set the time when the message was generated. If this option is not set, the time at which the message arrived at the user is set.
Require Interaction
This property specifies that user interaction is required for the notification. The popup is usually displayed immediately and disappears after a certain time. If this option is activated, the popup remains until the user answers. This property should be used carefully (for very important or security issues only) as the user may find it annoying and may block the notifications permanently.
Silent
No sound is played or vibration is triggered.
Vibrate
A vibration pattern to run with the display of the notification. A vibration pattern must be an array with at least one member. The values are times in milliseconds where the even indices (0, 2, 4, etc.) indicate how long to vibrate and the odd indices indicate how long to pause. For example, [300, 100, 400] would vibrate 300ms, pause 100ms, then vibrate 400ms.
Sound
URL to a sound file. So far I have not found a browser that supports this.

Extend the 'push' event listener in the service-worker

To generate the notification, the PNPayload class provides all methods to define the properties described and create the Object. Since we initially assumed pure text as user data when creating the service worker in section 2.1.4, the event listener must now be expanded for the data contained in the notification. All that needs to be done is to decode the received JSON-formatted data and pass it on when calling the showNotification() function.

function pnPushNotification(event) 
{
    console.log('push event: ' + event);
    var strTitle = strDefTitle;
    var oPayload = null;
    var opt = { icon: strDefIcon };
    if (event.data) {
        // PushMessageData Object containing the pushed payload
        try {
            // try to parse payload JSON-string
            oPayload = JSON.parse(event.data.text());
        } catch (e) {
            // if no valid JSON Data take text as it is...
            // ... comes maybe while testing directly from DevTools
            opt = {
                icon: strDefIcon,
                body: event.data.text(),
            };
        }
        if (oPayload) {
            if (oPayload.title != undefined && oPayload.title != '') {
                strTitle = oPayload.title;
            }
            opt = oPayload.opt;
            if (oPayload.opt.icon == undefined || 
                oPayload.opt.icon == null || 
                oPayload.icon == '') {
                // if no icon defined, use default
                opt.icon = strDefIcon;
            }
        }
    }
    var promise = self.registration.showNotification(strTitle, opt);
    event.waitUntil(promise);
}

Send the notification via Http-request

The last step is to send the notification(s) to the respective push services via HTTP request. In order to be as independent as possible, this is done directly using cURL. To have as little idle time as possible even with a large number of notifications to be sent, all pending messages are first generated completely, encrypted and then sent using a cURL Multirequest. Since PHP does not support multithreading per se, this is the most elegant solution without complex external PHP extensions. After all notifications have been sent, the response codes of all requests are read in order to filter out any subscriptions that are no longer valid and, if so configured, to delete them from the database.

The complete process to send notifications

  • create the VAPID header signature
  • generate the payload
  • encrypt the payload
  • send via HTTP request
  • If necessary, delete expired / no longer valid subscriptions

is implemented in PNServer by using the respective classes.

Clone this wiki locally