ponyfoo.com

ServiceWorker, MessageChannel, & postMessage

Fix
A relevant ad will be displayed here soon. These ads help pay for my hosting.
Please consider disabling your ad blocker on Pony Foo. These ads help pay for my hosting.
You can support Pony Foo directly through Patreon or via PayPal.

Last week I wrote an article about a caching strategy for progressive networking that uses a cache first and then goes to the networking, sharing messages between web pages and a ServiceWorker to coordinate updates to cached content. Today I’ll describe the inner workings of the swivel library that’s used to simplify message passing for ServiceWorkers.

As it turns out, there’s many different ways in which you can share messages between a ServiceWorker and the web pages it controls.

  • A client may want to message a ServiceWorker, asking something or notifying about something – this is “unicast” (1-to-1)
  • A ServiceWorker may want to send a reply to a client – unicast
  • A ServiceWorker may want to send an update to every client under its control – a “broadcast” (1-to-many) message
  • A ServiceWorker may want to send an update to the client where a fetch request originated – unicast

Messaging the ServiceWorker

In order to message a ServiceWorker, you can use a piece of code like the following snippet.

worker.postMessage(data);

By default you probably want to use navigator.serviceWorker.controller, the active ServiceWorker instance for the current page that controls its requests, as the worker. I haven’t personally found a need yet for talking to workers other than the controller, which is why that’s the default worker you talk to in swivel. That being said, there are use cases for them, which is why there’s swivel.at(worker), but let’s go back to our focus area.

The worker can set up an event handler that deals with the data payload we posted from the web page.

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

As soon as you have different types of messages you want to send to workers, this becomes an issue. You’ll have to turn to a convention for message routing. A common convention is to define an envelope for your messages that has a command property in it, so now you’d send messages like the following.

worker.postMessage({ command: 'deleteCache', key: key });

Then the worker needs to be updated with a command handling router, a bunch of if will do in the simpler case. You can see how the code starts becoming diluted with implementation details.

self.addEventListener('message', function handler (event) {
  if (event.data.command === 'deleteCache') {
    caches.delete(event.data.key);
  }
});

Getting Replies from the ServiceWorker

If you want the worker to be able to reply to the message things get very ugly, very quickly, mostly because browsers haven’t implemented the final API quite yet.
For the time being, on the client-side you’ll need to set up a MessageChannel, bind a listener to port1 and pass along port2 when posting the message.

var messageChannel = new MessageChannel();
messageChannel.port1.addEventListener('message', replyHandler);
worker.postMessage(data, [messageChannel.port2]);
function replyHandler (event) {
  console.log(event.data); // this comes from the ServiceWorker
}

On the ServiceWorker side, it’s not that fun either. You have to reference port2 of the messageChannel using event.ports[0], as its the port in position zero of the ports passed along with the message.

self.addEventListener('message', function handler (event) {
  event.ports[0].postMessage(data); // handle this using the replyHandler shown earlier
});

Browsers will eventually have an event.source alternative to event.ports[0] on the ServiceWorker side that doesn’t need us to do any of the MessageChannel stuff on the pages. Unfortunately that’s not here yet, and so we have to resort to MessageChannel for now.

Broadcasting from a ServiceWorker to every client

This one is straightforward, but it’s also pretty different from the two situations we’ve just talked about. And, quite honestly, very verbose. Of course, ServiceWorker is all about expressiveness and being able to cater for multiple different use cases, and we’ve all seen the veiled evil in seemingly simple but cleverly complicated interfaces like the AppCache manifest.

That being said, having to type this out sucks. Libraries will definitely help abstract the pain away, while ServiceWorker can go on being just about the most powerful feature the modern web has to offer.

self.clients.matchAll().then(all => all.map(client => client.postMessage(data)));

To listen to these messages from a ServiceWorker, you can register an event listener like below in your web page. Note how the listener is added on navigator.serviceWorker and not on an specific worker (like navigator.serviceWorker.controller).

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

You probably want to keep a reference to the worker you’re interested in, and then filter on the message listener by event.source. That way you’ll avoid messages broadcasted from workers other than the one you’re expecting messages from.

navigator.serviceWorker.addEventListener('message', function handler (event) {
  if (event.source !== worker) {
    return;
  }
  console.log(event.data);
});

Dual Channeling fetch Requests

Sending updates to the origin of fetch requests is, perhaps, the most interesting use case for communication between ServiceWorker and web pages. Sending an update to the origin of a fetch request is what makes using the cache immediately and then sending an update as soon as possible so effective.

self.on('fetch', function handler (event) {
  // reply to client here
});

Eventually, event will have a clientId property identifying the client where the request came from. We could then use code like below to send a message to the client using client.postMessage. No browser implements event.clientId yet.

self.on('fetch', function handler (event) {
  event.respondWith(caches.match(event.request));
  fetch(event.request).then(response => response.json()).then(function (data) {
    self.clients.match(event.clientId).then(client => client.postMessage(data));
  });
});

You could still use the broadcasting mechanism discussed earlier, but it’d go to every client and not just the origin of the fetch event. A better alternative, for now, may be to issue another request from the page after load, maybe adding a custom header indicating that we should force a fetch on the ServiceWorker side.

Swivel Makes Your Life Easier

If you’d prefer to avoid all of this frivolous knowledge, you may like to swivel like Ron Swanson.

Ron Swanson swivelling to avoid human contact
Ron Swanson swivelling to avoid human contact

Swivelling has a number of benefits. The API is unified under an event emitter style. On the client, you can send messages to the ServiceWorker like below.

swivel.emit('remove-cache', 'v1');

Then on the worker, you could just listen for that with a matching API.

swivel.on('remove-cache', (context, key) => caches.delete(key));

If the worker needs to reply, it can use context.reply.

swivel.on('remove-cache', function handler (context, key) {
  caches.delete(key).then(function () {
      context.reply('removed-cache', 'ok', 'whatever');
  });
});

The client that sent the message then gets to handle the reply as long as they’re listening for the removed-cache event. Note how the API here is identical to the API for listening on events on the ServiceWorker.

swivel.on('removed-cache', function handler (context, success, metadata) {
  // do something else
});

When ServiceWorker has important announcements, it can broadcast to every client.

swivel.broadcast('announcement', { super: '!important' });

Broadcasted messages can be listened using the exact same swivel.on API in the client-side. In addition to swivel.on, there’s also swivel.once that binds one time event handlers, and swivel.off to remove event handlers.

Lastly, as we mentioned earlier you can interact with different ServiceWorker instances. Instead of using the swivel.* API directly, you could use swivel.at(worker).* instead. Messages sent from a client using swivel.at(worker).emit will only go to worker, and messages broadcasted by worker will only be available to listeners registered using swivel.at(worker).on. This kind of scoping helps prevent accidents when phasing out old workers and when installing new ones.

You can check out the full documentation on GitHub.

ServiceWorker, MessageChannel, & postMessage. Oh, my!

Liked the article? Subscribe below to get an email when new articles come out! Also, follow @ponyfoo on Twitter and @ponyfoo on Facebook.
One-click unsubscribe, anytime. Learn more.

Comments