Communication patterns for browser extension development
npm install @jam.dev/extension-messagingThis package provides a set of common communication patterns for building browser extensions.
Right now, this package supports the chrome API as the underlying transport layer. Additional APIs/browser vendors will be added over time.
This package provides two classes:
- EventBus for event general broadcasting
- MessageBroker for request/response style communication between two extension components
#### Event Bus
``ts
import {EventBus, Component} from "@jam.dev/extension-messaging";
type MyEvents = {
Hello: {world: boolean};
};
const bus = new EventBus
await bus.emit({
name: "Hello",
data: { world: true },
});
// some other component can listen
bus.on("Hello", (payload) => {
console.log(payload.data) // {world: true}
});
`
#### Message Broker
`ts
import {MessageBroker, Component} from "@jam.dev/extension-messaging";
type MyMessages = {
Ping: {
request: boolean;
response: boolean;
};
};
const broker = new MessageBroker
component: Component.ContentScript,
// in case you have multiple scripts injected, this
// gives the ability to target individual scripts
context: "my-script-context",
});
const response = await broker.send({
name: "Ping",
data: true,
target: Component.Main,
});
// On the Component.Main instance:`
broker.on("Ping", (payload) => {
console.log(payload.data); // true
return true;
});
is a one-way event broadcast with many potential receivers. An event is emitted by a sender, and N receivers may listen for that event. There is _no_ acknowledgement to the sender that listeners received the message.MessageBroker is a two-way channel between a single sender and a single receiver. A message is sent with a target receiver, and that receiver is expected to return a response. The sender _will_ know if the target received the message via the acknowlegement of receiving a response. Useful for situations where the callee needs receipt of the message, or for request/response style patterns where a callee needs data from another component.$3
The
chrome API has a few different ways to send messages: -
chrome.runtime.sendMessage — Used to send messages to "background" components. For example, the background.js or worker.js script sending a message to your extension's popup, or a content script sending a message back to your background.js or worker.js instance
- chrome.tabs.sendMessage — Used to send messages to content scripts on a specific tab
- port.postMessage — If using chrome.runtime.connect() to generate a long-lived (and direct) communication channel between a tab/content-script and a background script, this API provides a common interface to send and receive messages.These APIs can start to get a little confusing/convoluted. For instance: if you are doing simple message passing between a content script and your background/worker script, the content script will need to listen for and send messages via
chrome.runtime (sendMessage/onMessage), and your background/worker script will need to send messages via chrome.tabs.sendMessage but listen for messages on chrome.runtime.onMessage (since the content script will send messages via chrome.runtime.sendMessage).This package abstracts these APIs (and the overhead of thinking about them) from your application logic by requiring you to define the operating environment of your class instances, via specifying a
component, and optionally a context. These components are:
- Component.Main [single instance] - your background/worker instance.
- Component.Popup [multiple instances] - your extension popup. There is (typically) only one of these, but certain user flows could cause multiple (multiple windows each with the popup open at the same time)
- Component.ContentScript [multiple instances] - any script injected into a tab (isolated world context).
- Component.HostScript [multiple instances] - any script injected directly into the tab page (not the isolated world context). There many
- Component.Foreground [multiple instances] - any extension component created via a new page (e.g., options.html) or via the Offscreen Documents APIFor the components that can have multiple instances, it's recommended to provide a
context parameter when instantiating classes to allow for more accurate handling of messages/events.$3
By providing a type mapping for events and messages when instantiating an
EventBus or MessageBroker, you will get type safety and auto-completion in your IDE.The
EventBus map is simple: keys are your event names, and the value is your payload.`ts
type MyEvents = {
EventOne: {
my: true;
payload: number;
};
}const bus = new EventBus({ component: Component.Main });
`The
MessageBroker map is similar: keys are your event names, and the value is keyed by request and response structures.`ts
type MyMessages = {
HelloWorld: {
request: { message: string };
response: boolean;
};
}const broker = new MessageBroker({ component: Component.Main });
`Event Bus
The event bus is a pattern that the browser-provided APIs closely resemble. The EventBus class in this package goes a bit further by broadcasting messages across multiple communication channels, to ensure that you don't have to think about whether an event emitted from one area of your extension has made it to another.Example: Your background/worker script emits an event that you want _all_ other extension components and tabs to receive.
Achieving this with the browser-provided APIs means you'd need to send that message with
chrome.runtime.sendMessage and chrome.tabs.sendMessage. And for chrome.tabs.sendMessage you would first need to query for all tabs and loop over them.Using the
EventBus, you simply bus.emit() and the class takes care of the rest. On the receiving end, you simply subscribe to the event via bus.on(). Emitting an event in your popup, and receiving it in a content script
`ts
// In your popup
const bus = new EventBus({ component: Component.Popup });await bus.emit({
name: "Ping",
data: true,
});
// In your content script
const bus = new EventBus({ component: Component.ContentScript });
bus.on("Ping", (payload) => {
console.log(payload.data); // true
})
`Note: This package currently assumes that you will have an instance of the
EventBus and/or MessageBroker in your background/worker (e.g., a Component.Main instance) for message/event routing to work correctly. For example: a message or event sent from a popup or foreground instance that is targeting a specific tab's content script, will forward the event to the Component.Main instance, which will then send the event/message to its destination. This can be changed, as popup and foreground instances do have access to the chrome.tabs API.Message Broker
The message broker is a pattern that is partial implemented in the
chrome APIs, via the sendResponse parameter of chrome.runtime.onMessage handlers. However, it leaves too much room for error and based on our experience, doesn't appear to handle concurrency very well.There are a few important details for routing a message to the correct target. If a single
Component is not specific enough, you can provide an object to the target parameter in .send() help target the receiver: 1.
component - the high-level extension component
2. context - your application-specific context for any component that may have multiple instances
3. tabId - when trying to send a message to a content script of a specific tabTargeting a specific content script on a specific tab
`ts
const response = await broker.send({
name: "HelloWorld",
target: {
component: Component.ContentScript,
context: "my-script-context",
tabId: 123,
},
data: { message: "hi" },
});
``