Skip to main content

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.

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' }

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)