Sending Web push messages from Spring Boot to Browsers

Published: January 09, 2018  •  java, javascript, pwa, spring

Service Workers not only have the ability to intercept requests from the browser and cache assets. They also bring another tool to the Web platform: Push Messages.

In this blog post we will create a simple Spring Boot application that periodically sends push messages over Firebase Cloud Messaging (FCM) to subscribed clients. The client is a trivial JavaScript application that displays the messages in a list. As example payload we use the jokes from the Internet Chuck Norris Database

The setup is very similar to my previous blog about Push Messages in a Cordova App. The difference is that this example works in any browser with a Service Worker implementation and does not depend on any native plugins.
Because the Web Push API depends on a Service Worker implementation the example I present here currently (January 2018) only works in Firefox and Chrome. But the future for Service Workers looks very promising. Just a few days ago Apple and Microsoft released developer previews of their browsers with Service Worker implementations. Looks like we are only a few months away until all major browsers support the Service Worker API. See the announcements from Apple and Microsoft for more information.


Firebase

Before we start coding we need to set up a Firebase project. Login to your Firebase Console https://console.firebase.google.com and create a new project or use an existing one. On the project page open the settings by clicking on the cog wheel icon and click Project settings and then open the Cloud Messaging tab.

step1 step2

From this page we need two things: the server key and the sender ID. Take a note of these two strings.
keys

This is already everything you have to do in the Firebase Console. Cloud Messaging (FCM) is enabled by default and according to the pricing page part of the free tier.


Server

The server is a Spring Boot application built with the start.spring.io page. As only dependency we add "Reactive Web" to the project. Note that this package is only available with Spring Boot 2.

The server needs to know the server key from the previous step. To externalize this setting I add the key to application.properties

fcm.api-key=AAAAU_I

src/main/resources/application.properties

and created a Pojo annotated with @ConfigurationProperties and @Component to read this setting

@ConfigurationProperties(prefix = "fcm")
@Component
public class FcmSettings {
  private String apiKey;
  public String getApiKey() {
    return this.apiKey;
  }
  public void setApiKey(String apiKey) {
    this.apiKey = apiKey;
  }
}

src/main/java/ch/rasc/swpush/FcmSettings.java

With this set up you can then inject the settings class into other beans

@Service
public class PushChuckJokeService {
  private final FcmClient fcmClient;
  private final WebClient webClient;

  public PushChuckJokeService(FcmClient fcmClient, WebClient webClient) {
    this.fcmClient = fcmClient;
    this.webClient = webClient;
  }

src/main/java/ch/rasc/swpush/PushChuckJokeService.java

The PushChuckJokeService bean is responsible for reading a joke from the Internet Chuck Norris Database every 30 seconds and send a push message over FCM to the subscribed clients.

  @Scheduled(fixedDelay = 30_000)
  public void sendChuckQuotes() {
    IcndbJoke joke = this.webClient.get().uri("http://api.icndb.com/jokes/random")
        .retrieve().bodyToMono(IcndbJoke.class).block();
    sendPushMessage(joke);
  }

src/main/java/ch/rasc/swpush/PushChuckJokeService.java

To send push messages you send a JSON to the FCM URL: https://fcm.googleapis.com/fcm/send
The JSON needs to follow this structure

{
  "message":{
    "to":"/topics/chuck",
    "notification":{
      "title":"Notification Title",
      "body":"A new message"
    }
    "data":{
      "id" : 1,
      "joke" : "The joke...."
    }
  }
}

In this example we send the messages to a topic that is routed to all clients that are subscribed to this topic. Instead of sending the message to a topic you can address a client directly by setting the token property and provide the token from the client.

    "token":"bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1...",

The notification section is optional and provides information that are displayed in the little popup window that appears when the client receives the message while the browser is in the background. We will see later that you can set this information on the client too.

In the data section you add custom key-value pairs. This is the data the client will receive and has access to. Note that data messages are limited to 4KB. For more details see the documentation: https://firebase.google.com/docs/cloud-messaging/concept-options

In Java, I use POJOs and a Map to create the message, Jackson then takes care of serializing the objects into JSON.

  void sendPushMessage(IcndbJoke joke) {
    Map<String, Object> data = new HashMap<>();
    data.put("id", joke.getValue().getId());
    data.put("joke", joke.getValue().getJoke());
    data.put("seq", this.seq++);
    data.put("ts", System.currentTimeMillis());

    // Send a message
    System.out.println("Sending chuck joke...");

    Notification notification = new Notification();
    notification.setBody("Background Body (server)");
    notification.setTitle("Background Title (server)");
    notification.setIcon("mail2.png");

    SendMessage sendMessage = new SendMessage();
    sendMessage.setNotification(notification);
    sendMessage.setData(data);
    sendMessage.setTo("/topics/chuck");
    sendMessage.setTimeToLive(2);

    this.fcmClient.send(sendMessage);
  }

src/main/java/ch/rasc/swpush/PushChuckJokeService.java

The FcmClient class sends the message to the Firebase URL. Here I use the reactive Web Client from Spring 5. Together with the message you have to send the server key in the Authorization header.

