Cross-Tab Communication in JavaScript using a SharedWorker

In this lesson we'll be going over how to do cross-tab communication using a SharedWorker. SharedWorkers are Web Workers that are sharable across browser-instances (tabs, windows, etc).

Published
Nov 28, 20
Duration
21m 18s

Developer, dog lover, and burrito eater. Currently teaching AdonisJS, a fully featured NodeJS framework, and running Adocasts where I post new lessons weekly. Professionally, I work with JavaScript, .Net C#, and SQL Server.

Adocasts

Burlington, KY

In the last lesson we discussed how to approach building a cross-tab messaging application using LocalStorage. Today we'll be doing the same, however, this time we'll be using a SharedWorker. SharedWorkers are Web Workers that are sharable across browser instances (tabs, windows, etc). Because they're Web Workers this means all the code within our worker will run on a different thread in the browser from our application code. So, if you have heavy logic to run in your cross-tab communications this will be a great approach.

We can dispatch messages to and from our workers using a method called postMessage(message), which accepts the message we want to post. Inversely, we can listen for messages using an event called onmessage. This event will be passed an event argument that contains the data we sent via postMessage.

What We'll Be Building

Interested in what we'll be building? You can check it out here. You'll be able to post a message from one tab and receive it in another. Messages sent from one tab will appear to the right and will be blue. Messages received from other tabs will be gray and to the left, similar to texting.

Following Along?

If you'd like to follow along, I have a repository setup where we'll be starting in this lesson. It's got the project structure and boilerplate markup for our page so we don't have to waste time adding that in. So, go ahead and clone it down, install the dependency (serve), and run npm run serve to get the dev server started up.

https://github.com/jagr-co/cross-tab-communication/tree/00_StartHere

In this lesson, we'll be working within pages -> sharedworker -> index.html.

Cross-Tab Communication

Let's start with why you're here, cross-tab communication using a SharedWorker. To define a SharedWorker we need to point the SharedWorker constructor to a script containing our worker code. So, let's start by creating that script so we can instantiate our SharedWorker.

Let's create a new file at /pages/scripts/sharedworker.js.

Inside our worker script, we'll need a method called onconnect which, when called, is passed an event. Our onconnect function will be called anytime a new browser instance connects to our worker.

// pages/scripts/sharedworker.js

onconnect = function(e) {
}

Inside our event resides a ports array containing an array of the ports that trigger the onconnect call. We'll want to grab the first port from this array. This port is what we'll use to listen to and post messages to the port's connected browser instance.

// pages/scripts/sharedworker.js

onconnect = function(e) {
  const port = e.ports[0];
}

Sending & Receiving Data

In order to accept a message, we'll need to listen for messages on this port using the onmessage event listener. When our onmessage listener is called it'll be passed an event and this event will contain our message's data.

If the purpose of your worker isn't to just consume data, you'll likely want to store/manipulate the data and then post it back out for consumption by your application. To do this, we can call postMessage off our port.

// pages/scripts/sharedworker.js

const messages = [];

onconnect = function(e) {
  const port = e.ports[0];

  port.onmessage = function(event) {
    const message = event.data;

    // store/manipulate your message
    messages.push(message);

    // post message back out to your application
    port.postMessage(messages);
  }
}

Connecting To Multiple Browser Instances

Every time we connect to a port, you can think of this as connecting to a browser instance. The same applies when dispatching messages to ports. So, in our above example, we're really only posting our message out to the browser instance where the message originated from. In order to change this, we can store all our connected browser instances (ports) in our worker. Then whenever we send out a new message we'll post this message across all stored browser instances.

// pages/scripts/sharedworker.js

const browserInstances = [];
const messages = [];

onconnect = function(e) {
  const port = e.ports[0];

  // store the newly connected browser instance
  browserInstances.push(port);

  port.onmessage = function(event) {
    const message = event.data;

    // store/manipulate your message
    messages.push(message);

    // post message back out to your application
    browserInstances.forEach(instance => {
      instance.postMessage(message);
    });
  }
}

