Skip to content

Messaging

epos.bus is the messaging layer used to communicate between extension contexts. You can use the same API in the popup, background, side panel, web page, or iframes.

The most basic methods are:

  • epos.bus.on() to listen for messages.
  • epos.bus.send() to send a message to other contexts.
  • epos.bus.off() to remove a listener.

Why “bus”?

In computing, a bus is not a vehicle, but a communication system that transfers data between different components. It acts as a centralized hub for exchanging messages.

Why Use epos.bus?

Browser extensions often need different messaging APIs, depending on where a message starts and where it needs to go.

epos.bus gives you one API for all of those cases. Instead of switching between chrome.runtime.sendMessage, chrome.tabs.sendMessage, and window.postMessage, you use the same methods everywhere and let Epos handle the routing.

Basics

The most common pattern is one context listening and another context sending.

In the example below, the background listens for analytics:event, and the popup sends it:

ts
epos.bus.on('analytics:event', eventName => {
  console.log('Analytics event received:', eventName)
})
tsx
const App = () => {
  return (
    <button onClick={() => epos.bus.send('analytics:event', 'save')}>
      Save
    </button>
  )
}

epos.render(<App />)
json
{
  "name": "My Extension",
  "targets": [
    {
      "matches": "<popup>",
      "load": ["dist/popup.js"]
    },
    {
      "matches": "<background>",
      "load": ["dist/background.js"]
    }
  ]
}

That is the most common pattern. You choose any event name, attach a listener with on(), and send data with send().

Returning Data

epos.bus.send() is not only for fire-and-forget events. If a listener returns a value, the sender receives it back.

This works well for request-response flows:

ts
// background.ts
epos.bus.on('user:get', async (userId: string) => {
  const response = await fetch(`https://api.example.com/users/${userId}`)
  return await response.json()
})

// popup.tsx
const user = await epos.bus.send<User>('user:get', '42')
console.log(user)

If there are no listeners, send() resolves to undefined.

Local Events with emit()

epos.bus.send() does not call listeners in the current context. It is meant for sending messages to other contexts.

To trigger local listeners, use epos.bus.emit() instead:

ts
epos.bus.on('modal:close', () => {
  console.log('Close modal')
})

epos.bus.emit('modal:close')

This can be useful for local coordination when you do not need cross-context delivery.

Removing Listeners

Use epos.bus.off() when a listener should stop receiving messages.

ts
const handleUpdate = (value: number) => {
  console.log('Updated:', value)
}

epos.bus.on('counter:update', handleUpdate)

// Later
epos.bus.off('counter:update', handleUpdate)

If you call off() without a callback, Epos removes all listeners for that event name in the current context.

One-Time Listeners

Sometimes an event should be handled only once. In that case, use epos.bus.once():

ts
epos.bus.once('auth:ready', () => {
  console.log('Auth is ready')
})

After the first call, the listener is removed automatically.

Waiting for Readiness

For startup flows, it is common to wait until another context finishes some work. epos.bus.setSignal() and epos.bus.waitSignal() are made for that.

For example, your popup may need to wait until the background loads configuration:

ts
// background.ts
const config = await loadConfig()
epos.bus.setSignal('config:ready', config)

// popup.tsx
const config = await epos.bus.waitSignal<Config>('config:ready')
console.log(config)

By default, waitSignal() waits indefinitely. You can also pass a timeout in milliseconds. If the signal is not set within that time, it resolves to undefined:

ts
// Resolves to `undefined` if config is not ready within 5 seconds
const config = await epos.bus.waitSignal<Config>('config:ready', 5000)

Type Safety

If you are using TypeScript, send() can be typed in two ways.

  1. For a simple return value, pass the expected result type:
ts
const total = await epos.bus.send<number>('math:sum', 5, 10)
  1. If you also want argument checking, pass a function type:
ts
import type { sum } from './background'

const total = await epos.bus.send<typeof sum>('math:sum', 5, 10)
ts
export const sum = (a: number, b: number) => a + b

epos.bus.on('math:sum', sum)

This keeps the event name string-based, while still giving you solid TypeScript support.

Providing this

To set this for a listener, pass the context as the third argument to on():

ts
const api = {
  value: 42,
  getValue () {
    return this.value
  }
}

epos.bus.on('api:getValue', api.getValue, api)

Exposing APIs

If you have many related methods, exposing them all through epos.bus.on() can get tedious:

ts
export const userApi = {
  getUser () { ... },
  updateUser () { ... },
  removeUser () { ... },
  // ... 10 more methods
}

epos.bus.on('user:get', userApi.getUser, userApi)
epos.bus.on('user:update', userApi.updateUser, userApi)
epos.bus.on('user:remove', userApi.removeUser, userApi)
// ... 10 more listeners

To expose all methods at once, you can register() the API object and make it available to other contexts:

ts
export const userApi = { ... }

epos.bus.register('user', userApi)

Then, in another context, you can use() that API by its name:

ts
import type { userApi } from './background'

const userApi = epos.bus.use<typeof userApi>('user')
const user = await userApi.getUser('42')

Notice that epos.bus.use() returns the API object immediately. You do not await it.

You also get full type safety for the API methods.

If the API should no longer be available, you can unregister it later with epos.bus.unregister('user').

Namespaces with for()

On larger projects, event names can start to collide. epos.bus.for() solves that by creating a namespaced version of the bus:

ts
const chatBus = epos.bus.for('chat')

chatBus.on('message', text => {
  console.log('Chat message:', text)
})

// In another context
await chatBus.send('message', 'Hello')

The namespaced bus has all the methods of the normal epos.bus API. It also has a dispose() method that removes all listeners registered through it.

Sending Blobs

epos.bus is not limited to JSON data. You can safely transfer Blob objects between contexts. This is useful when you work with images, files, or other binary data.

ts
// popup.ts
await epos.bus.send('file:save', imageBlob)

// background.ts
epos.bus.on('file:save', async (blob: Blob) => {
  console.log('Save file:', blob.type, blob.size)
})

Epos does not serialize blobs as base64 strings. Instead, it uses a more efficient transfer techniques. You can safely send large files between contexts without freezing your app.

When to Use What

  • Use on() and send() for normal cross-context messaging.
  • Use emit() for local-only events.
  • Use once() when an event should be handled a single time.
  • Use setSignal() and waitSignal() for readiness.
  • Use register() and use() when you want to expose an API to another context.
  • Use for() to create a namespaced bus and avoid event name collisions.

If you want the exact signatures, continue to the Bus API Reference.