7.1 KiB
Design: @suaveplan/web-workers
Browser-tier Web Worker / SharedWorker / Worker-pool runtime, built on top of
@suaveplan/events (EventBus), @suaveplan/error (SuaveplanError,
defineCodes), @suaveplan/opentelemetry (counters / histograms), and
@suaveplan/types (Clock, IdGenerator).
File layout
packages/foundation/data/web-workers/
package.json
README.md
tsconfig.lib.json
vite.config.ts
vitest.config.ts
src/
index.ts
base-manager/
base-manager.ts # protected lifecycle + RPC state shared by classic + shared
base-manager.md
base-manager.test.ts
base-manager.types.ts
worker-manager/
worker-manager.ts # classic Worker manager
worker-manager.md
worker-manager.test.ts
shared-worker-manager/
shared-worker-manager.ts # SharedWorker manager (port-per-client)
shared-worker-manager.md
shared-worker-manager.test.ts
worker-pool/
worker-pool.ts # FIFO + round-robin + least-busy + restart-on-crash + health probe
worker-pool.md
worker-pool.test.ts
scheduler.ts
scheduler.md
scheduler.test.ts
message-bus/
message-bus.ts # in-process pub/sub + request/response
message-bus.md
message-bus.test.ts
streaming/
stream.ts # sendStream → AsyncIterable<T>
stream.md
stream.test.ts
circuit-breaker/
circuit-breaker.ts # open / half-open / closed, configurable thresholds
circuit-breaker.md
circuit-breaker.test.ts
helpers/
define-worker.ts # defineWorker({ url, type })
define-worker.md
define-worker.test.ts
observability/
observability.ts # createWebWorkersObservability
observability.md
observability.test.ts
observability.types.ts
errors/
errors.ts # WEB_WORKERS_CODES + WebWorkersError
errors.md
errors.test.ts
types/
web-workers.types.ts # public type definitions
web-workers.types.md
Key design decisions
BaseWorkerManager shares lifecycle + RPC state
Both WorkerManager (classic Worker) and SharedWorkerManager (SharedWorker
port) need:
- a named registry of managed instances,
- per-instance pending-request maps with timers + AbortSignal,
- circuit breaker state,
- backpressure ceiling on
maxPendingRequests, EventBus<WorkerManagerEventMap>emission.
The base class owns all of that. The two concrete classes only differ in how
they instantiate the underlying transport (new Worker vs
new SharedWorker(...).port) and how terminate cleans up (worker.terminate()
vs port.close()).
EventBus payload reshape
Upstream WorkerManagerEventMap uses variadic listener args
("worker:created": [name: string, config: WorkerConfig]). The genesis
@suaveplan/events EventBus takes a single payload per listener. We reshape
every event into an object payload:
{ name: string, config: WorkerConfig }. Consumers gain a self-documenting
payload and we keep the contract testable.
WorkerPool scheduling
- Round-robin (
scheduler: "round-robin", default): eachsubmit()picksworkers[next++ % workers.length]. Cheap, fair, deterministic. - Least-busy (
scheduler: "least-busy"): pick the worker with the smallestpendingResponses.size. Better for skewed task durations. - FIFO queue when the pool is saturated (
maxConcurrentreached): tasks wait on a queue, drained as workers free up. - Backpressure: queue has a
maxQueueDepth; over-quota submits reject withWEB_WORKERS.QUEUE_FULL. - Restart-on-crash: when a managed worker emits
error, the pool terminates it, increments a restart counter, and re-creates a fresh one from the sameWorkerConfig.maxRestartsper worker bounds the loop; exceeding it removes the slot and rejects in-flight requests withWEB_WORKERS.RESTART_EXHAUSTED. - Health probe: optional
healthCheck: (worker) => Promise<boolean>runs on an interval; failing workers are restarted.
AbortSignal + transfer list + backpressure
sendRequest accepts an optional { signal?, transfer?, priority? }. Aborts
reject with WEB_WORKERS.REQUEST_ABORTED; transfers go through the second
argument of postMessage. The pending-request map has a configurable ceiling
(maxPendingRequests, default 1024) — over-quota requests reject immediately
with WEB_WORKERS.BACKPRESSURE.
Circuit breaker
Per-worker breaker tracks consecutive WEB_WORKERS.REQUEST_TIMEOUT /
WEB_WORKERS.WORKER_ERROR failures. After failureThreshold it opens for
resetMs; while open, sendRequest rejects with
WEB_WORKERS.CIRCUIT_OPEN. After cool-down it goes half-open: the next
request is allowed; success closes it, failure re-opens.
Streaming RPC
sendStream<TReq, TChunk>(name, type, payload) returns
AsyncIterable<TChunk>. The worker pushes { type, payload: { chunk }, id, sequence }
frames terminated by { type, payload: { done: true }, id }. Cancellation:
AsyncIterator.return() cancels via AbortSignal and emits a final
{ type: "stream:cancel", id } to the worker.
defineWorker helper
defineWorker({ url, type = "module" })
Returns a callable WorkerFactory that does
new Worker(url, { type }). The helper isolates the "must use
new URL('./worker.ts', import.meta.url)" pattern from consumer code and
makes the factory injectable into managers without re-typing the options
object.
Error registry
WEB_WORKERS.DUPLICATE_WORKER 409
WEB_WORKERS.WORKER_NOT_FOUND 404
WEB_WORKERS.WORKER_TERMINATED 410
WEB_WORKERS.REQUEST_TIMEOUT 504
WEB_WORKERS.REQUEST_ABORTED 499
WEB_WORKERS.WORKER_ERROR 500
WEB_WORKERS.BACKPRESSURE 429
WEB_WORKERS.QUEUE_FULL 429
WEB_WORKERS.CIRCUIT_OPEN 503
WEB_WORKERS.RESTART_EXHAUSTED 503
WEB_WORKERS.HANDLER_NOT_FOUND 404
WEB_WORKERS.STREAM_ABORTED 499
WEB_WORKERS.INVALID_TRANSFER 400
Each code is reachable from a real call site; every code is tested.
OTel instruments
Counters:
web_workers_messages_sent_totalweb_workers_messages_received_totalweb_workers_workers_created_totalweb_workers_workers_terminated_totalweb_workers_restarts_totalweb_workers_errors_total
Histograms:
web_workers_rpc_latency_msweb_workers_queue_depthweb_workers_pool_active_workers
Created via @suaveplan/opentelemetry's createCounter / createHistogram,
identical pattern to @suaveplan/clipboard.
Test strategy
- No real
WorkerAPI. We ship aFakeWorker/FakeSharedWorkertest helper inside*.test.ts. The injectedWorkerFactory/SharedWorkerFactoryreturn fake instances that simulate message + error events via aMessageChannelmirror. This is not a mock — it is a real Web-Worker-protocol implementation in-process, which satisfies CLAUDE rule 2 (novi.mock/jest.mock). - Coverage: 100/100/100/100 enforced by
vitest.config.ts. - All
it()carry{ timeout: 5000 }(async) / 30000 (long). - Tests import runner primitives from
@suaveplan/testing/runnerper CLAUDE rule 11.