Connecting To Our Worker

Now that our example worker is all setup, we’re ready to connect to our worker from our application.

// pages/sharedworker/index.html

const worker = new SharedWorker('scripts/sharedworker.js');

Sending & Receiving Data

Sending data to and receiving data from our worker works the same as it does inside our worker. We’ll listen for messages using onmessage and we’ll send data using postMessage.

// pages/sharedworker/index.html

const worker = new SharedWorker('scripts/sharedworker.js');

worker.port.onmessage = function(event) {
  const message = event.data;
}

worker.port.postMessage('it lives!');

Now that we’re familiar with how to perform cross-tab communication using SharedWorkers, we can move on to creating our application.

Setting Up Our SharedWorker

To start with, let's get our SharedWorker script set up and ready to go. In order to allow for messages to have different purposes, we'll define some mutations our worker will accept. These mutations will define the purpose of a received message.

// pages/scripts/sharedworker.js

const mutations = {
  ADD: 'ADD',
  SET: 'SET'
}

We'll use the 'ADD' mutation when we're adding a single record and the 'SET' mutation when we're setting the entire collection.

Next, we'll want to adjust our onmessage event handler to work with these different mutations.

// pages/scripts/sharedworker.js

port.onmessage = function({ data }) {
  switch(data.mutation) {
    case mutations.ADD:
      messages = [...messages, data.value];
      break;
    case mutations.SET:
      messages = data.value;
      break;
  }

  browserInstances.forEach(instance => {
    instance.postMessage(message);
  });
}

Finally, let's make sure all the data we're emitting from our worker is in the same format, ie:

{
  mutation: string, // the mutation that was run
  value: Array,     // the values changes
  messages: Array   // the new messages collection
}

When we receive an 'ADD' mutation our value is going to be our message object. So, in order to get everything emitting in the same format, we'll want to wrap this value in an array before posting our messages.

// pages/scripts/sharedworker.js

if (!Array.isArray(data.value)) {
  data.value = [data.value];
}

Reviewing Our SharedWorker

With that, we are done with our SharedWorker logic. Here's what your /pages/scripts/sharedworker.js file should look like:

// pages/scripts/sharedworker.js

const mutations = {
  ADD: 'ADD',
  SET: 'SET'
}

const browserInstances = [];
const messages = [];

onconnect = function(e) {
  const port = e.ports[0];

  browserInstances.push(port);

  port.onmessage = function(event) {
    switch(data.mutation) {
      case mutations.ADD:
        messages = [...messages, data.value];
        break;
      case mutations.SET:
        messages = data.value;
        break;
    }

    if (!Array.isArray(data.value)) {
      data.value = [data.value];
    }

    browserInstances.forEach(instance => {
      instance.postMessage(message);
    });
  }
}

Binding Our Forms

On our page, we have two forms, one for sending a message and another for clearing all messages. To start out, let's get submit handlers bound to these two forms along with their handler functions. We're also going to have a few global variables to this functionality holding some elements and storage keys.

// pages/sharedworker/index.html

const messagesEl = document.getElementById('messages');
const messagesPlaceholderEl = document.getElementById('messagesPlaceholder');
const messagesKey = 'messages';
const senderIdKey = 'senderId';
const mutations = {
  ADD: 'ADD',
  SET: 'SET'
}

const worker = new SharedWorker('scripts/sharedworker.js');
worker.port.onmessage = handleNewMessage

document.forms.sendMessageForm.addEventListener('submit', handleSendMessage);
document.forms.clearMessagesForm.addEventListener('submit', handleClearMessages);

function handleSendMessage(event) {
  event.preventDefault();
}

function handleClearMessages(event) {
  if (event) event.preventDefault();
}

function handleNewMessage({ data }) {

}

In addition to this, we'll need one more global variable called tabSenderId, this will be a unique ID we'll use to determine if a message was sent from the tab we're currently on. To start, let's add a utility method to the bottom of our script element to generate a unique ID. Let's also add our tabSenderId with our other global variables.