  public void send(SendMessage sendMessage) {
    ClientResponse response = this.webClient.post()
        .uri("https://fcm.googleapis.com/fcm/send")
        .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
        .header(HttpHeaders.AUTHORIZATION, "key=" + this.settings.getApiKey())
        .syncBody(sendMessage).exchange().block();
  }

src/main/java/ch/rasc/swpush/fcm/FcmClient.java


Client

On the client side we first create the index.html page. We add a div section where the JavaScript code will print out the received messages. We also have to reference a manifest.json file and import the Firebase JavaScript libraries and index.js, that contains our JavaScript application.

<!DOCTYPE html>
<html>
<head>
 <meta charset=utf-8 />
 <title>Web Push</title>
 <link rel="manifest" href="manifest.json">
  
</head>
<body>
<div id="outTable">
</div>
<script src="https://www.gstatic.com/firebasejs/4.8.1/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/4.8.1/firebase-messaging.js"></script>
<script src="index.js"></script>
</body>
</html>

src/main/resources/static/index.html

The manifest.json needs to contain at least an entry for the gcm_sender_id.
Don't change the number 103953800507. This is a fixed number and all clients in the world, that are connected to FCM, use this same number.

{
 "gcm_sender_id": "103953800507",
}

Like in any other manifest.json file you can add additional information

{
 "gcm_sender_id": "103953800507",
 "name": "Chuck Norris Jokes",
 "short_name": "jokes",
 "start_url": "index.html",
 "display": "standalone",  
 "theme_color": "#9acc99",
 "background_color": "#9acc99"
}

src/main/resources/static/manifest.json

Next we write the Service Worker (sw.js). First we need to import the Firebase library like we do in the foreground script. Then call the initializeApp method with the sender ID, that we copied from the Firebase Console.

importScripts('https://www.gstatic.com/firebasejs/4.8.1/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/4.8.1/firebase-messaging.js');
firebase.initializeApp({
  'messagingSenderId': '360542858297'
});

src/main/resources/static/sw.js

Our Service Worker can now install an event listener that listens for the push event. This handler runs every time a new message arrives. It does not matter if the application is in the foreground or the tab with the application is closed or the browser runs in the background.

The handler reads the key-value pairs from the data section (event.data.json().data) and stores them into an IndexedDB database. IndexedDB is one of the few interfaces that you can access from a Service Worker and from foreground scripts. Both have access to the same databases.

self.addEventListener('push', async event => {
  const db = await getDb();
  const tx = this.db.transaction('jokes', 'readwrite');
  const store = tx.objectStore('jokes');
  const data = event.data.json().data;
  data.id = parseInt(data.id);
  store.put(data);
  tx.oncomplete = async e => {
    const allClients = await clients.matchAll({ includeUncontrolled: true });
    for (const client of allClients) {
      client.postMessage('newData');
    }
  };
});

src/main/resources/static/sw.js

After the handler stored the message in the database it sends a message to the foreground script with client.postMessage('newData');. It is possible that the application is open in multiple tabs, therefore we loop over all clients that are associated with this Service Worker.

The foreground script (index.js) listens to this message with a message handler.

 navigator.serviceWorker.addEventListener('message', event => {
   if (event.data === 'newData') {
     showData();
   }
 });

src/main/resources/static/index.js

Note: Both handlers, the push handler in the Service Worker and the message handler in the foreground, are called when a push message arrives while the application is in the foreground. When the application is not open or in the background only the push handler in the Service Worker is called.
The example application ignores the message invocation from the push message and only reacts to the postMessage('newData') call from the Service Worker (if (event.data === 'newData')). The showData() method reads the items from IndexedDB so we wait until the Service Worker stored the message in the database.


In the foreground script (index.js) we first have to install the Service Worker and then initialize the Firebase messaging system like we do in the Service Worker with the sender ID. Then we need to link the Service Worker with the Firebase library (messaging.useServiceWorker).

There is an alternative way to do that. Name your Service Worker file firebase-messaging-sw.js and then remove the navigator.serviceWorker.register and messaging.useServiceWorker lines. Firebase will then automatically install the Service Worker for you. This is a more convenient way to set things up. If you already have a Service Worker in your application you do it the way I present here.

const registration = await navigator.serviceWorker.register('/sw.js');
  
 firebase.initializeApp({
   'messagingSenderId': '360542858297'
 });
 const messaging = firebase.messaging();
 messaging.useServiceWorker(registration); 
  try {
   await messaging.requestPermission();
 } catch (e) {
   console.log('Unable to get permission', e);
   return;
 }

src/main/resources/static/index.js

After the set up we have to request permission from the user. An application can only receive push messages when the user gave the permission. messaging.requestPermission() will open a little dialog that asks for permission

AskPermission

When the user gave the permission and the client could connect to FCM he gets a token. This is a unique reference for this particular client. You can access this token with the messaging.getToken() method. You should also install a handler for the tokenRefresh event, Firebase could assign a new token while the application is running.

