Home | Send Feedback

Sending Web push messages from Spring Boot to Browsers

Published: January 09, 2018  •  Updated: December 07, 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. 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.

On the same page scroll down to Web Push certificates and click on Generate key pair.
step1

Copy the public key
step1

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 the Firebase project.

Server

The server is a Spring Boot application built with the https://start.spring.io page. As sole dependency, we add "Reactive Web" to the project.

The server needs to know the location of the Service Account file. To externalize this setting I created a new application setting and added it to application.properties

fcm.service-account-file=D:/ws/blog/sw-push/pushdemo-da91e-firebase-adminsdk-pccrs-76f3a73740.json

application.properties

To read this setting I created a POJO annotated with @ConfigurationProperties and @Component.

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


The PushChuckJokeService bean is responsible for fetching 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>6.6.0</version>

pom.xml

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 downloaded earlier from the Firebase console and added the path in the application.properties file.

  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 in seconds. If FCM cannot deliver 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.

  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 configure 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 the data 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 add a link to the 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/5.6.0/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/5.6.0/firebase-messaging.js"></script>
<script src="index.js"></script>
</body>
</html>

index.html

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

importScripts('https://www.gstatic.com/firebasejs/5.6.0/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/5.6.0/firebase-messaging.js');

firebase.initializeApp({
    messagingSenderId: "544834184896"
});

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 Service Workers 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 for 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 message invocations from the push service and only reacts to the postMessage('newData') call from the Service Worker (if (event.data === 'newData')).


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. In addition, we set the public Vapid key (messaging.usePublicVapidKey). Insert here the public key you copied from the Web Push certificates section in the Firebase console.

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, in that case, automatically install the Service Worker for you. This is a more convenient way to set things up. If you already have a Service Worker, you can set it up the way I presented here.

  const registration = await navigator.serviceWorker.register('/sw.js');
  await navigator.serviceWorker.ready;  
  firebase.initializeApp({
      messagingSenderId: "544834184896"
  });
  const messaging = firebase.messaging();
  messaging.usePublicVapidKey('BHioL1lTK99SeRs-Vi6lWYfw9xlTQtkVvCPsOoyrCjWyFHpCL05aDXaAFiEdam36xJdALL5ENYN6a6c-4zaMIfw');
  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 his permission. messaging.requestPermission() will open a little dialog that asks for permission. The user sees this dialog only once per domain. If he declines the request there is no way for an application to ask again.

AskPermission

When the user gave the permission and the client could connect to FCM, a token is assigned to this client. 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, in our example the Spring Boot application, 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.

  @PostMapping("/register")
  @ResponseStatus(HttpStatus.NO_CONTENT)
  public Mono<Void> register(@RequestBody Mono<String> token) {
    return token.doOnNext(t -> this.fcmClient.subscribe("chuck", t)).then();
  }

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. We only have to send Firebase the token of the client and the topic name.
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


To test the application, start Spring Boot and then open the application in the browser with the URL http://localhost:8080/index.html. When everything is set up correctly, you should see a new joke appear every 30 second.

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. The browser is only able to receive push messages when he is running. In Desktop 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 saw that we also specified 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 object 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 push message already contains a notification object.
If you want to test the client configuration, you need to remove the setNotification() call in the Java code.

 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.