Message Routing
Messages travel between isolated contexts (UI, content, background) using typed routes in the form namespace:action. Controllers define the routes; clients send the messages.
Security boundary defaults
Routes are internal-only by default.
- Internal sender calls continue to work without extra configuration.
- Background external listeners are resolved at CLI build time.
- External callers must be explicitly allowed with
@AllowExternal(). - Method decorators override class decorators.
For background routes, both @Action (unicast) and @On (multicast) can be externally exposed with method-level @AllowExternal().
Use boundary decorators from @hexajs-dev/common:
import { AllowExternal, InternalOnly } from '@hexajs-dev/common';
import { Action, Controller } from '@hexajs-dev/core';
@AllowExternal({
ids: ['trusted.extension.id'],
origins: ['https://partner.example.com'],
})
@Controller({ namespace: 'integration' })
export class IntegrationController {
@Action('public')
onPublic(payload: unknown): { ok: true } {
return { ok: true };
}
@InternalOnly()
@Action('admin')
onAdmin(payload: unknown): { ok: true } {
return { ok: true };
}
}
Method-level @AllowExternal() is also supported. This is useful when most routes stay internal-only and only specific routes are exposed:
import { AllowExternal, InternalOnly } from '@hexajs-dev/common';
import { Action, Controller } from '@hexajs-dev/core';
@InternalOnly()
@Controller({ namespace: 'integration' })
export class IntegrationController {
@Action('public')
onPublic(payload: unknown): { ok: true } {
return { ok: true };
}
@AllowExternal()
@Action('admin')
onAdmin(payload: unknown): { ok: true } {
return { ok: true };
}
}
External behavior in background is channel-aware:
- Routes without
@AllowExternal()are not externally subscribed and are ignored onruntime.onMessageExternal. - Externally subscribed unicast routes return
__hexa_code__ = 'HEXA_BOUNDARY_POLICY_DENIED'whenids/originschecks fail. - Externally subscribed multicast routes are dropped and logged when
ids/originschecks fail. - Content scripts still use internal runtime messaging only. Browser runtime external listeners are background-only.
Message flow fundamentals
Request/Response pattern
Sender (UI / Content / Background)
-> client.sendMessage('namespace:action', payload)
-> ControllerContainer resolves → @Controller + @Action
-> Handler executes, returns result
-> Promise resolves with response
All routes are typed and validated at build time (AOT scanning).
ClipVault keeps route names in a shared API file so UI, content, and background all use the same constants:
export const configApi = {
Get: 'config:get',
Update: 'config:update',
} as const;
export const clipboardApi = {
Add: 'clipboard:add',
Get: 'clipboard:get',
Remove: 'clipboard:remove',
} as const;
export const clipboardHandlesApi = {
SyncClips: 'clipboard:sync-clips',
SyncConfig: 'clipboard:sync-config',
} as const;
Tutorial: Three message flows
Scenario 1: UI sends to background
Popup snippet:
import { inject } from '@hexajs-dev/common';
import { HexaUIClient } from '@hexajs-dev/ui';
import { configApi } from './api';
import { ConfigResponseMessage, GetConfigMessage } from './messages';
const hexaUIClient = inject(HexaUIClient);
const response = await hexaUIClient.sendMessage<GetConfigMessage, ConfigResponseMessage>(
configApi.Get,
new GetConfigMessage(Date.now())
);
Background Controller:
@Controller({ namespace: configNamespace })
export class ClipVaultConfigController {
@Action(ConfigActionsApi.Get)
async onGetConfig(_payload: GetConfigMessage): Promise<ConfigResponseMessage> {
const config = await this.configService.loadConfig();
return new ConfigResponseMessage(config);
}
}
Message flow:
Popup calls hexaUIClient.sendMessage(configApi.Get, new GetConfigMessage(...))
→ Browser extension sends the request to background
→ Background ControllerContainer receives message
→ Resolves config:get → calls onGetConfig(payload)
→ Returns ConfigResponseMessage
→ Popup receives the config payload
Scenario 2: Content script sends to background
Content snippet:
const clip = this.captureService.captureFromCopyEvent(event);
if (clip) {
this.client.sendMessage<AddClipMessage, ClipsResponseMessage>(
clipboardApi.Add,
new AddClipMessage(clip)
).catch(err => this.logger.error('Failed to send clip to background:', err));
}
Background controller:
@Controller({ namespace: clipboardNamespace })
export class ClipVaultClipboardController {
@Action(ClipboardActionsApi.Add)
async onAddClip(payload: AddClipMessage): Promise<ClipsResponseMessage> {
let clips = await this.clipboardManager.loadClips();
clips = this.clipboardManager.addClip(clips, payload.clip, config.storage.maxItems);
await this.clipboardManager.persistClips(clips);
return new ClipsResponseMessage(clips);
}
}
Message flow:
Content calls this.client.sendMessage(clipboardApi.Add, new AddClipMessage(clip))
→ Browser extension message API routes to background
→ Background ControllerContainer receives
→ Resolves clipboard:add → calls onAddClip(payload)
→ Background persists the updated clip list
→ Returns ClipsResponseMessage
→ Content can continue with the synchronized result
Scenario 3: Background broadcasts to content handlers
ClipVault uses background broadcasts to keep content scripts synchronized after config or clip changes.
Background controller:
@Action(ConfigActionsApi.Update)
async onUpdateConfig(payload: UpdateConfigMessage): Promise<ConfigResponseMessage> {
const merged = this.configService.mergeConfig(current, payload.config);
const syncMessage = new SyncConfigMessage(merged);
this.client.broadcast(clipboardHandlesApi.SyncConfig, syncMessage)
.catch(err => this.logger.error('Broadcast config failed:', err));
return new ConfigResponseMessage(merged);
}
Content handler:
@Handler({ namespace: clipboardHandlesNamespace, Contents: [ClipVaultContent] })
export class ClipVaultHandler {
@Handle(ClipboardHandlesApi.SyncConfig)
onSyncConfig(payload: SyncConfigMessage): { status: string } {
this.store.dispatch(configSynced({ config: payload.config }));
return { status: 'received' };
}
}
Message flow:
Background calls this.client.broadcast(clipboardHandlesApi.SyncConfig, syncMessage)
→ Browser extension routes the message to matching content scripts
→ Content HandlerContainer receives the message
→ Resolves clipboard:sync-config → calls onSyncConfig(payload)
→ Content updates local state and acknowledges with { status: 'received' }
Unicast vs multicast
HexaJS has two routing modes. The decorator pair you choose determines how many handlers can exist for a route and whether the sender receives a response.
| Background | Content | Sender gets response | |
|---|---|---|---|
| Unicast | @Action | @Handle | Yes — Promise<T> |
| Multicast | @On | @Subscribe | No — fire-and-forget |
Unicast — @Action / @Handle
One handler per route. The sender awaits a typed response. Use this for request/response flows where the caller needs a result or confirmation.
// Background
@Controller({ namespace: 'clipboard' })
export class ClipboardController {
@Action('add')
async onAdd(payload: AddClipMessage): Promise<ClipsResponseMessage> {
return new ClipsResponseMessage(await this.manager.addClip(payload.clip));
}
}
// Content caller
const response = await this.client.sendMessage<AddClipMessage, ClipsResponseMessage>('clipboard:add', new AddClipMessage(clip));
Multicast — @On / @Subscribe
Multiple handlers can listen to the same event name. The sender does not await a response — fire-and-forget. Use this when background needs to notify all content scripts of a change, or when multiple independent handlers should react to the same event.
// Background — emits the event
@Controller({ namespace: 'clipboard' })
export class ClipboardController {
constructor(private readonly client: HexaBackgroundClient) {}
@On('clips-changed')
async onClipsChanged(payload: SyncClipsMessage): Promise<void> {
// multiple @Subscribe handlers across content scripts all receive this
}
}
// Content — one or more subscribers on the same event name
@Handler({ namespace: 'clipboard', Contents: [MyContent] })
export class ClipboardHandler {
constructor(private readonly store: HexaContentStore<ContentState>) {}
@Subscribe('clips-changed')
onClipsChanged(payload: SyncClipsMessage): void {
this.store.dispatch(clipsSynced({ clips: payload.clips }));
}
}
@Handler({ namespace: 'clipboard', Contents: [MyContent] })
export class ClipboardAuditHandler {
@Subscribe('clips-changed')
onClipsChanged(payload: SyncClipsMessage): void {
this.logger.log('Clips changed, count:', payload.clips.length);
}
}
Both onClipsChanged methods receive the same event. Neither returns a value the background caller can read.
Wire protocol difference
Internally, unicast messages carry { action: 'namespace:name', payload } and multicast messages carry { event: 'namespace:name', payload }. The container routes them differently — unicast goes to a single registered handler and sends a response; multicast fans out to all registered subscribers and does not send a response.
This means @Action/@Handle and @On/@Subscribe routes with the same name are independent and do not conflict.
When to use each
Use unicast when:
- the caller needs a result or confirmation,
- exactly one handler should process the message,
- you need validation pipe coverage on the response.
Use multicast when:
- background pushes a notification and does not need acknowledgement,
- multiple independent handlers should react to the same event (e.g., store update + audit log),
- the broadcast pattern from
HexaBackgroundClient.broadcastmaps to content subscribers.
Message structure
Payload types
All payloads are serializable (JSON.stringify must work):
// ✅ Good
{
timestamp: 1234567890,
url: 'https://example.com',
tags: ['important', 'urgent'],
metadata: { key: 'value' }
}
// ❌ Bad
{
element: document.getElementById('main'), // DOM node
func: () => { ... }, // Function
circular: { ref: self } // Circular reference
}
Response types
Controllers return values that are automatically serialized:
@Action('query')
onQuery(payload: any): { status: string; results: any[] } {
// HexaJS auto-serializes this object
return {
status: 'success',
results: [...],
};
}
Error handling
If a controller throws, the client promise rejects:
// Background controller
@Action('risky')
onRiskyAction(payload: any) {
if (!payload.required_field) {
throw new Error('Missing required_field'); // ← Sent to caller
}
return { success: true };
}
// UI code
try {
await uiClient.sendMessage('namespace:risky', {});
} catch (error) {
console.error('Controller threw:', error.message); // "Missing required_field"
}
Best practices
✅ Do:
- Keep action names descriptive:
popup:opened,visibility:show,storage:read - Validate payload in @Action handlers before processing
- Use store + reducers for state changes (not just return values)
- Namespace controllers logically (
popup,content,tabs,storage) - Return lightweight, serializable responses
❌ Don't:
- Rely on message order if sending multiple async messages
- Pass large objects (serialize first if needed)
- Embed business logic in message creation; keep it in controllers
- Mix messaging routes with internal service calls (clients are for cross-context only)
Classes
HexaBackgroundClient
Background-context HexaClient. Extends the base with tab-targeted messaging and broadcast.
import { HexaBackgroundClient } from '@hexajs-dev/core';
class HexaBackgroundClient { ... }
Methods
broadcast()
Broadcast a fire-and-forget message to all tabs.
broadcast<TPayload>(target: `${namespace}:${api}`, payload?: TPayload): Promise<void>
sendMessage()
Send a message and await a response. Content → background uses runtime.sendMessage. Background → content requires a tabId — use BackgroundHexaClient.sendToTab().
sendMessage<TPayload, TResponse>(target: `${namespace}:${api}`, payload?: TPayload): Promise<TResponse>
sendToTab()
Send a message and await a response. Content → background uses runtime.sendMessage. Background → content requires a tabId — use BackgroundHexaClient.sendToTab().
sendToTab<TPayload, TResponse>(tabId: number, target: `${namespace}:${api}`, payload?: TPayload): Promise<TResponse>
Classes
HexaContentClient
Content-context HexaClient. Sends messages from the content script to the background.
import { HexaContentClient } from '@hexajs-dev/core';
class HexaContentClient { ... }
Methods
sendMessage()
Send a message and await a response. Content → background uses runtime.sendMessage. Background → content requires a tabId — use BackgroundHexaClient.sendToTab().
sendMessage<TPayload, TResponse>(target: `${namespace}:${api}`, payload?: TPayload): Promise<TResponse>