 const currentToken = await messaging.getToken();
 fetch('/register', { method: 'post', body: currentToken });
 showData();

 messaging.onTokenRefresh(async () => {
   console.log('token refreshed');
   const newToken = await messaging.getToken();
   fetch('/register', { method: 'post', body: currentToken });
 });

src/main/resources/static/index.js

The sender of push messages needs to know this client token. So we send it to the /register http endpoint with a POST request to our Spring Boot application.

In the Spring Boot application we add a Controller for this endpoint.

@RestController
@CrossOrigin
public class RegistryController {
  private final FcmClient fcmClient;
  public RegistryController(FcmClient fcmClient) {
    this.fcmClient = fcmClient;
  }

  @PostMapping("/register")
  public void register(@RequestBody Mono<String> token) {
    token.subscribe(t -> this.fcmClient.subscribe("chuck", t));
  }
}

src/main/java/ch/rasc/swpush/RegistryController.java

When you address the clients directly with the token you need to store it somewhere and perhaps associate it with a logged in user.
But in this example we send messages to a topic so we have to subscribe the client to the topic chuck.

We don't have to manage the subscriptions ourselves. Firebase does that for use but we have to send a subscription request to Firebase. We do that with a GET request to https://iid.googleapis.com/iid/v1/{clientToken}/rel/topics/{topic} and, like in the send message code, we have to add the server key in the Authorization header.

  public void subscribe(String topic, String clientToken) {
    ClientResponse response = this.webClient.post()
        .uri("https://iid.googleapis.com/iid/v1/{clientToken}/rel/topics/{topic}",
            clientToken, topic)
        .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
        .header(HttpHeaders.AUTHORIZATION, "key=" + this.settings.getApiKey()).exchange()
        .block();
  }

src/main/java/ch/rasc/swpush/fcm/FcmClient.java

Technically we could send this request from the client without this complicated setup of sending it to our server and then from there to Google. The problem is that you need to send the server key together with the request and this would expose the key to the world. You need to keep the server key secret.

Now we test our appliction. Start Spring Boot and then open the application in the browser with the URL http://localhost:8080/index.html. Every 30 second you should see a new message appearing in the list.

Now close the tab or open another tab or minimize the browser window. You should see a little popup dialog when a message arrives.
NotificationServer

You only see this message when the tab with our application is closed or not active. Also note that the browser only receives messages when he is running. In Chrome you can enable the following setting and it stays in the background when you close the browser.
BackgroundSetting

Another setting you should look at is the time to live. In the sending part of our application we set the time to live to 2 minutes.

sendMessage.setTimeToLive(2);

src/main/java/ch/rasc/swpush/PushChuckJokeService.java

You can specify a TTL of up to 4 weeks. The chance that you reach all connected clients is higher with a longer TTL. But even with a long time to live it is possible that we don't reach all clients when they not start their browser during that time. In conclusion, you should not use push message for very important messages.


Notification

In the sending part you see that we also send a notification object together with the payload of the message

    Notification notification = new Notification();
    notification.setBody("Background Body (server)");
    notification.setTitle("Background Title (server)");
    notification.setIcon("mail2.png");

    SendMessage sendMessage = new SendMessage();
    sendMessage.setNotification(notification);

src/main/java/ch/rasc/swpush/PushChuckJokeService.java

You see this information in the popup window that the browser displays when the message arrives. As mentioned before you only see this window when the tab with our application is not active or in the background.

Instead of sending the notification from the server you can configure it in the Service Worker code. For that purpose you need to install a background message handler. In that handler you construct a notification object and display the notification with self.registration.showNotification

messaging.setBackgroundMessageHandler(function(payload) {
 const notificationTitle = 'Background Title (client)';
 const notificationOptions = {
   body: 'Background Body (client)',
   icon: '/mail.png'
 };
 return self.registration.showNotification(notificationTitle,
     notificationOptions);
});

src/main/resources/static/sw.js

Note that this background handler is NOT called when the server sends a notification object.
Comment out the line

// sendMessage.setNotification(notification);

src/main/java/ch/rasc/swpush/PushChuckJokeService.java

in PushChuckJokeService.java if you want to test this. When everything is set up correctly, you should see this message
NotificationClient


This concludes our journey into Web Push Message land. Thanks to the Firebase JavaScript library the set up on the client is very easy because the library does most of the work for use. As mentioned at the start of the blog post this example currently (January 2018) only runs on Chrome and Firefox. But it looks very promising that in a few months Edge and Safari will join the Service Worker train and bring support for Push messages with it.

For the sending part you can use any technology that is able to send JSON to a HTTP service.

Note that Firebase is not the only service that you can use for sending Push Messages. Mozilla provides the Mozilla Cloud Services (MCS) and from Microsoft you can use the Windows Push Notification Service (WNS). I don't have any experience with these services so I don't know how they compare with FCM.