Skip to main content

Bridged Routing

In modern browser extension development, code is inherently fractured across isolated sandboxes: UI Popups, Content Scripts, and Background Service Workers. Furthermore, the underlying browser APIs (Chrome, Firefox, Safari) are highly fragmented.

HexaJS addresses these environment constraints using a pattern called Bridged Routing.

Instead of scattering chrome.runtime.sendMessage and platform-specific if/else checks throughout the application, HexaJS treats the background script as a central hub, and the UI/Content scripts as clients communicating over a type-safe message bus.

The Architecture​

Bridged Routing consists of three distinct layers:

  1. The Client (UI / Content Scripts): Isolated from browser APIs. They request data by dispatching typed actions.
  2. The Controller (Background): Receives actions, handles business logic, and delegates browser-level work to Ports.
  3. The Port (Platform Adapter): Platform-specific implementations that wrap native browser APIs into a standardized, Promise-based interface.

Core Rule: UI components and Content and Background Scripts should never call browser APIs directly. All browser-level operations must be routed through @Ports.

The Problem: API Fragmentation​

Without an abstraction layer, supporting multiple browsers leads to code heavily reliant on platform checks. Chrome relies on callbacks, Firefox uses Promises, and Safari often requires proprietary APIs:

// ❌ The old way: Scattered platform checks
function getActiveTab() {
if (isChrome) {
chrome.tabs.query({ active: true }, (tabs) => handle(tabs));
} else if (isFirefox) {
browser.tabs.query({ active: true }).then((tabs) => handle(tabs));
} else if (isSafari) {
const tab = safari.application.activeBrowserWindow.activeTab;
handle([tab]);
}
}

The HexaJS Solution: Ports & Controllers​

HexaJS abstracts platform specifics into Ports. Application logic relies strictly on Dependency Injection to access browser capabilities.

1. The Client Request (UI Context)​

UI components use the HexaUIClient to trigger an action on the background script, agnostic to the browser environment.

import { inject } from '@hexajs-dev/common';
import { HexaUIClient } from '@hexajs-dev/ui';

const uiClient = inject(HexaUIClient);

const result = await uiClient.sendMessage('tabInfo:current', {});
console.log('Active tab metadata:', result);

2. The Background Controller​

The Controller listens for the 'tabInfo:current' action. It injects the necessary Ports to execute the browser API calls.

import { Controller, Action } from '@hexajs-dev/core';
import { TabsPort, StoragePort } from '@hexajs-dev/ports';

@Controller({ namespace: 'tabInfo' })
export class TabInfoController {
constructor(private readonly tabsPort: TabsPort, private readonly storagePort: StoragePort) {}

@Action('current')
async onGetCurrentTabInfo(): Promise<{ tabId: number; url: string; metadata?: unknown }> {
const [activeTab] = await this.tabsPort.query({ active: true });
if (!activeTab) throw new Error('No active tab found');

const cached = await this.storagePort.get('tabMetadata', { [activeTab.id]: {} });
return {
tabId: activeTab.id,
url: activeTab.url,
metadata: cached[activeTab.id] ?? {},
};
}
}

Benefits​

  • Predictable Architecture: Clear separation between business logic (Controllers) and UI state (Clients).
  • Cross-Platform Compatibility: Controller logic is written once; changing the build platform swaps the underlying Port implementation.
  • Testability: Components rely on Dependency Injection, allowing Ports like TabsPort or StoragePort to be mocked in unit tests.
  • Security: Sensitive browser APIs are restricted to the Background script, minimizing the attack surface in Content Scripts and UI environments.

@Action​

For Request/Response (Unary). CLI should enforce that actionName is UNIQUE per context.

import { Action } from '@hexajs-dev/core';
@Action(actionName: string)

@Controller​

import { Controller } from '@hexajs-dev/core';
@Controller(options: ControllerOptions)

@On​

For Fire-and-Forget (Multicast). Multiple methods can listen to the same eventName.

import { On } from '@hexajs-dev/core';
@On(eventName: string)

Supporting Types​

ControllerOptions​

interface ControllerOptions {
namespace: string;
}

@Handle​

For Request/Response (Unary). CLI should enforce that handleName is UNIQUE per context.

import { Handle } from '@hexajs-dev/core';
@Handle(handleName: string)

@Handler​

import { Handler } from '@hexajs-dev/core';
@Handler(options: HandlerOptions)

@Subscribe​

For Fire-and-Forget (Multicast). Multiple methods can listen to the same eventName.

import { Subscribe } from '@hexajs-dev/core';
@Subscribe(eventName: string)

Supporting Types​

HandlerOptions​

interface HandlerOptions {
namespace: string;
Contents?: ContentClass[];
}

Classes​

HexaPipeValidationError​

import { HexaPipeValidationError } from '@hexajs-dev/core';
class HexaPipeValidationError extends Error { ... }

Properties​

  • code
  • details

HexaRemoteError​

import { HexaRemoteError } from '@hexajs-dev/core';
class HexaRemoteError extends Error { ... }

Properties​

  • code
  • details

Types & Interfaces​

HexaPipeInput​

import { HexaPipeInput } from '@hexajs-dev/core';
interface HexaPipeInput {
route: string;
payload: unknown;
sender: unknown;
context: HexaPipeContextName;
}

HexaPipeValidationResult​

import { HexaPipeValidationResult } from '@hexajs-dev/core';
interface HexaPipeValidationResult {
valid: boolean;
error?: string;
code?: string;
details?: unknown;
}

HexaPipeContextName​

import { HexaPipeContextName } from '@hexajs-dev/core';
type HexaPipeContextName = 'background' | 'content';

HexaPipeFn​

import { HexaPipeFn } from '@hexajs-dev/core';
type HexaPipeFn = (input: HexaPipeInput) => unknown | HexaPipeValidationResult | Promise<unknown | HexaPipeValidationResult>;