Chapter 8. Service Worker to Page Communication with Post Messages

As we move more and more logic away from the page and into the service worker, we often find ourselves needing to communicate between the two.

In Chapter 7, we saw how moving important events such as network requests away from the volatile page unto the service worker can make our apps more reliable. But we often need to update our pages based on the results of those actions. For example, in “Adding Background Sync to Our App”, we moved the code that makes new registrations to a background sync event that runs in the service worker. This event calls the server and receives in response a JSON file containing the updated reservation details. We are using the data in that JSON file to update the reservation details in IndexedDB, but as the service worker doesn’t have access to the window, we were unable to update the details of the reservation in the DOM. Instead, the page relies on a naïve setInterval() that checks the network for the reservation’s status every few seconds and updates the DOM. If our sync event could send the updated reservation details to the page as soon as they are received, we could update the DOM immediately and avoid making this needless network request.

In this chapter, we will see how we can use postMessage() to send messages and data back and forth between the page and service worker and explore several types of communication:

  • Sending a message from the window to the service worker that controls it

  • Sending a message from a service worker to all windows in its scope

  • Sending a message from a service worker to a specific window

  • Sending a message between windows (through the service worker)

Window to Service Worker Messaging

Sending a message from a page to the service worker is relatively straightforward.

Before we can post a message from the page, we first need to get the service worker that controls it. We can access this service worker using navigator.serviceWorker.controller.

Next, we can use the service worker’s postMessage() method that receives as its first argument the message itself. This message can be almost any value or JavaScript object, including strings, objects, arrays, numbers, booleans, and more.

The following example shows sending a message containing a simple object from a page to the service worker:

navigator.serviceWorker.controller.postMessage(
  {arrival: "05/11/2022", nights: 3, guests: 2}
)

Once a message has been posted, the service worker can catch it by listening to the message event:

self.addEventListener("message", function (event) {
  console.log(event.data);
});

The code in this example would listen for incoming messages and log the contents of those messages to the console. The contents of the message can be found in the data property of the event object passed to the event listener (event.data).

Besides containing the message data itself, the event object contains a number of other useful properties. Some of the most useful ones are inside of its source property. source contains information about the window that posted this message and can help us decide what to do and where to post messages in response. The following are some sample uses for the message event’s source:

self.addEventListener("message", function (event) {
  console.log("Message received:", event.data);
  console.log("From a window with the id:", event.source.id);
  console.log("which is currently pointing at:",  event.source.url);
  console.log("and is", event.source.focused ? "focused" : "not focused");
  console.log("and", event.source.visibilityState);
});

Let’s look at one possible use case for posting a message to the service worker.

The Gotham Imperial Hotel might decide to expand its web app with a travel guide listing every restaurant in Gotham. As Gotham has thousands of restaurants, we may decide that caching the details of every single one would be too much. We can instead choose just to cache the details of restaurants the user looked at.

To accomplish this, we could add code that would post a message from restaurant details pages:

navigator.serviceWorker.controller.postMessage("cache-current-page");

When the user visits a restaurant page, a message will be posted to the service worker. The service worker could listen for these messages and use the event’s source to determine which page to cache:

self.addEventListener("message", function (event) {
  if (event.data === "cache-current-page") {
    var sourceUrl = event.source.url;
    if (event.source.visibilityState === "visible") {
      // Cache sourceUrl and related files immediately
    } else {
      // Add sourceUrl and related files to a queue to be cached later
    }
  }
});

The code in this example would use the message’s source URL to determine which page it needs to cache. It will also use that page’s current visibility state to determine which pages to fetch and cache first. This way, if the user opens a bunch of restaurants in many separate tabs, the contents of the visible one would be cached first. In “Common Messages in Progressive Web Apps”, we will look at how to communicate back to the page once it has been cached and update its UI to let the user know that the page is now cached and available offline.

Note

Note that the current page needs to have a service worker controlling it, or the code in otherwise calling navigator.serviceWorker.controller.postMessage() would cause an error. If the user just visited your site for the first time, a new service worker may have been installed and activated—but that doesn’t mean it is controlling the current page. In this case, navigator.serviceWorker.controller is undefined, and the code would break because undefined doesn’t contain a postMessage() method. In Chapter 4 you can read more about service workers moving from installing to active and taking control of the page.

The preceding code should actually be rewritten to include a check for the existence of the service worker before attempting to use it:

if ("serviceWorker" in navigator
    && navigator.serviceWorker.controller) {
  navigator.serviceWorker.controller.postMessage(
    "cache-current-page"
  );
}