// pages/sharedworker/index.html

const tabSenderId = getUniqueId();

/***/

function getUniqueId() {
  return '_' + Math.random().toString(36).substr(2, 9);
}

Now, we're also going to want to persist the generated tabSenderId so that when we refresh our browser tabs this remains the same. We'll use SessionStorage for this, which isn't shared across tabs. Let's create a helper function called getSenderId that will attempt to find a SessionStorage item with the key of our senderIdKey. If an item is found we'll use that value as our tabSenderId, otherwise we'll use our getUniqueId utility function to create a new unique ID that we'll then store in SessionStorage under the key of our senderIdKey.

// pages/sharedworker/index.html

const tabSenderId = getSenderId();

/***/

function getSenderId() {
  let senderId = sessionStorage.getItem(senderIdKey) || getUniqueId();
  sessionStorage.setItem(senderIdKey, senderId);
  return senderId;
}

function getUniqueId() {
  return '_' + Math.random().toString(36).substr(2, 9);
}

Adding A Few Utilities

Before we go any further, let's add a few utilities that will help keep things concise from here out.

// pages/sharedworker/index.html

function hideEl(el) {
  el.classList.add('hidden');
}

function showEl(el) {
  el.classList.remove('hidden');
}

function parseArray(item) {
  if (!item || !item.length) return [];
  return !Array.isArray(item) ? JSON.parse(item) : item;
}

function setStringifiedStorageItem(key, value) {
  localStorage.setItem(key, value ? JSON.stringify(value) : value);
}

function getParsedStorageItem(storageKey) {
  let stringValue = localStorage.getItem(storageKey);
  return stringValue ? JSON.parse(stringValue) : stringValue;
}

hideEl and showEl are pretty straightforward, but let's run through the rest.

  • parseArray will gracefully attempt to parse a stringified array, if needed

  • setStringifiedStorageItem will store an item in LocalStorage, stringifying it beforehand.

  • getParsedStorageItem will get an item from LocalStorage, parsing its contents beforehand.

Getting, Making, and Adding Message Helpers

Now that we have our storage and form listeners set up, we can move into adding some helpers to keep our message logic clear and concise.

Getting Messages

// pages/sharedworker/index.html

function getMessages() {
  const messages = getParsedStorageItem(messagesKey);
  return !Array.isArray(messages) ? [] : messages;
}

This will get and return an array of our messages, or an empty array if we have none.

Adding A Message

// pages/sharedworker/index.html

function addMessage(message) {
  let messages = getMessages();
  messages.push(message);
  return messages;
}

We'll call this when we go to add a message

Making A Message

// pages/sharedworker/index.html

function makeMessage(message) {
  return {
    id: getUniqueId(),
    senderId: tabSenderId,
    message,
  };
}

And this is when we want to make a new message. We'll have a unique ID per message, our tab's sender ID, and the message.

// pages/sharedworker/index.html

function makeMessageEl({ message, id, senderId }) {
  const li = document.createElement('li');
  const baseClassName = 'px-3 py-1 rounded';

  li.id = id;
  li.textContent = message;
  li.className =
    senderId == tabSenderId
      ? `${baseClassName} bg-blue-600 text-white place-self-end`
      : `${baseClassName} bg-gray-700 text-white place-self-start`;

  return li;
}

We'll use this to create a message LI element to display our messages. If the message's senderId equals our tabSenderId, the message will be blue and aligned right. Otherwise, it'll be gray and aligned left.

Displaying Messages

// pages/sharedworker/index.html

function displayMessages(messages) {
  messages.forEach((msg) => messagesEl.appendChild(makeMessageEl(msg)));
}

We'll use this to append our created message LI's from makeMessageEl to the DOM under our messages list.

Post Message

// pages/sharedworker/index.html

function postMessage(mutation, value) {
  worker.port.postMessage({ mutation, value });
}

We'll create a helper to send our messages to our SharedWorker. mutation will be the action we're performing (ADD, SET). 'ADD' will be when we're adding a single message and 'SET' will be used to set our entire message collection.

