1.0.3 • Published 1 year ago

@jam.dev/extension-messaging v1.0.3

Weekly downloads
-
License
MIT
Repository
github
Last release
1 year ago

@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, 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 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:

  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 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" },
});