Skip to Content
HeronJS v4 is now available.
4.0.0Thread

Worker Threads

@heronjs/thread provides Thread and the injectable ThreadManager. @heronjs/common depends on it and provides defineWorker, createThreadManager, and the worker-local DI bridge. Core automatically owns the configured manager. Runnable<I, O> and RunContext come from @heronjs/common. Workers use node:worker_threads; they import compiled modules and rebuild decorator metadata and a local DI container. Existing @Service classes continue to run on the main thread.

Define the worker module

// workers.ts — compile to workers.js before starting the application. import {Inject, injectable, Module, type Runnable, type RunContext} from '@heronjs/common'; import {defineWorker} from '@heronjs/common'; @injectable() class Calculator implements Runnable<number, number> { run(input: number, context: RunContext) { context.signal.throwIfAborted(); return input * input; } } @Module({providers: [Calculator]}) class JobsModule {} export const worker = defineWorker({module: JobsModule, tasks: {square: Calculator}});

Configure named pools when creating the application:

const app = await HeronJS.create({ module: AppModule, runtime: { threads: { pools: { cpu: { entry: new URL('./workers.js', import.meta.url), workers: 2, maxQueue: 64, }, }, }, }, }); await app.start(); // HTTP remains the default.

Inject ThreadManager into an ordinary provider or controller using @Inject(ThreadManager). Call threads.run('cpu', 'square', 12) for a Promise<unknown> or threads.run$('cpu', 'square', 12) for a cold Observable<unknown>. Validate results at this cross-process boundary:

const result = await threads.run('cpu', 'square', 12); if (typeof result !== 'number') throw new TypeError('Expected a numeric worker result'); console.log(result + 1);

Each subscription submits a distinct job. Jobs return their first emission and finish scope cleanup before delivering the result. Use compiled ESM file URLs for bootstraps and DI bridges. The packaged worker entry is ESM. The default export name is worker; override it with exportName.

Import ThreadManager from @heronjs/thread or its common re-export. Outside application startup, use createThreadManager(container, options) from @heronjs/common to select the framework DI bridge. The lower-level constructor in thread accepts a parent resolver and an explicit bridge module or a worker’s own createContainer factory. There is no dependency from thread to common.

DI lifetimes and parent services

Worker modules load their imports and providers without starting application transports or ordinary services. setup(container, config) can asynchronously initialize clients and register provider values. config must be cloneable. teardown(container) runs before the worker root container is disposed.

Singletons belong to one worker. Each job has a fresh child container for Container-scoped providers, plus the injectable RUN_CONTEXT token. A singleton resolves dependencies from its owning container and cannot capture job context. Resolution-scoped and transient providers retain their existing meanings.

Constructed instances with Symbol.asyncDispose, Symbol.dispose, or dispose are cleaned up in reverse construction order. The first available disposal method is used. registerInstance values remain externally owned; use registerOwnedInstance or onDispose to register cleanup explicitly. Container dispose() is asynchronous and idempotent, attempts all owned cleanup, and aggregates failures. Application shutdown releases owned instances after stopping workers and services. disposeInstances() retains registrations and external values while releasing their owned instances. Register cleanup for resources created by factory providers.

To reuse a parent-owned service, expose a small method interface:

// Parent configuration; the token is resolved only in the parent container. parentServices: { catalog: { token: CatalogService, methods: ['lookup'] } } // Worker definition; this token is local to the worker. parentServices: { catalog: 'catalog-proxy' } // Worker constructor. constructor(@Inject('catalog-proxy') private catalog: { lookup(id: string): Promise<{ name: string }> }) {}

Parent proxies always return Promises. The declared proxy interface must match the exposed service; raw container.resolve('catalog-proxy') returns unknown, so validate its callable methods and returned data before using it directly. Parent methods may return values, Promises, or Observables; unary normalization applies. Aliases and allowed methods cross the message boundary, while tokens, functions, live containers, client handles, and class identity remain local. Arguments and results must support structured cloning. Parent services are kept alive until worker shutdown completes.

Dedicated services

Export a provider in defineWorker({ services: { ticker: Ticker } }), implementing RunnableService<I, O> from core. Its run(input, context) may return an Observable or a Promise that remains active until cancellation. Optional onStart(context) and onStop(context) hooks support asynchronous completion.

Configure runtime.threads.services.clock with an entry, service: 'ticker', and optional input. Configured services start with the application, each in a dedicated worker. threads.startService('clock') also supports explicit startup and returns an existing service’s handle after startup has finished.

A handle exposes events$, state$, and idempotent stop(). Events replay up to maxBufferedEvents recent values and then stream live. Unsubscribing from events does not stop the service. Service completion closes its worker; failures are reported through the event/state streams. Services are not restarted automatically.

Scheduling, cancellation, and shutdown

SettingDefault
Workers per poolmax(1, min(4, availableParallelism() - 1))
Jobs executing per worker1
Waiting jobs per pool64
Startup timeout30 seconds
Job timeout, including queue time30 seconds
Parent RPC timeout30 seconds
Pending RPC calls per worker64
Service events awaiting acknowledgement64
Cancellation/shutdown grace5 seconds

Pool settings include workers, maxQueue, and jobTimeout. Worker entries also accept startupTimeout, shutdownTimeout, rpcTimeout, maxPendingRpc, and maxBufferedEvents. Durations are milliseconds. A per-call { signal, timeout } overrides job cancellation/deadline; timeout: 0 disables the job deadline.

Cancellation aborts RunContext.signal and unsubscribes the handler. A cancelled native Promise keeps the slot until it settles. Workers that exceed cancellation grace are terminated and replaced. A crash rejects its active job once and replaces the worker; jobs are never replayed. Failed replacement bootstrap fails the pool. Event-buffer overflow fails the service rather than accumulating data.

app.close() rejects queued jobs, aborts active work, awaits service hooks and owned cleanup, and terminates unresponsive workers. Worker stdout and stderr are drained with their contents redacted; the parent emits a fixed notice once per worker. This preserves MCP/ACP protocol stdout and prevents raw worker output from bypassing the logging privacy boundary. Standalone ThreadManager/Thread instances are owned by their caller and must be closed.


Based on 4.0.0 release source  (4.0.0).

Last updated on