DEV Community

Cover image for Building Chrome Extensions: Communicating Between Scripts

Posted on • Updated on • Originally published at Medium

Building Chrome Extensions: Communicating Between Scripts

A while ago, I set out to build my first Chrome extension. Having recently gotten into the world of web development and getting my hands dirty by building a React project, I felt I had the tools necessary to take on this new challenge.
While I wasn't completely wrong to think this, it wouldn't be the whole truth to say I didn't have to rethink the way I approached designing my project.
I realized this pretty early on into development. You see, when developing any sort of app, as our projects get bigger, we'll tend to inevitably break it up into separate classes, functions, and eventually scripts. Nothing forces us to do this, but unless you want to end up with a remake of 1958's The Blob, it would be smart to do so.

The Naive Approach to Script Interoperability

In my case, the extension needed to do the following: Whenever a user makes any changes in the text field, its content needs to be parsed and displayed accordingly as entries in the table to its right. Then, as soon as the "Log entries!" button is clicked, these parsed entries are to be used to invoke changes on the web page.

Alt Text

Demo of the extension pop-up (web page manipulation not shown)

To that end, I broke up my code's functionality into the following scripts:

  • popup.js: Contains the behavior of the pop-up and its components. For example, what happens when text is inserted into the text field or when a button is pressed.
  • parser.js: Contains functionality to parse text following certain rules and returns the parsed result in a specific format.
  • crawler.js: Contains functionality that utilizes data to crawl a web page in search of specific elements and make certain modifications.

There's an obvious interdependence here. crawler.js needs data presented to it in a certain format in order to be able to successfully crawl and modify the web page. This data is provided by parser.js, which in turn receives its input from the pop-up's text field, managed by popup.js.

Alt Text
Interaction diagram - Ant assets by freepik

If, like me, you were spoiled by the simplicity of using ES6 modules in React, your first notion might be to say, "Well, no problem. I'll just export the relevant functions in parser.js and crawler.js and import them in popup.js."

However, my then-vanilla ES5 JavaScript codebase had other ideas, and by the time I emerged bruised and bloodied from my attempt to integrate ES6 features into my project, I had already discovered the proper way of getting my extension's scripts to talk to each other.

Fun fact: On the road to ES6 integration, I did eventually make the leap to Parcel (which I can highly recommend to anyone getting started using bundlers after a brief incident with Webpack left me questioning my life choices). The use of a bundler was motivated partly by the need to easily reference external libraries.

Since Parcel comes preconfigured with Babel, I was then also able to use ES6 features such as import/export, which did enable that more familiar way of working with different files. Nevertheless, that isn't the way communication is intended in Chrome extensions, as we'll see shortly.

Content and Background Scripts

A Chrome extension will typically consist of various cohesive parts or components, each with a different set of responsibilities. In order for all these components to work together, they communicate via messaging.

In our example, crawler.js needs to interact with the web page and is thus declared as a so-called content script. Content scripts are those that need to be able to perform actions on web pages, such as DOM manipulations.

On the other hand, parser.js doesn't need this, but it still needs to receive data from popup.js and send it back. Thus, we'll declare it as a background script.

A background script, as the name implies, runs in the background. Its roles include listening and reacting to browser events (e.g. closing a tab, perform actions when the extension is (un-)installed), as well as sending and receiving messages.

The declaration of content and background scripts is done in the extension's manifest.json.

Part of the extension's manifest.json showing background and content script declaration

Message Passing 101

Now we know enough to finally get to the nitty-gritty.

popup.js, being the communication initiator here, will need to send out two messages. One whenever the text field is changed and another when the button is clicked. Depending on who the recipient is, it does this using one of two ways. If the recipient is a content script, chrome.tabs.sendMessage() is used. Otherwise, it's chrome.runtime.sendMessage().

Non-content script communication

Let's start with the second case. Here's an example of what that might look like in popup.js:

Example of a message sent from popup.js to parser.js

