Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/davestewart/extension-bus
Universal message bus for chromium and firefox browsers
https://github.com/davestewart/extension-bus
chrome chrome-extension firefox firefox-extension webextension webextensions
Last synced: about 1 month ago
JSON representation
Universal message bus for chromium and firefox browsers
- Host: GitHub
- URL: https://github.com/davestewart/extension-bus
- Owner: davestewart
- License: mit
- Created: 2024-01-10T15:20:08.000Z (11 months ago)
- Default Branch: main
- Last Pushed: 2024-06-09T11:07:21.000Z (6 months ago)
- Last Synced: 2024-10-24T21:57:29.742Z (about 2 months ago)
- Topics: chrome, chrome-extension, firefox, firefox-extension, webextension, webextensions
- Language: TypeScript
- Homepage:
- Size: 319 KB
- Stars: 27
- Watchers: 2
- Forks: 0
- Open Issues: 2
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
- awesome-webext-dev - extension-bus - Universal message bus for chromium and firefox browsers (Packages)
README
# Extension Bus
> Universal message bus for web extensions
![splash](https://raw.githubusercontent.com/davestewart/extension-bus/main/splash.png)
## Abstract
The Web Extensions API provides a way to communicate between processes by way of [message passing](https://developer.chrome.com/docs/extensions/mv2/messaging).
However, setting up a robust, consistent and flexible messaging implementation is surprisingly complex.
This package provides an elegant solution, with:
- simple cross-process messaging
- named buses to easily target processes
- nested handlers with an API-like interface
- transparent handling of sync and async handlers
- transparent handling of process and handler errors
- transparent handling of internal and external calls
- a consistent interface for process, tab and external callsOnce configured with targets and handlers typical [messaging code](#sending-a-message) is as follows:
```ts
const result = await bus.call('some/handler', payload)
```And with consistent handling of [errors and edge cases](#error-handling) messaging both simple and intuitive.
## Usage
### Installation
Install from NPM:
```bash
npm i @davestewart/extension-bus
```Alternatively, you can shorten imports with an alias, for example `bus`:
```bash
npm i bus@npm:@davestewart/extension-bus
``````ts
// easier import
import { makeBus } from 'bus'
```### Creating a bus
For each process, i.e. `background`, `popup`, `content`, `page` :
- create a named `Bus`
- add handler functions
- optionally specify a `target`
- optionally configure `external` access```js
import { makeBus } from 'extension-bus'// named process
const bus = makeBus('popup', {
// optionally target a specific process
target: 'background',
// handle incoming requests
handlers: {
foo (value, sender) { ... },
bar (value, { tab }) { ... },
},// allow external connection
external: true,
})
```#### TypeScript
If you prefer to declare `handlers` separately, type their parameters with the `Handlers` type:
```ts
import { type Handlers } from 'extension-bus'export const handlers: Handlers = {
// number, chrome.runtime.MessageSender
foo (value: number, { tab }) {
const url = tab?.url
}
}
```Note that:
- you can name a `bus` anything, i.e. `content`, `account`, `gmail`, etc
- any `target` must be the name of another `bus`, or `*` to target all buses (the default)
- `handlers` may be nested, then targeted using `/` syntax, i.e. `'baz/qux'`
- new handlers may be added via `add()`, i.e. `bus.add('baz': { qux })`### Sending a message
#### To other processes
To send a message to buses in one or more processes, call their handlers by `path`:
```js
// flat
const result = await bus.call('greet', 'hello')// nested
const result = await bus.call('foo/bar/baz', payload)// override target
const result = await bus.call('popup:greet', 'hello')
```Note that calls will *always* complete; use `await` to receive returned values ([errors](#error-handling) always return `null`)
#### To other tabs
To target tab content scripts, use `callTab()`:
```js
// call a specific tab
const result = await bus.callTab(123, 'greet', 'hello')// call the current tab (useful from the extension's action icon)
const result = await bus.callTab(true, 'greet', 'hello')
```#### To other extensions
To target buses in other extensions, use `callExtension()`:
```js
const result = await bus.callExtension('', 'account/login', { username, password })
```See the [Receiving messages](#from-web-pages-or-other-extensions) section for more information.
#### TypeScript
If you want to type any `call()` functions' `result` and `payload`, pass the type parameters in that order:
```ts
const window = await bus.call('windows/get', 1)
```If you think a call may *not* complete (missing tab, popup closed, etc) pass a `null` union as the result type:
```ts
const window = await bus.call('windows/get', 1000)
if (window) {
...
}
```See the [Error handling](#error-handling) section for more information.
### Receiving a message
#### From other processes
Messages that successfully target a bus will be routed to the correct handler:
```ts
// content script
const result = await bus.call('bookmarks/related', 'www.google.com')
```
Once a handler is targeted, you have a few additional conveniences:```ts
// background script
import { type Handlers } from 'extension-bus'// Use the Handlers type to automatically type the `sender` property
const handlers: Handlers = {
bookmarks: {
async related (domain: string, { tab }) {
// 1️⃣ reference sender
if (tab.url?.includes(domain)) {
// 2️⃣ reference sibling handlers
const bookmarks = await this.search(domain)// 3️⃣ optionally return a value
return { bookmarks }
}
},
search (domain: string) {
return chrome.tabs.query({ url: `https://${domain}/*` })
}
}
}
```Note that:
- the first parameter is the call payload (can be any JSON-serializable value)
- the second parameter is the `sender` context (which _may_ contain a tab)
- handlers are scoped to their containing block (so `this` targets siblings)
- return a value to respond to the `source` bus#### From web pages or other extensions
You can configure whether a bus should be able to receive external messages:
```ts
const bus = makeBus('background', {
// always accept messages
external: true,// accept calls only to these paths (supports wildcards)
external: [
'account/login',
'user/*',
],// programatically accept messages
external (path: string, sender: chrome.runtime.MessageSender): boolean {
return sender.tab.url.startsWith('https://yourdomain.com') && path.startsWith('account/')
},
})
```Note:
- it's generally more reliable to receive messages _only_ in the background process
- if the predicate fails the sending extension will receive no response#### Sending from a non-Extension Bus extension
If you want to message an Extension Bus extension from a non-Extension Bus extension, pass an object with `path` and optional `data` properties:
```ts
chrome.runtime.sendMessage('', { path: 'path/to/handler', data: 123 }, function (response) {
if (response) {
console.log(response.result)
}
})
```Note however, that Extension Bus is designed to be used across multiple extensions.
### API
See the types file for the full API:
- https://github.com/davestewart/extension-bus/tree/main/src/types.ts
## Error handling
Extension Bus guarantees all calls complete, but an "error" state occurs if:
- the targeted bus or tab does not exist
- no handler paths were matched
- a matched handler errors or rejects a promise
- extension source code was updated but not reloadedFailed calls return `null`, and may trigger a warning if configured:
```
extension-bus[popup] ReferenceError at "background:foo/bar": foo is not defined
```If you're not sure if there was an error, check the `bus.error` property:
```js
const result = await bus.call('foo/bar')
if (result === null && bus.error) {
// handle error
}
```If there is an error, the property will contain further information:
```js
{
code: 'handler_error',
message: 'foo is not defined',
target: 'background:foo/bar',
}
```The following table explains the error codes:
| Code | Message | Reason |
|-----------------|---------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `no_response` | The message port closed before a response was received. | There were no `target` buses loaded that matched the source bus' `target` property, or multiple buses were called via (`*`) and none contained matching handlers |
| | Could not establish connection. Receiving end does not exist. | The targeted tab didn't exist, was discarded, was never loaded, or wasn't reloaded after reloading the extension |
| `no_handler` | No handler | A named `target` bus was found, but did not contain a handler at the supplied `path` |
| `handler_error` | *The error message* | A handler was found, but threw an error when called (see the `target`'s console for the full `error` object) |Note that because of the way message passing works, a `no_handler` error will only be recorded when targeting a **single** *named* bus. This is because when targeting multiple (bus) listeners, the first listener to reply wins, so in order not to prevent a _potential_ matched bus from replying, unmatched buses **must** stay quiet; thus if _no_ buses match or contain handlers, the error can only be `no_response`.
For example:
```js
await bus.call('*:unknown') || bus.error?.code // 'no_response'
await bus.call('background:unknown') || bus.error?.code // 'no_handler'
```### Error handling options
To modify how errors are handled, configure the `onError` option:
```js
const bus = makeBus('popup', {
// warns in the console (unless error is "no_response") and returns null
onError: 'warn',// rejects a BusError object, and should be handled by try/catch or .catch(err)
onError: 'reject',// custom function, from which you can return a value
onError: (request: BusRequest, response: BusResponse, error: Bus) => { ... },
})
```### A note about error trapping
Handler execution is wrapped in a `try/catch` and uses `console.warn()` to log errors.
The console output will contain a call stack so should be sufficient for debugging purposes – though logging errors is really just a courtesy to prevent them being swallowed by the `catch`. If you have code that may error, you should handle it _within_ the target handler function, rather than letting errors leak into the bus.
### Writing code in development
Writing successful message handling is complicated by the fact that as code is updated / reloaded, connections are replaced, and Chrome can error (see above table).
To successfully write, run, *re*write and *re*run code which sends messages between processes:
- For `page` and `background` processes, reload the process using `Cmd+R`/`F5`
- For `popup` scripts, reopen the popup to load the new script
- For `content` scripts:
- make sure to reload both the extension **and** content scripts tabs
- if you're having trouble targeting the new script context in the console's "context" dropdown, open the URL in a new tab## Demo
The package is compatible with both MV2 and MV3 and ships with near-identical demos for both:
![screenshot](https://raw.githubusercontent.com/davestewart/extension-bus/typescript/demo/assets/screenshot.png)
You can check the source code at:
- https://github.com/davestewart/extension-bus/tree/main/demo
In each demo, each of the main processes have a named `bus` configured, and each of them sends messages to one or more processes:
| Process | Sends to | Registered handlers | Demonstrates |
|------------|-----------------------|---------------------|:----------------------------------------|
| Popup | All, Page, Background | `pass`, `fail` | Returning and erroring calls |
| Page | All, Page, Background | `pass`, `fail` | Returning and erroring calls |
| Background | All | `pass`, `fail` | Returning and erroring calls |
| | | `handle` | Non-returning call |
| | | `nested/hello` | Nested handler |
| | | `delay` | Async handler |
| | | `bound` | Referencing a sibling handler |
| | | `tabs/identify` | Returning a content script its tab `id` |
| | | `tabs/update` | Executing a script in the sending tab |
| Content | Background | `pass`, `fail`, | Returning and erroring calls |
| | | `update` | Calling a content script by tab id |The examples demonstrate:
- a handler called `pass()` which always returns a result
- a handler called `fail()` which will throw an error and receive `null`
- sync and async handlers
- nested handlers
- passing payloads
- calling content scripts by idFor more information and usage examples, check the comments in each of the functions in the demo `.js` files.
Note that the extension will need to be reloaded if you make changes!
### Installation
To install:
- clone this repository
- From Chrome's extensions page
- Toggle on "Developer mode"
- Click "Load unpacked"
- Choose the appropriate `demo` folder in the cloned repoTo run the MV3 demo in Firefox, modify the `background` key in the `manifest.json` file as follows:
```json
{
"background": {
"scripts": [
"app/background/background.js"
]
}
}
```### Getting started
Jump in and play with each of the extension's processes / buses in the browser.
Note that this will be a mix of UI for `popup` and `pages` and DevTools for `background` and `content`.
As you click the buttons in the page, or make calls in the DevTools, watch to see related pages update or console
entries appear.### Popup and Page
To use the popup bus, click the Extension's icon in the toolbar.
Once the popup is open, or an extension page is loaded, you can:
- click the buttons to call handlers on buses in other processes:
- **Call All** – calls all registered and loaded buses
- **Call Background** – calls the `background` bus only
- **Call Content** – calls the current tab's `content` bus (tab must have reloaded)
- **Call Page** – calls the `pass()` handler in any loaded `page` bus
- **Fail Page** – calls the `fail()` handler in any loaded `page` bus
- click the **Add Page** button to add a new `page` tab (which are also registered to send and receive)Note that:
- sending and receiving messages (from other processes / buses) will update the table
- not all processes may exist at any particular time:
- if no `page` tabs are open
- if `content` scripts are not yet loaded or have been changed since last load### Background and Content
Background and content buses can be interacted with via the DevTools.
#### Background
Open the `background` page from the extension's Options page, and the `content` script using the DevTools for open tabs.
As an example, here's how you might call other buses from the `background` page:
```js
// call all processes
await bus.call('pass', 'hello from background')// call an open page function
await bus.call('page:pass', 'hello from background') || bus.error// call an open page function that fails
await bus.call('page:fail', 'hello from background') || bus.error// call popup (if open)
await bus.call('popup:pass', 'hello from background') || bus.error// target a content script
// reload any tab and check the console for the tab id, e.g. 334068351
await bus.callTab(334068351, 'pass')// set active tab's body color to red (must be an https:// page)
chrome.windows.getLastFocused(function (window) {
chrome.tabs.query({ active: true, windowId: window.id }, async function (tabs) {
const [tab] = tabs
const result = await bus.call(tab.id, 'update', 'red')
console.log(result)
})
})
```The background bus also exposes two paths to external messaging. See the [section above](#from-web-pages-or-other-extensions) for more information, but from another extension you should _only_ be able to call `pass` or `nested/hello`:
```ts
const result = await bus.callExtension('', 'pass')
```To test this, you can install the MV2 extension and the MV3 extension, and message one from the other.
### Content
The content script example is set up to `reject` errors, so you can play with `try/catch ` here if you prefer that way of working.
In the console, select the "Extension Bus Demo" option from the script context dropdown, then:
```js
bus.call('fail').catch((err: BusError) => {
console.log('Error:', err)
})
```
```
Error: {
code: 'handler_error',
message: 'foo is not defined',
target: 'page:fail'
}
```## Compatibility
The package is compatible and tested on both MV2 and MV3 Chrome and Firefox.
All code written in TypeScript, generated code comes with source maps for easy debugging.
## Support
This project open sources code from my main project [Control Space](https://controlspace.app/), a super-interactive tab manager for those who juggle **a lot** of tasks:
[![control space](https://controlspace.app/images/home/examples/actions.png)](https://controlspace.app)
If you think Control Space might work for you, click above to find out more and give it a spin.
Thanks!
Dave