Service Worker to All Open Windows Messaging

Posting a message from a service worker to a page is similar to posting one from the page to the service worker. The only difference is the object on which we call postMessage(). If so far we called postMessage() on the service worker, this time we will call it on the service worker’s clients.

From within a service worker, we can get all of the windows (WindowClients) that are currently open in the service worker’s scope using the clients object, which is in the service worker’s global object. clients contains a matchAll() method that we can use to get all of the windows (clients) currently open in the service worker’s scope. matchAll() returns a promise that resolves to an array containing zero or more WindowClient objects:

self.clients.matchAll().then(function(clients) {
  clients.forEach(function(client) {
    if (client.url.includes("/my-account")) {
      client.postMessage("Hi client: "+client.id);
    }
  });
});

This code gets all the clients currently controlled by the service worker, iterates over them, and then posts a message to the ones that currently show the My Account page.

Listening for message events coming from the service worker from within the page itself is also quite similar to what we have already seen in “Window to Service Worker Messaging”. This time, however, we add the event listener to the serviceWorker object:

navigator.serviceWorker.addEventListener("message", function (event) {
  console.log(event.data);
});

If you include the code on your page and run the previous code in a service worker, any page that is currently pointing at the My Account page will log a message to the console that says something like:

Hi client: b85b7e3d-a893-4b67-9e41-1d6fddf40110
Caution

Just placing the code at the top of your service worker will not be enough. If it is placed outside of an event, it will be executed once when the service worker script loads, before the service worker installed, and before any clients are listening for it. Instead, add it inside an event, as shown in the following code example. During development, you can also use the browser’s console to run this code in the service worker’s scope. See “The Console”.

Let’s look at a typical use case for this type of communication.

We would like to assure users of the Gotham Imperial Hotel app that they can use the app whether they are online or offline. We can do this by showing them a message as soon as the service worker has installed and finished caching all the assets it needs. The following sample modifies the install event to post a message to all clients after caching has completed. The page could then respond to this event by displaying a message to the user assuring him that the app can now be used both offline and online:

self.addEventListener("install", function(event) {
  event.waitUntil(
    caches.open(CACHE_NAME).then(function(cache) {
      return cache.addAll(CACHED_URLS);
    }).then(function() {
      return self.clients.matchAll({ includeUncontrolled: true });
    }).then(function(clients) {
      clients.forEach(function(client) {
        client.postMessage("caching-complete");
      });
    })
  );
});

The code is similar to the existing state of the Gotham Imperial Hotel’s install event, with one addition. Once the promise returned by cache.addAll() resolves, we use the clients object to get all currently open WindowClients and post a message to each of them.

The code for posting these messages is based on the same principles we have seen in the first example in this section, but includes one important change. When we call clients.matchAll(), we pass it an options object, telling it to include uncontrolled clients. This is another example of how important it is for us as developers to understand the service worker’s lifecycle (as explained in Chapter 4). When a user visits our page for the first time, the service worker installs and activates. However, the page is still not controlled by the service worker. If we did not tell self.clients.matchAll() to include uncontrolled windows, our message wouldn’t have reached its destination.

In Chapter 11 we will look at a complete example of informing the user when caching is complete.

Service Worker to Specific Window Messaging

In addition to the matchAll() method, the clients object has another useful method that allows you to get() a single client object. By passing the ID of a known client to get(), we can receive a promise that resolves to that WindowClient object. We can then use that object to post a message specifically to that client.

For example, if the ID of one of our WindowClients is "d2069ced-8f96-4d28", we can run the following code to let that window know if it is currently visible or not:

self.clients.get("d2069ced-8f96-4d28").then(function(client) {
  client.postMessage("Hi window, you are currently " + client.visibilityState);
});

There are several ways you might find out a client window’s ID. One way is getting it from the ID attribute of the WindowClient object when you are iterating over all open clients with clients.matchAll(). Another possible way is to get it from a post message event’s source attribute. Both can be seen in the following:

self.clients.matchAll().then(function(clients) {
  clients.forEach(function(client) {
    self.clients.get(client.id).then(function(client) {
      client.postMessage("Messaging using clients.matchAll()");
    });
  });
});

self.addEventListener("message", function(event) {
  self.clients.get(event.source.id).then(function(client) {
    client.postMessage("Messaging using clients.get(event.source.id)");
  });
});

Yes, both of these samples are quite superfluously silly. In both of them we are using a client object (client in the first case, event.source in the second) to get its ID so that we can use that ID to get that client object. The two samples can be simplified to avoid clients.get() altogether:

