Sending Web push messages from Spring Boot to Browsers

Published: January 09, 2018  •  Updated: March 24, 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 page by clicking on the cog wheel icon and click Project settings and then open the Cloud Messaging tab.

step1 step1 step1

From this page we need to copy the Sender ID.

Next open the Service Accounts tab and click on "Generate New Private Key"
step1

and download the file with "Generate Key".
step1

Keep this file safe and don't commit it into a public repository. Everybody that has access to this file has full access to this Firebase project.


Next we need to open the Google Api Console: https://console.developers.google.com/apis
Select your project in the top bar and open the Library menu
step1

Search for "FCM" and select "Firebase Cloud Messaging API" step1

and then enable it
step1


This is everything you have to do in the Firebase and Google Console. Cloud Messaging (FCM) is now enabled 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 sole 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 location of the Service Account file. To externalize this setting I added the key to application.properties

fcm.service-account-file=./demoproject-be803-firebase-adminsdk-odtym-8dd3b17efc.json

src/main/resources/application.properties

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

package ch.rasc.swpush;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@ConfigurationProperties(prefix = "fcm")
@Component
public class FcmSettings {
  private String serviceAccountFile;

  public String getServiceAccountFile() {
    return this.serviceAccountFile;
  }

  public void setServiceAccountFile(String serviceAccountFile) {
    this.serviceAccountFile = serviceAccountFile;
  }

}

FcmSettings.java


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

public class PushChuckJokeService {

  private final FcmClient fcmClient;

  private final WebClient webClient;

  private int seq = 0;

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

PushChuckJokeService.java

The PushChuckJokeService bean is responsible for reading a joke from the Internet Chuck Norris Database 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();
    try {
      sendPushMessage(joke);
    }
    catch (InterruptedException | ExecutionException e) {
      Application.logger.error("send chuck joke", e);
    }
  }

PushChuckJokeService.java


To send push messages we use the official Firebase Admin SDK for Java
<dependency>
  <groupId>com.google.firebase</groupId>
  <artifactId>firebase-admin</artifactId>
  <version>5.9.0</version>
</dependency>

The SDK is also available for Node.js, Go and Python.

Before we can use the SDK, we need to set it up. For that we need to call the FirebaseApp.initializeApp() method with an options objects. For our example we only need to specify the path to the Service Account file we donwloaded earlier from the Firebase console.

  public FcmClient(FcmSettings settings) {
    Path p = Paths.get(settings.getServiceAccountFile());
    try (InputStream serviceAccount = Files.newInputStream(p)) {
      FirebaseOptions options = new FirebaseOptions.Builder()
          .setCredentials(GoogleCredentials.fromStream(serviceAccount)).build();

      FirebaseApp.initializeApp(options);
    }
    catch (IOException e) {
      Application.logger.error("init fcm", e);
    }
  }

FcmClient.java

In this example we send the messages to a topic that is routed to all clients that are subscribed to this topic. FCM also supports sending messages to specific clients.

The Firebase SDK provides the sendAsync() method that pushes messages to the clients. It takes a Message instance as parameter. In the Message object we specify the topic and specify the time to live of the message in seconds. If FCM cannot send the message in that time it will be deleted and the client never receives the message. 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 don't start their browser during that time. In conclusion, you should not use push messages for very important messages.

  public void send(Map<String, String> data)
      throws InterruptedException, ExecutionException {

    Message message = Message.builder().putAllData(data).setTopic("chuck")
        .setWebpushConfig(WebpushConfig.builder().putHeader("ttl", "300")
            .setNotification(new WebpushNotification("Background Title (server)",
                "Background Body (server)", "mail2.png"))
            .build())
        .build();

    String response = FirebaseMessaging.getInstance().sendAsync(message).get();
    System.out.println("Sent message: " + response);
  }

FcmClient.java

The notification object 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.

The data part of the message consists of custom key-value pairs (String->String). This is the data the client will receive and has access to in the Push message. Note that data section is limited to 4KB. For more details see the documentation: https://firebase.google.com/docs/cloud-messaging/concept-options


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">
  <style>
     body {
        font-family: -apple-system, BlinkMacSystemFont, “Segoe UI”, Roboto, Helvetica, Arial, sans-serif;
     }
     .header {
        font-family: monospace;
        font-size: 1.1em;
     }
     .joke {
        margin-bottom: 10px;
     }
  </style>
    
</head>
<body>
<div id="outTable">
</div>
<script src="https://www.gstatic.com/firebasejs/4.12.0/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/4.12.0/firebase-messaging.js"></script>
<script src="index.js"></script>
</body>
</html>

index.html

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"
}

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.12.0/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/4.12.0/firebase-messaging.js');

firebase.initializeApp({
  'messagingSenderId': '933399038510'
});

sw.js

Our Service Worker installs an event listener that listens for the push event. The browser emits this event each time a new push message arrives from FCM, regardless 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');
    }
  };
});

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();
    }
  });

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 foreground script in our example 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 entries from IndexedDB so it waits 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 can set it up the way I present here.

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

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 });
  });
}

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));
  }

}

RegistryController.java

When you address 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 by sending a request to FCM. The Firebase SDK provides the method subscribeToTopicAsync() for this purpose. It takes as first parameter one or more client tokens and as the second parameter the topic name. FCM will then subscribe all provided clients to this topic.

  public void subscribe(String topic, String clientToken) {
    try {
      TopicManagementResponse response = FirebaseMessaging.getInstance()
          .subscribeToTopicAsync(Collections.singletonList(clientToken), topic).get();
      System.out
          .println(response.getSuccessCount() + " tokens were subscribed successfully");
    }
    catch (InterruptedException | ExecutionException e) {
      Application.logger.error("subscribe", e);
    }
  }

FcmClient.java

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 joke 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


Notification

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

    Message message = Message.builder().putAllData(data).setTopic("chuck")
        .setWebpushConfig(WebpushConfig.builder().putHeader("ttl", "300")
            .setNotification(new WebpushNotification("Background Title (server)",
                "Background Body (server)", "mail2.png"))
            .build())
        .build();

FcmClient.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);
});

sw.js

Note that this background handler is NOT called when the server sends a notification object.
If you want to test it you need to remove the setNotification() call.

 Message message = Message.builder().putAllData(data).setTopic("chuck")
        .setWebpushConfig(WebpushConfig.builder().putHeader("ttl", "300").build())
        .build();

When everything is set up correctly, you should see this message
NotificationClient


This concludes our journey into Web Push Message country. 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.

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.