5.2 KiB
base-manager
(no submodules)
Purpose
base-manager.ts declares BaseWorkerManager<TTransport>, the abstract
class that hosts everything shared between the classic-Worker manager
(WorkerManager) and the SharedWorker manager (SharedWorkerManager).
Both concrete classes extend this base and only override
createTransport, terminateTransport, and postToTransport — the rest
of the lifecycle, RPC state, AbortSignal wiring, transfer-list validation,
backpressure ceiling, circuit-breaker integration, OTel emission, and
EventBus lifecycle events all live here.
Centralising this logic is what makes the contract testable in isolation
and identical across transports: the same terminate-rejects-pending
semantics, the same backpressure code, the same OTel counter increments
fire regardless of whether the underlying transport is a Worker or a
SharedWorker port.
Features
- Per-name worker registry with
idle / busy / error / terminatedstatus. - Pending-request map with timeout + AbortSignal + backpressure ceiling
(
maxPendingRequests). - Per-worker circuit breaker integration (
config.circuitBreaker). - Transfer-list validation (rejects non-array
transferwithINVALID_TRANSFER). EventBus<WorkerManagerEventMap>emission on every lifecycle event.- OTel counter increments on every send / receive / create / terminate.
- RPC latency histogram observation on every fulfilled request.
- Multi-handler
onErrorregistry.
Usage: Basic Example
BaseWorkerManager is abstract — consumers always work with WorkerManager
or SharedWorkerManager directly. The shared API surface that
BaseWorkerManager exposes is therefore consumed via the subclasses:
import { WorkerManager } from "@suaveplan/web-workers";
const manager = new WorkerManager({ factory });
manager.create({ name: "hash", url: WORKER_URL });
const digest = await manager.sendRequest<string, string>(
"hash",
"sha256",
"hello"
);
Usage: Advanced Example
Subclassing for a custom transport (e.g. a MessageChannel-backed in-process
worker):
import {
BaseWorkerManager,
type BaseManagerOptions,
type WorkerConfig,
type WorkerMessage,
} from "@suaveplan/web-workers";
interface ChannelTransport {
readonly port: MessagePort;
}
class MessageChannelManager extends BaseWorkerManager<ChannelTransport> {
constructor(opts: BaseManagerOptions) {
super(opts);
}
protected override createTransport(config: WorkerConfig): ChannelTransport {
const { port1, port2 } = new MessageChannel();
port1.onmessage = (event) => {
this.handleMessage(
config.name,
event.data as WorkerMessage
);
};
port1.start();
// hand `port2` to the worker side somehow (e.g. via postMessage)
return { port: port1 };
}
protected override terminateTransport(t: ChannelTransport): void {
t.port.close();
}
protected override postToTransport(
t: ChannelTransport,
message: unknown,
transfer: Transferable[] | undefined
): void {
if (transfer !== undefined && transfer.length > 0) {
t.port.postMessage(message, transfer);
} else {
t.port.postMessage(message);
}
}
}
API Reference
BaseWorkerManager<TTransport>— abstract class.events: EventBus<WorkerManagerEventMap>— lifecycle event bus.create(config: WorkerConfig)— throwsDUPLICATE_WORKER.terminate(name)— throwsWORKER_NOT_FOUND; rejects pending withWORKER_TERMINATED.terminateAll()— iteratesterminate(name)for each.has(name): boolean.getStatus(name): WorkerStatus— throwsWORKER_NOT_FOUND.getWorkerNames(): string[].onError(handler: (name, error) => void): void.postMessage<T>(name, type, payload, options?)— fire-and-forget; validates transfer.sendRequest<TReq, TRes>(name, type, payload, options?)— request / response with timeout, signal, transfer, backpressure, circuit breaker.- Protected abstract methods:
createTransport,terminateTransport,postToTransport. - Protected helpers:
handleMessage,handleError,getEntry. ManagedEntry<TTransport>— exported internal entry type for subclass visibility.
Implementation Notes
The constructor is protected — only subclasses can instantiate. The
internal PendingEntry interface stores both the timer handle and the
cleanupSignal closure so both can be torn down in any of the three exit
paths (success, abort, terminate, timeout). The entry.pending.delete(id)
call happens unconditionally in both the timer + abort paths so a single
race where the abort fires after the timer body started running still
leaves the pending map in a consistent state. dispatchRequest is split
out of sendRequest to keep both methods under the 50-line soft limit and
to give sendRequest a clean place to do the synchronous pre-flight
(backpressure check, pre-aborted signal, transfer validation, breaker
probe). The status field flips busy → idle only when the pending count
hits zero, so concurrent in-flight requests don't prematurely reset the
status.