self.clients.matchAll().then(function(clients) {
  clients.forEach(function(client) {
    client.postMessage("Messaging using clients.matchAll()");
  });
});

self.addEventListener("message", function (event) {
  event.source.postMessage("Messaging using event.source");
});

A much more likely use case for clients.get() is one where you will store a client’s ID in the service worker so that you can access it with clients.get() later.

For example, consider an app that keeps track of the stock market. This app consumes a readable stream of data through which updates for many different stocks arrive. Knowing that your users tend to keep many windows open, each on a different screen and each keeping track of a different stock, you realize that you will have to keep the same stream open on each open window. Looking to optimize your app and save bandwidth and server costs, you decide to stop opening streams in individual windows and only open a single stream for all stock price changes in the service worker. Each page can then post a message to the service worker when it opens, telling it which stock updates it wants to subscribe to. The service worker would then keep a list of which client IDs want updates for each stock. Now, any time updates on a specific stock arrive through the stream, the service worker can get a list of clients interested in that stock and use clients.get() to post a message with the updated stock information to each of them.

Keeping the Line of Communication Open with a MessageChannel

So far we only saw how to use either the WindowClient or service worker objects to post messages, and only looked at the first argument that postMessage() accepts. But postMessage() can actually receive a second argument, one which can be used to keep the line of communication between the two sides open, sending messages back and forth.

This communication is handled by a MessageChannel object.

If you are familiar with the experiment of attaching two cups with a string, speaking into one cup while a friend listens to the other, you are already familiar with how MessageChannel works.

In MessageChannel, the two cups are called port1 and port2 (Figure 8-1). You can speak into each cup (or port) using postMessage(), and you can listen to each cup using an event listener:

Throw new ClipartException('string not pulled taut');
Figure 8-1. Throw new ClipartException(‘string not pulled taut’);
var msgChan = new MessageChannel();
msgChan.port1.onmessage = function(msg) {
  console.log("Message received at port 1:", msg.data);
};
msgChan.port2.postMessage("Hi from port 2");

This code creates a new cups-and-string MessageChannel, listens to the cup called port1, and then speaks into the other cup (port2). Running it in your browser should log Message received at port 1: Hi from port 2 to the console.

When we communicate between a window and a service worker (or vice versa), we can create a new MessageChannel object in the window and pass one of its ports to the service worker through a posted message. This port will then be accessible in the service worker when the message arrives. The result is an open communication channel with one port in the service worker and the other in the window.

The following example posts a message to the service worker and receives a response through an open MessageChannel port:

// Window code
var msgChan = new MessageChannel();
msgChan.port1.onmessage = function(event) {
  console.log("Message received in page:", event.data);
};

var msg = {action: "triple", value: 2};
navigator.serviceWorker.controller.postMessage(msg, [msgChan.port2]);


// Service Worker code
self.addEventListener("message", function (event) {
  var data = event.data;
  var openPort = event.ports[0];
  if (data.action === "triple") {
    openPort.postMessage(data.value*3);
  }
});

The code on the page begins by creating a new MessageChannel and adding an event listener that will listen to its first port, logging any messages that arrive to it. Next, the code posts a message to the service worker, passing the second port of the MessageChannel to it. Note that postMessage accepts an array of ports as its second argument so that you can communicate through zero or more ports.

Meanwhile, in the service worker, we are listening for message events sent to the service worker. When such an event is detected, the event object within it contains both the message contents (event.data), along with the array of ports the page sent (event.ports). Our event listener checks for messages where the data object contains an action attribute containing the string "triple" and then posts a message back with the data object’s value attribute multiplied by three. This message is posted directly through the MessageChannel port found in event.ports[0], which is actually port2 of the MessageChannel created in the page. This message will then travel along the string from the service worker to the page, arriving at port 1, where a separate event listener will log it to the console.

This simple example shows how we can delegate mathematical calculations from the page to our service worker. Similarly, our page could ask the service worker for the existence of items in the cache, or even ask it for a count of how many other tabs showing our app are currently open. The service worker could use the reverse approach to ask a window it controls for the value of an input field, or even how far in the page a user has scrolled so that the next page can begin caching.

Caution

I encourage you to look at the previous example again and see the different ways we call postMessage() and the different ways we attach event listeners. Note when we call postMessage() on the service worker object, and when we call it on a MessageChannel port. Similarly, note how we sometimes listen for message events on the service worker, and sometimes on a MessageChannel port.

