Building Modern Cross Browser Web Extensions: Background Scripts and Messaging (Part 4)
#webextensions#typescript#react#wxt
In the previous post, we explored how to work with content scripts and build isolated UIs for our extension. In this post, we will learn how to work with background scripts as well as about the communication process between the content scripts and the background scripts.
Background Scripts
Background scripts enable us to listen to browser events (such as opening/closing tabs, bookmarking a page, etc.) and use sensitive browser APIs (which cannot be accessed from content scripts directly) as long as the user has granted the necessary permissions. The term “background script” is used with Manifest V2, and with Manifest V3, the term “service worker” is used. In our project, we have a src/entrypoints/background.ts
file which is our background script. WXT automatically handles the registration of the background script in the manifest file as per Manifest V2 or Manifest V3. More information about background scripts can be found in MDN Web Docs and Chrome Extension Docs.
Listening to Browser Events
Recall from previous posts that we have a browser
global available via the auto imports that WXT provides, and that we can use the same for both Chrome and Firefox. Here is how we can listen to the onInstalled
event and perform some action when the extension is installed:
export default defineBackground(() => {
browser.runtime.onInstalled.addListener(() => {
browser.tabs.create({ url: "https://www.google.com" });
console.log("onInstalled");
});
});
This will open a new tab with google.com when the extension is installed. The log message will be visible in the service worker’s console. Similar other events such as onStartup
, onInstalled
, onMessage
, etc are available in browser.runtime
.
There is an extensive list of events provided by various APIs such as tabs, bookmarks etc. which can be used in background scripts.
Communication between Content Scripts and Background Scripts
Communication is one of the most crucial and complex component of web extensions. Errors in communication logic are easy to introduce and difficult to debug. Hence, having a good understanding of communication fundamentals is essential.
To communicate between content scripts and background scripts, we can use the messaging APIs provided by the browser. Using these APIs, we can send one-time messages, or use long-lived connections for continuous communication. Bidirectional communication is possible, so the background script can send messages to the content script and vice versa. Let’s try to send a message from the content script to the background script and log the message in the background script:
export default defineContentScript({
matches: ["<all_urls>"],
// The main function of the content script can be async
async main(ctx) {
const res = await browser.runtime.sendMessage("testMessage");
console.log(res);
},
});
and in the background script:
export default defineBackground(() => {
// The listener function passed to addListener CANNOT be async
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message === "testMessage") {
console.log("got message from test.content.ts");
sendResponse("testResponse");
}
});
});
Few important points to note here:
The
browser.runtime.sendMessage
function is used to send a message from the content script to the background script. When sending from the background script to the content script, we need to usebrowser.tabs.sendMessage
, which also requires thetabId
of the target tab.The
browser.runtime.onMessage.addListener
function is used to listen to messages in the background script, same can be used in content scripts as well.Although we haven’t used the
sender
parameter in the listener function, it can be used to get information about the tab that sent the message. Note that thebrowser.runtime.onMessage
event only receives messages from our own extension or its content scripts. By default, other extensions can send messages to our extension, but web pages cannot. To receive messages from web pages, we need to explicitly allow external sources in our manifest file by specifying theexternally_connectable
property. In such cases, we can use thebrowser.runtime.onMessageExternal
event to receive messages from external sources, and the sender parameter will contain the URL of the page or the ID of the extension sending the message, which can be used to verify the authenticity of the message.The
sendResponse
function here is used to send a synchronous response back to the content script. To send an asynchronous response withsendResponse
, we must return a true from the listener function. We cannot use an asynchronous listener function withaddListener
. For example:
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message === "testMessage") {
console.log("got message from test.content.ts, sending async response");
setTimeout(() => {
sendResponse("testResponse async");
}, 1000);
return true;
}
});
This way the message channel will be kept open until the response is sent back to the content script using sendResponse
. If we have multiple listeners for same event, only the first one to call sendResponse
will send the response, and rest will be ignored. Consider the following code:
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message === "testMessage") {
console.log("got message from test.content.ts, sending async response");
setTimeout(() => {
sendResponse("testResponse async");
}, 1000);
return true;
}
});
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message === "testMessage") {
console.log("A second listener for the same message");
setTimeout(() => {
sendResponse("This will be sent first, the other listener will be ignored");
}, 500);
return true;
}
});
In the above example, the response from the second listener will be sent first, and the response from the first listener will be ignored as both are listening for the same message.
For listening to multiple messages, a cleaner way would be to use a switch case:
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
switch (message) {
case "message_1":
console.log("got message_1");
setTimeout(() => {
sendResponse("response_1");
}, 1000);
return true;
case "message_2":
console.log("got message_2");
setTimeout(() => {
sendResponse("response_2");
}, 1000);
return true;
//...more cases
// on any unknown message
default:
console.log("got unknown message:", message);
//No async response here, so no need to return true
sendResponse("unknown message");
return false;
}
});
You can test this by sending different messages from the content script and checking the console logs in both the consoles.
Using sensitive browser APIs in Background Scripts
Let’s say we want to access the user’s browsing history in our extension. This is a sensitive API that requires the history
permission in the manifest file. We cannot access the history
API directly in our content scripts, so we will use messaging as discussed previously. We can add the history
permission to the manifest section in our wxt.config.ts
file as:
import { defineConfig } from "wxt";
// See https://wxt.dev/api/config.html
export default defineConfig({
srcDir: "src",
extensionApi: "chrome",
modules: ["@wxt-dev/module-react"],
manifest: {
name: "Command Palette",
description: "A command palette to quickly perform actions",
permissions: ["history"],
},
});
Let’s send a message from the content script to the background script to get the history and log it on response:
// The HistoryItem type from chrome namespace should work for both Chrome and Firefox
type HistoryItem = chrome.history.HistoryItem;
export default defineContentScript({
matches: ["<all_urls>"],
async main(ctx) {
const history: HistoryItem[] = await browser.runtime.sendMessage("getHistory");
console.log("history", history);
},
});
and in the background script:
export default defineBackground(() => {
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message === "getHistory") {
console.log("getting history");
browser.history.search({ text: "" }).then((history) => {
sendResponse(history);
});
return true;
}
});
});
Our response should look something like this in the browser console:
[
{
"id": "2",
"lastVisitTime": 1735732800000.0,
"title": "DuckDuckGo - Your protection, our priority.",
"typedCount": 0,
"url": "https://duckduckgo.com/",
"visitCount": 2
}
]
We have used the HistoryItem
type, allowing us to easily access the available attributes of the history item. The extensive list of browser APIs, their attributes and their events is available on MDN Web Docs and Chrome Developer Docs.
Warning
Although most of the browser APIs are similar between the browsers, there are some minor differences, which you may encounter when developing browser specific features. For example, browser.tabs.onActivated
event provides an activeInfo
object, which has previousTabId
, tabId
and windowId
properties in Firefox1, but only tabId
and windowId
in Chrome2. As we are using @types/chrome
for types, Typescript will complain that previousTabId
is not available if we try to use it. We can add a check for the browser and a simple //@ts-ignore
above the line to ignore the error, when using such browser specific features. Another way is to use the Webextension Polyfill (which is by default disabled as of now), but it might further add complexity in managing types in the project. In most cases, you should try to use the widely available APIs only unless absolutely necessary.
A better way of messaging
The above approach of messaging using the browser APIs is quite error-prone and can get difficult to manage as the extension grows. We have very minimally used types in it, and the complexity of asynchronous operations only adds to the complication. WXT recommends using a wrapper around the built-in messaging APIs for these reasons. We will be using the @webext-core/messaging to simplify our messaging implementation and have a more robust and maintainable design.
First, we need to install the package:
pnpm add @webext-core/messaging
We will keep our types in a separate file src/lib/types.ts
:
export type HistoryItem = chrome.history.HistoryItem;
Our messaging setup will be in src/lib/messaging.ts
:
import { HistoryItem } from "@/lib/types";
import { defineExtensionMessaging } from "@webext-core/messaging";
interface ProtocolMap {
getHistory(data: { size: number }): Promise<HistoryItem[]>;
//or
//getHistory(data: { size: number }): HistoryItem[];
// Both are same, as all messages are async. We don't explicitly need to return a Promise, but it's good for clarity.
}
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
We have defined a ProtocolMap
interface which will contain all the messages that we want to pass between the content script and the background script. We have defined a getHistory
message which will take a size
parameter and return an array of HistoryItem
. We have also defined the sendMessage
and onMessage
functions which will be used for message passing instead of the built-in browser APIs.
Note
We can skip the data
key if we have only one parameter in the message.
interface ProtocolMap {
getHistory(size: number): HistoryItem[];
}
Protocol map functions expect only one parameter data
. If we have multiple parameters, we can pass them as an object with multiple keys:
interface ProtocolMap {
getHistory(data: { size: number, query: string }): HistoryItem[];
}
Even though we have single parameter size
we are using data
for consistency. More information can be found on webext-core’s documentation.
Now in our background script, we can use the onMessage
function to listen to messages:
import { onMessage } from "@/lib/messaging";
//Notice that the function passed to defineBackground is still not async
export default defineBackground(() => {
// The message handler function can be async
onMessage("getHistory", async (message) => {
const history = browser.history.search({
text: "",
maxResults: message.data.size,
});
return history;
});
});
We do not need to use sendResponse
or return true
here. The onMessage
function will automatically handle the response. The message handler function can be async, and we can use await
to wait for the response from the browser APIs if we need to modify it before returning, or just directly return the Promise. We also have additional attributes such as sender
and timestamp
available on the message
parameter.
In the content script, we can use the sendMessage
function to send messages:
import { sendMessage } from "@/lib/messaging";
export default defineContentScript({
matches: ["<all_urls>"],
async main(ctx) {
const history = await sendMessage("getHistory", { size: 4 });
console.log(history);
},
});
Instead of passing the size
parameter directly, note that we are passing it as an object (the data object) with a key size
. You should now be able to see the history logged in the browser console.
We now have a more robust and maintainable messaging system in place. We can easily add more messages to the ProtocolMap
interface and use them in our content and background scripts. Using this approach allows to catch errors early in development, as it checks for literal message names (e.g. getHistory
), making it easier to identify typing mistakes in message passing – for example, even gethistory
would not be recognized as valid message in this case.
We can further organize the code by creating helper functions in src/lib/helpers.ts
:
import { HistoryItem } from "@/lib/types";
import { sendMessage } from "@/lib/messaging";
export function fetchHistory(size: number): Promise<HistoryItem[]> {
try {
const history = sendMessage("getHistory", { size: size });
return history;
} catch (error) {
console.error("Error getting history", error);
return Promise.resolve([]);
}
}
and then use the helper functions in our content script:
import { fetchHistory } from "@/lib/helpers";
export default defineContentScript({
matches: ["<all_urls>"],
async main(ctx) {
const history = await fetchHistory(4);
console.log("history", history);
},
});
In summary, our process for messaging will be:
- Define the appropriate types in
src/lib/types.ts
- Define the messages in
src/lib/messaging.ts
- Add the message handlers in the background script using
onMessage
- Use the
sendMessage
function in the content script to send messages / use helper functions for more complex operations.
We can also send messages from the background script to the content script using the same messaging system, the only difference is that we have to pass the tabId
as the third argument:
sendMessage("getHistory", { size: 4 }, tabId);
This will send the message to the content script of the tab with the given tabId
. We would also need to use onMessage
in the content script to listen to messages from the background script.
Using Proxy Service: an alternative to messaging
If majority of your extension’s logic needs to be run in the background script, you can look into @webext-core/proxy-service, which allows you to register a service in background script and call its methods directly from other contexts without any message passing. We will continue to use messaging in our extension for now, but you can explore this option if it fits your use case better.
In the next post, we will use the discussed concepts to build the core functionality of our extension, as well as explore storage and permissions in web extensions.
Share your feedback: [email protected]