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
| Setting | Default |
|---|---|
| Workers per pool | max(1, min(4, availableParallelism() - 1)) |
| Jobs executing per worker | 1 |
| Waiting jobs per pool | 64 |
| Startup timeout | 30 seconds |
| Job timeout, including queue time | 30 seconds |
| Parent RPC timeout | 30 seconds |
| Pending RPC calls per worker | 64 |
| Service events awaiting acknowledgement | 64 |
| Cancellation/shutdown grace | 5 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).