If things don’t seem to work as they should, check to see that you are attaching events and posting messages to the right objects. If your service worker posts a message to a MessageChannel port, and your page listens to message events on the service worker and not the other port, nothing will happen—you are putting your ear to a plate and not the other cup.

The previous example showed how we can use MessageChannel to respond to a postMessage. Let’s look at another example that shows how we can keep a continuous communication channel open between the page and the service worker:

// Window code
var msgChan = new MessageChannel();
msgChan.port1.onmessage = function(event) {
  console.log("URL fetched:", event.data);
};
navigator.serviceWorker.controller.postMessage("listening", [msgChan.port2]);


// Service worker code
self.addEventListener("message", function (messageEvent) {
    var openPort = messageEvent.ports[0];
    self.addEventListener("fetch", function(fetchEvent) {
      openPort.postMessage(fetchEvent.request.url);
    });
});

The window code in this example is very similar to the previous one. We create a new MessageChannel, listen to one port, and post a message to our service worker containing the other port. The only change is the contents of that message.

When the service worker receives this message, it adds an event listener on its own fetch event, telling it to post each fetch request’s URL as a message through the open port.

The result is a page that continuously logs the URL of every network request made—not just by the current tab, but also by any other window controlled by this service worker. Name this file network.html, and navigate through the site in another tab, and you have the first step toward building your own version of the browser’s developer tools.

Communicating Between Windows

Let’s take everything we have learned so far and see how we can communicate between different windows. In the past, passing messages between different windows required resorting to hacks, such as writing the message to a cookie, local storage, or even the server. But with service workers providing a central point of contact that can reach every open window in its scope, we can finally dispatch messages, objects, and even MessageChannel ports between windows.

It is time to get back to some coding.

Before you begin, make sure that your code is in the state we left it in at the end of the last chapter by running the following commands in the command line:

git reset --hard
git checkout ch08-start

At the top of Gotham Imperial Hotel’s My Account page is a Logout link. When clicked, this link sends the user back to the site’s home page. (Gotham is built on trust and requires no usernames or passwords. In your app, you may decide to include some actual login/logout logic here.) Let’s modify the site so that when the Logout link is clicked, all open windows that are pointed at the My Account page will navigate to the home page.

Modify the $(document).ready function in app.js to look like this:

$(document).ready(function() {
  $.getJSON("/events.json", renderEvents);

  if ("serviceWorker" in navigator) {
    $("#logout-button").click(function(event) {
      if (navigator.serviceWorker.controller) {
        event.preventDefault();
        navigator.serviceWorker.controller.postMessage(
          {action: "logout"}
        );
      }
    });
  }
});

Our code checks for service worker support, and if it is available, adds a click event listener to the Logout link. This event handler first checks that there is a service worker controlling this page, and if so, it prevents the link’s default behavior, posting a message to the service worker instead of letting the page navigate.

This is a great example of progressive enhancement in action. The Logout link begins its life like any other simple HTML link (<a href="/">Logout</a>) and is fully functional. We then enhance it to support logging out of multiple windows in browsers that support service workers—without breaking existing behavior in older browsers.

Next, we’ll add the code that listens for this message in the service worker.

Add the following code to the end of serviceworker.js:

self.addEventListener("message", function(event) {
  var data = event.data;
  if (data.action === "logout") {
    self.clients.matchAll().then(function(clients) {
      clients.forEach(function(client) {
        if (client.url.includes("/my-account")) {
          client.postMessage(
            {action: "navigate", url: "/"}
          );
        }
      });
    });
  }
});

This code listens for message events, gets the message data from the event object (event.data), and decides how to act based on that data. If the message data contains an action called "logout", the listener gets all WindowClients currently open, goes over each of them, and checks if their URL includes "/my-account". If it does, it posts a message to it containing the value "navigate" as the action to take and "/" as the URL of that action.

Note

This message object structure, containing an action to take and extra parameters for it, is completely arbitrary and was chosen because it works well for this situation. "navigate" has no specific meaning to the browser here; it is just a string I have chosen to describe the action I would like my app to take.

Next, we modify the page to listen to messages sent from the service worker.

Modify the $(document).ready function in app.js to look like this:

$(document).ready(function() {
  $.getJSON("/events.json", renderEvents);

  if ("serviceWorker" in navigator) {
    navigator.serviceWorker.addEventListener("message", function (event) {
      var data = event.data;
      if (data.action === "navigate") {
        window.location.href = data.url;
      }
    });

    $("#logout-button").click(function(event) {
      if (navigator.serviceWorker.controller) {
        event.preventDefault();
        navigator.serviceWorker.controller.postMessage(
          {action: "logout"}
        );
      }
    });
  }
});