Here, we're assuming this piece of code gets executed in popup.js whenever a change happens in the text field. As you can see, we've passed runtime.sendMessage() two parameters: a required object and an optional callback. What the object should contain is entirely up to you, but in my case, I've included two properties. The first, msg, contains a string identifier that is checked by the receiving end to determine how to handle the request. The second property, data, simply contains the new content of the text field following the change.

The callback function passed as the second argument to runtime.sendMessage() must have a single parameter. This function handles the response sent by the recipient of this message.

Note: The intended recipient of this message is parser.js. However, as we'll see shortly, any background script listening for onMessage events will receive it. This is another reason why it's useful to have a property such as msg in the passed object. It acts as an identifier so that recipients can determine whether a message is intended for them.

Content script communication

As mentioned before, when the recipient is a content script, we use tabs.sendMessage(). Here's what that could look like in popup.js:

Example of a message sent from popup.js to crawler.js

You'll notice this time around that we don't send the message straight away. With tabs.sendMessage(), we need to know which tab to send the message to. To do that, we first call tabs.query(), which retrieves all tabs that match the properties specified in the first argument. Since my extension pop-up only activates when I'm on a specific URL, I can simply get the active tab in the current window and be sure that it's the one I need.

💡 Hint: To retrieve all tabs, pass an empty object as the first argument.

The retrieved tabs are passed to the callback specified in the second argument. This is where we send our actual message, which should now look familiar. The only difference is that with tabs.sendMessage(), we need to pass the ID of the relevant tab. The rest follows the same structure as before.

Receiving and responding to messages

On the receiving end, it's quite straightforward. There, we use chrome.runtime.onMessage.addListener(). Essentially, what it does is add a listener to the onMessage event, which gets fired whenever a message is sent using either of the sendMessage() variations we've seen.

This method takes a callback function as its single argument, which gets called when the event is fired (i.e. a message is received). That callback, in turn, takes three arguments: the content of the message, its sender, and a function that is called if a response is to be sent back. This function takes a single argument of type object. That was verbose. Let's look at some code.

Example of how to receive a message in parser.js. Notice how the msg field in the request is being used as an identifier for the message

Bonus: Communication Between Content Scripts

So far, so good. But what if we had not just one content script, as was the case here with crawler.js, but two that wanted to communicate? To continue with our running example, say we broke up crawler.js into two separate content scripts: finder.js and filler.js. As the names imply, the former searches for certain elements on the webpage, while the latter fills those elements with content.

finder.js wants to be able to send the elements it finds to filler.js. "Well, no big deal," I hear you saying. We'll just use tabs.sendMessage() and onMessage.addListener() like before. As much as I hate to be the bearer of bad news, not quite. As it turns out, content scripts can't communicate directly. This actually had me scratching my head for a while. Fortunately, the solution is simple.

Fun fact: In case you're wondering why I even ran into this problem since I only have one content script, at some point, I unnecessarily had popup.js registered as a content script too and consequently its messages weren't reaching crawler.js using the direct path of communication. I've since removed this error, but the lesson learned remains.

All we need to do is have a background script act as a middleman in this exchange. This then looks as follows. Don't be intimidated by the size. I've essentially jammed code from three scripts into one gist for display purposes.

Example showing what communication between two content scripts could look like. Note that each code snippet would be contained in the respective actor's script

Essentially, there's nothing new here except a slight logistical change. Instead of direct point-to-point communication, we're using a background script to relay messages between the communicating parties (i.e. the content scripts).

One thing to note here is that we're returning true in the background script's addListener(). Without going too much into detail, this keeps the communication channel at the background script open to allow for filler.js's response to make it through to finder.js. For more on that, take a look at the description provided in Chrome's documentation for the sendResponse parameter of runtime.onMessage.addListener().


Thanks for sticking around! Chrome extensions can be quite idiosyncratic and there's often not much to go on on the internet when you're stuck. So I hope you found some of this useful.

I'd be happy to hear your thoughts and answer any questions you may have.


Hungry for more? You might also like:

Originally published on Medium

Top comments (0)