Workers
Target Audience: Advanced Goal: Build robust worker pipelines with predictable lifecycle, message boundaries, and performance characteristics.
HexaJS workers let you isolate CPU-heavy or latency-sensitive logic from your primary runtime surfaces (background, content, and UI). Workers are declared with @Worker({ ... }) and can be wired into background services or other workers via @InjectWorker() properties.
Scaffolding:
hexa add worker <name> [--environment compute|dom]generates a starter@Workerclass undersrc/background/workers/<name>.worker.ts. See CLI Commands → add worker.
Why Workers Matter
Modern browser extensions already split code across multiple contexts. Without a worker strategy, heavy workloads often leak into background or UI orchestration and create slow responses, dropped events, or unpredictable latency spikes.
A good worker design gives you:
- Isolation: expensive operations run outside controller/handler orchestration paths.
- Backpressure control: bounded queues prevent memory spikes during burst traffic.
- Clear contracts: typed request/response envelopes keep execution predictable.
- Operational visibility: worker-level timing and failure telemetry become first-class.
Runtime Model
At a high level, workers in HexaJS fit this flow:
- A controller/handler receives a typed command.
- The command is mapped to a worker operation contract.
- The worker executes with a bounded deadline and structured error mapping.
- The result is returned as a typed response DTO.
Use the same design principles as any distributed boundary: explicit contracts, timeout policy, retry policy, and idempotency.
Lazy boot behavior
Worker registration is lazy from the caller's perspective.
- The generated bootstrap registers worker proxies during container setup.
- The underlying worker host is not started at registration time.
- HexaJS boots the worker host on the first method call made through that proxy.
This means unused workers do not pay startup cost, but the first call to a worker may include a one-time boot penalty before the method executes.
Recommended Contract Shape
Use a strict message envelope for worker requests and responses.
export interface WorkerRequest<TPayload> {
requestId: string;
operation: string;
payload: TPayload;
issuedAt: number;
timeoutMs: number;
}
export interface WorkerSuccess<TData> {
ok: true;
requestId: string;
data: TData;
durationMs: number;
}
export interface WorkerFailure {
ok: false;
requestId: string;
errorCode: string;
message: string;
durationMs: number;
}
export type WorkerResponse<TData> = WorkerSuccess<TData> | WorkerFailure;
This shape ensures observability and deterministic caller behavior.
Example: OCR Normalization Worker
1. Worker-facing DTOs
export class NormalizeOcrRequest {
text!: string;
language!: string;
}
export class NormalizeOcrResponse {
normalized!: string;
confidence!: number;
}
2. Worker class
import { InjectWorker } from '@hexajs-dev/common';
import { Worker, WorkerProxy } from '@hexajs-dev/core';
@Worker({ name: 'ocr-normalizer', environment: 'compute' })
export class OcrNormalizationWorker {
async normalize(payload: NormalizeOcrRequest): Promise<NormalizeOcrResponse> {
const cleaned = payload.text.replace(/\s+/g, ' ').trim();
return {
normalized: cleaned,
confidence: cleaned.length > 0 ? 0.98 : 0,
};
}
}
@Worker({ name: 'ocr-pipeline', environment: 'compute' })
export class OcrPipelineWorker {
@InjectWorker()
private normalizer!: WorkerProxy<OcrNormalizationWorker>;
async run(payload: NormalizeOcrRequest): Promise<NormalizeOcrResponse> {
return this.normalizer.normalize(payload);
}
}
If you need to resolve a worker lazily at runtime instead of declaring a property, use injectWorker(WorkerClass) from @hexajs-dev/common.
Injected Workers Are Always Asynchronous
An injected worker is a proxy. Every method call is dispatched across a message boundary, so at runtime every method returns a Promise — regardless of the return type declared on the worker class.
Annotate @InjectWorker() properties with WorkerProxy<T> so the injected type reflects this reality:
// ✅ Recommended — methods are typed as Promise-returning
@InjectWorker() private readonly normalizer!: WorkerProxy<OcrNormalizationWorker>;
// ⚠️ Discouraged — hides that calls are async and can reject
@InjectWorker() private readonly normalizer!: OcrNormalizationWorker;
WorkerProxy<T> maps each method (...args) => R to (...args) => Promise<Awaited<R>> while passing non-method members through unchanged. This has two important effects:
awaitworks as expected. A method declared to returnvoidis typed asPromise<void>, soawait this.normalizer.normalize(...)no longer trips the "await has no effect" diagnostic.- Unhandled rejections become visible. Because every call is statically
Promise-returning, the compiler flags calls whose result you neitherawaitnor.catch().
If you build with the raw class type, hexa build emits a warning pointing you at WorkerProxy<T>.