This latest addition to the code adds an event listener that listens for message events from the service worker. When this event handler is triggered, it gets the content of the message and checks whether it contains an action attribute with the value "navigate". If it does, it navigates the current page to the URL passed in the message.

That’s it! We have just progressively enhanced a simple HTML link to cause it to navigate not just the current window, but also all other open windows that match a certain criterion (i.e., all windows that show the My Account page).

The logic to achieve this was relatively straightforward.

If there is a service worker controlling the page, override the logout link’s default action and instead post a message to the service worker telling it to take a "logout" action. Meanwhile, the service worker listens for these messages; and when they are detected, it posts a message to all controlled windows whose URL contains "/my-account", telling them to take a "navigate" action. The pages listen for these messages; and when they are detected, they each navigate to the URL included in the message.

Note

Note how in the sample code we are not checking that a service worker is controlling the page before adding the event listener. While only pages that are currently controlled by a service worker can post a message to the service worker, any page can add an event listener for incoming messages.

In the sample code in “Service Worker to All Open Windows Messaging” we saw an example of sending a message from the service worker to uncontrolled pages.

Posting Messages from a Sync Event to the Page

Let’s turn our attention back to the challenge we opened this chapter with.

In Chapter 7 we saw how moving events away from the page and into the service worker can make our app more resilient and reliable. But this exposed a new difficulty. If the page delegates sending a message, liking a post, or making a reservation to the sync event, how can we update the DOM once that event has completed? Now that we know how to post messages between the service worker and the page, we have all the tools we need to tackle this.

In “Adding Background Sync to Our App”, we moved the logic to make new reservations away from the My Account page and into the sync event. Unfortunately, when the sync event completed successfully, we could not communicate this back to the window. We may have made our app more resilient, but we actually took a step back in the user experience. While the code that predated the sync event did update the DOM as soon as the reservation was made, the new sync code waited until the next time the page requested updates from the network.

Let’s fix this.

Update the syncReservations() function in serviceworker.js to look like this:

var syncReservations = function() {
  return getReservations("idx_status", "Sending").then(function(reservations) {
    return Promise.all(
      reservations.map(function(reservation) {
        var reservationUrl = createReservationUrl(reservation);
        return fetch(reservationUrl).then(function(response) {
          return response.json();
        }).then(function(newReservation) {
          return updateInObjectStore(
            "reservations",
            newReservation.id,
            newReservation
          ).then(function() {
            postReservationDetails(newReservation);
          });
        });
      })
    );
  });
};

The new syncReservations() function includes a single enhancement; just after it calls updateInObjectStore(), it also calls postReservationDetails(), passing it the new reservation details received from the network.

Next, add the postReservationDetails() function to serviceworker.js, just above the syncReservations() function:

var postReservationDetails = function(reservation) {
  self.clients.matchAll({ includeUncontrolled: true }).then(function(clients) {
    clients.forEach(function(client) {
      client.postMessage(
        {action: "update-reservation", reservation: reservation}
      );
    });
  });
};

The code for postReservationDetails() gets all the clients in the service worker’s scope, iterates over them, and posts a message to each. The message includes the details of the new reservation and names the action it asks the browser to take "update-reservation".

Finally, back in app.js, update the message event handler we added earlier to also handle these messages:

navigator.serviceWorker.addEventListener("message", function (event) {
  var data = event.data;
  if (data.action === "navigate") {
    window.location.href = data.url;
  } else if (data.action === "update-reservation") {
    updateReservationDisplay(data.reservation);
  }
});

This code adds another condition that looks for messages with an "update-reservation" action. When those are detected, it calls the updateReservationDisplay() function, passing it the new reservation details contained in the message. updateReservationDisplay() can be found in my-account.js, takes a reservation object, and updates the details of that reservation in the DOM.

In Chapter 7 we moved the reservation logic to the service worker. Now, with just a few extra commands, we can communicate the results of those actions back to the page and update the display. The cycle is complete.

Summary

In this chapter, we explored how we can use postMessage() to communicate between the service worker and the windows it controls. We were able to enhance the UI of our app with updated reservation data from the sync event, and sync login status between different windows.

In Chapter 11 we will take what we have learned in this chapter and use it to further improve the user experience. For example, we will let our users know when the app has been cached for offline use by posting a message from the service worker’s install event to the page.

First, though, we will explore two of the most exciting new features of progressive web apps.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset