@jam.dev/extension-messaging
This 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.
Overview
Building a browser extension requires communication between multiple system components. The browser-provided messaging APIs are a great foundation, but quickly require adding additional application logic to ensure your messages are being received and processed by the right component.
This package provides two classes:
-
EventBus
for event general broadcasting -
MessageBroker
for request/response style communication between two extension components
Quick usage
Event Bus
import {EventBus, Component} from "@jam.dev/extension-messaging";
type MyEvents = {
Hello: {world: boolean};
};
const bus = new EventBus<MyEvents>({ component: Component.Main });
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
import {MessageBroker, Component} from "@jam.dev/extension-messaging";
type MyMessages = {
Ping: {
request: boolean;
response: boolean;
};
};
const broker = new MessageBroker<MyMessages>({
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;
});
Concepts
Application Patterns
EventBus
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.
Transport Layers
The chrome
API has a few different ways to send messages:
-
chrome.runtime.sendMessage
— Used to send messages to "background" components. For example, thebackground.js
orworker.js
script sending a message to your extension's popup, or a content script sending a message back to yourbackground.js
orworker.js
instance -
chrome.tabs.sendMessage
— Used to send messages to content scripts on a specific tab -
port.postMessage
— If usingchrome.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 API
For 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.
Strongly-typed events and messages
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.
type MyEvents = {
EventOne: {
my: true;
payload: number;
};
}
const bus = new EventBus<MyEvents>({ component: Component.Main });
The MessageBroker
map is similar: keys are your event names, and the value is keyed by request
and response
structures.
type MyMessages = {
HelloWorld: {
request: { message: string };
response: boolean;
};
}
const broker = new MessageBroker<MyMessages>({ 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 **
// In your popup
const bus = new EventBus<MyEvents>({ component: Component.Popup });
await bus.emit({
name: "Ping",
data: true,
});
// In your content script
const bus = new EventBus<MyEvents>({ 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:
-
component
- the high-level extension component -
context
- your application-specific context for any component that may have multiple instances -
tabId
- when trying to send a message to a content script of a specific tab
Targeting a specific content script on a specific tab
const response = await broker.send({
name: "HelloWorld",
target: {
component: Component.ContentScript,
context: "my-script-context",
tabId: 123,
},
data: { message: "hi" },
});