Clearing All Messages

Good news, we're done with helpers and setup! Let's now add our logic for clearing all our messages.

so, if you recall, earlier we created a handleClearMessages function that took an event. Let's adjust that method to the one below.

// pages/sharedworker/index.html

function handleClearMessages(event) {
  if (event) event.preventDefault();

  postMessage(mutations.SET, []);
}

To make this function callable from our other methods, we'll check to see if we were given an event. If we were all we want to do is preventDefault.

Then we'll use our postMessage utility to clear out our messages collection in our SharedWorker. Our SharedWorker will then post back to use our new messages collection and our handleNewMessage function will take care of the rest.

Handling New Messages

Now we're ready for our logic to handle new messages sent from the current and other tabs. Let's edit the handleNewMessage method we added earlier to the below.

// pages/sharedworker/index.html

function handleNewMessage({ data }) {
  const { mutation, value, messages } = data;

  setStringifiedStorageItem(messagesKey, messages);

  messages.length
    ? hideEl(messagesPlaceholderEl)
    : showEl(messagesPlaceholderEl);

  switch(mutation) {
    case mutations.ADD:
      displayMessages(value);
      break;
    case mutations.SET:
      messagesEl.innerHTML = '';
      displayMessages(value);
      break;
  }
}

Here we're spreading out our { mutation, value, messages } properties from our data object so we can directly reference them.

Next, we update our LocalStorage (used only to persist messages on reload).

Then we determine whether to show or hide our placeholder element. If we have messages we'll hide it, otherwise, we'll show it.

Finally, we'll use a switch to determine which mutation was run and we can individually handle those as needed. When adding all we'll need to do is display the new message. When setting we'll need to clear out our ordered list's innerHTML before we can display the new messages.

Sending A New Message

Finally, we're ready to add the ability to send a new message. Let's adjust the handleSendMessage function we added earlier to the below.

// pages/sharedworker/index.html

function handleSendMessage(event) {
  event.preventDefault();

  const value = event.target.message.value;
  const message = makeMessage(value);

  postMessage(mutations.ADD, message);

  event.target.reset();
  event.target.message.focus();
}

We grab the value from the message form input, make a new message matching our { id, senderId, message } object format. Next, we'll post our new message to our SharedWorker, calling the 'ADD' mutation. Finally, we'll reset our form and refocus the message input.

Let's Test!

We can now finally test what we have! So open up two browser tabs pointing to the /sharedworker page. If you've had these pages open previously; you'll need to close one tab, refresh the single tab remaining open, then open the second tab back up in order to refresh the SharedWorker. Now, send a message and you should see that message gets added both for the tab you're on and the inactive tab. Huzzah!

Now refresh the page and you'll see why we aren't quite done. Our messages are saved in LocalStorage, but we aren't doing anything to actually populate them on load.

Populating Messages On Load

To do this, let's add one more function to our page called loadMessages.

// pages/sharedworker/index.html

function loadMessages() {
  const messages = getMessages();

  postMessage(mutations.SET, messages);
}

This gets our messages from LocalStorage, then makes use of our 'SET' mutation to set the entire value of our messages within our worker. Our handleNewMessage event listener will take care of the rest.

Finally, let's call our loadMessages function.

// pages/sharedworker/index.html

loadMessages();

Now refresh the page again and you should see your messages populating on load.

Summary

While we did a lot in this lesson, the actual code needed to communicate across tabs using our SharedWorker was very minimal. All we needed to do was create our worker and add an onmessage event listener and call postMessage anytime we have a new message to send.

In the next lesson, we'll be covering how to do the exact same thing using the BroadcastChannel API.

Join The Discussion! (2 Comments)

Please sign in or sign up for free to join in on the dicussion.

  1. Anonymous (ManateeChrystel384)
    Commented 1 year ago

    Beautiful

    1

    Please sign in or sign up for free to reply

    1. Commented 1 year ago

      Thank you!! 😊

      0

      Please sign in or sign up for free to reply